Compare commits

..
60 changed files with 1681 additions and 1513 deletions

No files matched your search

+5 -11
View File
@@ -1245,17 +1245,6 @@ Highlight the focused split pane:
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.title": "Active grants",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.empty": "No active grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
@@ -1269,6 +1258,11 @@ Highlight the focused split pane:
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.status": "Notices: {count}",
"settings.plugins.info": "Plugin information",
"settings.plugins.manager.noVaultShort": "Secret storage unavailable",
"settings.plugins.market.offlineShort": "Offline catalog · installation unavailable",
"settings.plugins.market.readmeInfo": "About this README",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
+5 -11
View File
@@ -1245,17 +1245,6 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.title": "Autorizaciones activas",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones activas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
@@ -1269,6 +1258,11 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.status": "{count} avisos",
"settings.plugins.info": "Información de plugins",
"settings.plugins.manager.noVaultShort": "Almacenamiento de secretos no disponible",
"settings.plugins.market.offlineShort": "Catálogo sin conexión · instalación no disponible",
"settings.plugins.market.readmeInfo": "Acerca de este README",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
+5 -11
View File
@@ -1140,17 +1140,6 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.title": "Действующие разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Действующих разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
@@ -1164,6 +1153,11 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.status": "Уведомления: {count}",
"settings.plugins.info": "Информация о плагинах",
"settings.plugins.manager.noVaultShort": "Хранилище секретов недоступно",
"settings.plugins.market.offlineShort": "Каталог офлайн · установка недоступна",
"settings.plugins.market.readmeInfo": "Об этом README",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
+5 -11
View File
@@ -1160,17 +1160,6 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.title": "实际授权",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.empty": "没有有效授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
@@ -1184,6 +1173,11 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.status": "{count} 项提醒",
"settings.plugins.info": "插件说明",
"settings.plugins.manager.noVaultShort": "凭据存储不可用",
"settings.plugins.market.offlineShort": "目录离线 · 暂不可安装",
"settings.plugins.market.readmeInfo": "README 来源说明",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
+5 -11
View File
@@ -1163,17 +1163,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.title": "實際授權",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.empty": "沒有有效授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
@@ -1193,6 +1182,11 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.status": "{count} 項提醒",
"settings.plugins.info": "外掛說明",
"settings.plugins.manager.noVaultShort": "憑據儲存無法使用",
"settings.plugins.market.offlineShort": "目錄離線 · 暫時無法安裝",
"settings.plugins.market.readmeInfo": "README 來源說明",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
+1 -1
View File
@@ -41,7 +41,7 @@ export function usePluginManager() {
}
}, [refresh]);
return {
plugins, available, busy, error, credentialStorageAvailable, refresh,
plugins, available, busy, error, credentialStorageAvailable,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('information previews on hover/focus, pins on click and Escape returns focus without closing the manager', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginInfo } = await import('./PluginInfo');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginInfo label="Plugin help">Packages remain disabled until reviewed.</PluginInfo><button data-other>Other control</button></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
const trigger = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin help"]')!;
const other = document.querySelector<HTMLButtonElement>('[data-other]')!;
assert.ok(trigger); assert.equal(trigger.getAttribute('title'), null, 'not a native title-only hint');
await act(async () => { other.focus(); await settle(); });
const hover = new MouseEvent('pointermove', { bubbles: true });
Object.defineProperty(hover, 'pointerType', { value: 'mouse' });
await act(async () => { trigger.dispatchEvent(hover); await settle(260); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Packages remain disabled/);
await act(async () => { trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'));
await act(async () => { trigger.click(); await settle(); });
const pinned = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Plugin help"]');
assert.ok(pinned, 'click exposes a persistent readable popover');
assert.equal(document.querySelector('[role="tooltip"]'), null);
await act(async () => { pinned.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"][aria-label="Plugin help"]'), null);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'Escape closes only the information layer');
assert.equal(document.activeElement, trigger);
assert.equal(Boolean(document.querySelector('[role="tooltip"]')), false, 'Escape does not immediately reopen the focus preview');
await act(async () => { other.focus(); trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'), 'a subsequent deliberate focus still previews help');
const { PluginMarketplaceContent } = await import('./PluginMarketplace');
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
return <I18nProvider locale="en"><div data-toolbar ref={setToolbar} /><div data-settings-scroll-pane><PluginMarketplaceContent catalog={catalog} installed={[]} selected={selected} onSelect={setSelected} toolbarContainer={toolbar} /></div></I18nProvider>;
}
await act(async () => { root.render(<Browser />); await settle(); });
const pane = document.querySelector<HTMLElement>('[data-settings-scroll-pane]')!;
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
pane.scrollTop = 450;
await act(async () => { document.querySelector<HTMLButtonElement>('[data-plugin-id]')!.click(); await settle(); });
assert.equal(document.querySelector('.plugin-marketplace-catalog'), null, 'detail replaces the candidate list');
assert.doesNotMatch(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/, 'list refresh stays out of detail');
assert.equal(pane.scrollTop, 0);
assert.equal(document.activeElement?.tagName, 'H4');
assert.equal(document.activeElement?.textContent, 'Guide');
const back = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('All plugins'))!;
await act(async () => { back.click(); await settle(); });
assert.equal(pane.scrollTop, 450);
assert.equal(document.activeElement?.getAttribute('data-plugin-id'), entry.id);
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
const { PluginStatus } = await import('./PluginStatus');
function StatusHarness() {
const [offline, setOffline] = useState(true);
const [noVault, setNoVault] = useState(true);
return <I18nProvider locale="en"><PluginStatus noVault={noVault} offlineError={offline ? 'Network unavailable' : null} retry={() => setOffline(false)} /><button onClick={() => setNoVault(false)}>Restore vault</button></I18nProvider>;
}
await act(async () => { root.render(<StatusHarness />); await settle(); });
const status = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 2"]')!;
await act(async () => { status.focus(); await settle(); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Network unavailable/);
assert.equal(document.querySelector('[role="tooltip"] button'), null, 'hover preview never contains an interactive Retry');
await act(async () => { status.click(); await settle(); });
const retry = document.querySelector<HTMLButtonElement>('[role="dialog"] button')!;
assert.equal(retry.textContent, 'Retry');
assert.equal(status.textContent, '2', 'only the number badge is visible, not a second text label');
assert.equal(document.querySelectorAll('button[aria-label*="Notices:"]').length, 1);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
assert.ok(document.querySelector('[role="dialog"] hr'));
await act(async () => { retry.click(); await settle(); });
assert.ok(document.querySelector('button[aria-label$="Notices: 1"]'));
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /Network unavailable/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.match(document.activeElement?.getAttribute('aria-label') ?? '', /Notices: 1$/);
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Restore vault')!.click(); await settle(); });
const healthy = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin information"]')!;
assert.ok(healthy, 'one information control remains when healthy');
assert.equal(healthy.querySelector('span[aria-hidden="true"]'), null, 'healthy control has no count badge');
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
+25
View File
@@ -0,0 +1,25 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
return <Popover open={pinned} onOpenChange={(open) => { setPinned(open); setPreview(false); }}>
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { before, after } from 'node:test';
import { JSDOM } from 'jsdom';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
@@ -8,6 +9,19 @@ import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginMarketplaceContent } from './PluginMarketplace';
import type { usePluginCatalog } from '../../../application/state/usePluginCatalog';
const infoDom = new JSDOM('<!doctype html><html><body></body></html>');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
before(() => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: infoDom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: infoDom.window.document });
});
after(() => {
infoDom.window.close();
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); else Reflect.deleteProperty(globalThis, 'window');
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); else Reflect.deleteProperty(globalThis, 'document');
});
const entries = ['Alpha', 'Beta'].map((name) => ({
id: `com.example.${name.toLowerCase()}`, name, author: 'Example', version: '1.0.0', license: 'MIT',
description: `${name} local utility`, repository: 'example/plugins', commit: 'a'.repeat(40),
@@ -39,16 +53,13 @@ test('catalog keeps search during selection/back, switches detail, and displays
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Alpha');
assert.equal(cards()[0].props['aria-current'], 'true');
assert.equal(cards().length, 0, 'detail replaces the entire grid at every width');
assert.equal(renderer.root.findAllByType('input').length, 0);
const detailText = JSON.stringify(renderer.toJSON());
assert.match(detailText, /0.9.0/);
assert.match(detailText, /Disabled/);
assert.match(detailText, /Incompatible with this host/);
assert.match(detailText, /Last recorded error/);
assert.match(cards()[0].props.className, /bg-primary\/10/);
assert.match(cards()[0].props.className, /border-primary\/40/);
assert.doesNotMatch(cards()[0].props.className, /bg-accent|text-accent-foreground/);
assert.match(detailText, /1 plugins/);
assert.equal(renderer.root.findAllByType('details').length, 0, 'permissions are not hidden in a disclosure');
assert.equal(renderer.root.findAll((node) => node.type === 'button' && node.children.includes('Install this version (disabled)')).length, 0, 'source-only cards have no install action');
const back = () => renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
@@ -56,15 +67,12 @@ test('catalog keeps search during selection/back, switches detail, and displays
assert.equal(renderer.root.findAllByType('article').length, 0);
assert.equal(input().props.value, 'Alpha');
await act(async () => input().props.onChange({ target: { value: '' } }));
await act(async () => cards()[0].props.onClick());
await act(async () => cards()[1].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta');
assert.equal(cards()[1].props['aria-current'], 'true');
assert.equal(cards()[0].props['aria-current'], undefined);
assert.equal(cards().length, 0);
await act(async () => back().props.onClick());
await act(async () => input().props.onChange({ target: { value: 'no match' } }));
assert.equal(cards().length, 0);
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta', 'filtering does not erase the selected detail');
await act(async () => back().props.onClick());
assert.equal(input().props.value, 'no match');
await act(async () => renderer.unmount());
} finally {
@@ -81,29 +89,29 @@ test('README failure leaves the permissions and source visible, before the failu
assert.match(html, /Compatibility and permissions/);
assert.match(html, /settings.read/);
assert.match(html, /not a grant/);
assert.match(html, /Build with the local CLI/);
assert.match(html, /Source example/);
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
assert.match(html, /Retry/);
assert.doesNotMatch(html, /<details/);
});
test('responsive layout is scoped to marketplace content width, with a narrow detail-only fallback', () => {
test('cards use subtle shadows and details have no breakpoint-specific columns or nested scroller', () => {
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
assert.match(css, /\.plugin-marketplace \{ container-type: inline-size;/);
assert.match(css, /@container \(min-width: 820px\)/);
assert.match(css, /grid-template-columns: 248px minmax\(0, 1fr\)/);
assert.match(css, /plugin-marketplace-browser--selected \.plugin-marketplace-catalog \{ display: none;/);
assert.match(css, /box-shadow: 0 1px 4px/);
assert.match(css, /\.dark \.plugin-card/);
assert.doesNotMatch(css, /@container|248px|max-height|overflow-y/);
});
test('narrow detail starts at the top and back restores the settings scroll and card focus', async () => {
test('full-page detail starts at the top and back restores the settings scroll and card focus', async () => {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
const pane = { scrollTop: 450 };
let focused = false;
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 700 } : pane,
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 1100 } : pane,
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
let renderer!: ReactTestRenderer;
function Harness() {
@@ -111,12 +119,14 @@ test('narrow detail starts at the top and back restores the settings scroll and
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected} installed={[]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' ? list : { scrollTo() {}, focus() {} } }); });
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' && (element.props as { className?: string }).className?.includes('plugin-marketplace-catalog') ? list : null }); });
const card = renderer.root.findAll((node) => node.type === 'button' && node.props['data-plugin-id'] === entries[0].id)[0];
await act(async () => card.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 0);
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 450);
assert.equal(focused, true);
await act(async () => renderer.unmount());
@@ -131,11 +141,10 @@ test('offline catalog warning keeps details visible and disables market installa
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable], listError: 'Registry offline', source: 'cached' }}
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Registry offline/);
assert.match(html, /Installation is unavailable until refresh succeeds/);
assert.match(html, /Offline catalog · installation unavailable/);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
assert.match(html, /Retry/);
assert.match(html, /Notices: 1/);
});
test('manual refresh remains available after success and is disabled while loading', () => {
@@ -144,3 +153,12 @@ test('manual refresh remains available after success and is disabled while loadi
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
});
test('replacing an installed plugin discloses that its running version will stop beside the install action', () => {
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
});
+27 -28
View File
@@ -1,3 +1,6 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
@@ -5,18 +8,19 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
import { Input } from '../../ui/input';
import { Button } from '../../ui/button';
import { SettingCard } from '../settings-ui';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [] }: { installed?: NetcattyInstalledPlugin[] }) {
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected);
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} />;
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect }: {
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
installed: NetcattyInstalledPlugin[];
selected: string | null;
@@ -25,23 +29,19 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
const { t } = useI18n();
const [search, setSearch] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailRef = useRef<HTMLHeadingElement>(null);
const lastSelection = useRef<string | null>(null);
const listScroll = useRef(0);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const installedEntry = installed.find((item) => item.id === entry?.id);
const choose = (id: string) => {
listScroll.current = listRef.current?.scrollTop ?? 0;
if (!entry) pageScroll.current = listRef.current?.closest('[data-settings-scroll-pane]')?.scrollTop ?? 0;
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
lastSelection.current = id;
onSelect(id);
requestAnimationFrame(() => {
detailRef.current?.scrollTo?.(0, 0);
const container = listRef.current?.closest<HTMLElement>('.plugin-marketplace');
const pane = listRef.current?.closest('[data-settings-scroll-pane]');
if (container && container.clientWidth < 820 && pane) pane.scrollTop = 0;
if (pane) pane.scrollTop = 0;
detailRef.current?.focus({ preventScroll: true });
});
};
@@ -49,7 +49,6 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
onSelect(null);
requestAnimationFrame(() => {
if (listRef.current) {
listRef.current.scrollTop = listScroll.current;
const pane = listRef.current.closest('[data-settings-scroll-pane]');
if (pane) pane.scrollTop = pageScroll.current;
const button = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[data-plugin-id]'))
@@ -58,14 +57,14 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
}
});
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
<div className="flex items-start justify-between gap-3"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.note')}</p><Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button></div>
{catalog.listStatus === 'ready' && catalog.listError && <div role="status" className="space-y-2 rounded-lg border p-3 text-xs text-muted-foreground"><p>{t('settings.plugins.market.offline')}</p><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className={`plugin-marketplace-browser${entry ? ' plugin-marketplace-browser--selected' : ''}`}>
<div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
{catalog.listStatus === 'ready' && <p className="text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
<div className="flex items-center gap-3"><Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
{catalog.listStatus === 'ready' && <p className="shrink-0 text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}</div>
{catalog.listStatus === 'ready' && catalog.entries.length > 0 && filtered.length === 0 && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{catalog.listStatus === 'loading' && <p className="text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
@@ -73,26 +72,26 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id);
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className={`min-w-0 flex flex-col gap-3 rounded-lg border p-4 text-left ${entry ? '' : 'min-h-[200px]'} transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${selected === item.id ? 'border-primary/40 bg-primary/10 text-foreground' : 'border-border bg-card hover:bg-muted/50'}`}>
<div className="flex items-start gap-2"><PluginBrandIcon entry={item} size={32} className="mt-0.5 shrink-0 text-muted-foreground" /><span className="min-w-0 break-words text-sm font-semibold">{item.name}</span></div>
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded border px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
</button>;
})}</div>
</div>
{entry && <article key={entry.id} ref={detailRef} tabIndex={-1} aria-label={entry.name} className="plugin-marketplace-detail min-w-0 space-y-5 rounded-lg border bg-card p-4 focus:outline-none">
</div>}
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 className="break-words text-xl font-semibold">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p><Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <p className="rounded-lg bg-muted/40 p-3 text-xs leading-5">{t('settings.plugins.market.sourceOnly')}</p>}
<SettingCard padded><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></SettingCard>
<div className="min-w-0 border-t pt-4"><h4 className="text-sm font-semibold">README</h4><p className="mt-2 text-xs text-muted-foreground">{t('settings.plugins.market.readmeNote')}</p>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
@@ -1,7 +1,6 @@
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
@@ -76,14 +75,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
const [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg border bg-card p-4 focus:outline-none ${selectedPlugin ? 'space-y-4' : 'flex min-h-[200px] flex-col gap-3'}`}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
@@ -109,7 +107,6 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
@@ -1,69 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const root = createRoot(document.getElementById('root')!);
const initial = {
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1 }],
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
};
let reads = 0;
let revokes = 0;
let changed = 0;
let staleRead: (value: typeof initial) => void = () => {};
let releaseRevoke: () => void = () => {};
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
Object.assign(dom.window, { netcatty: {
async getPluginPermissions(id: string) {
assert.equal(id, 'com.example.synthetic');
reads++;
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
return reads > 2 ? { grants: [], audit: [] } : initial;
},
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
} });
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
assert.match(document.body.textContent!, /synthetic.invalid/);
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
assert.match(document.body.textContent!, /Until revoked/);
await act(async () => { button('Refresh').click(); });
await act(async () => {
button('Disable and revoke all grants').click();
button('Disable and revoke all grants').click();
});
assert.equal(revokes, 1);
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No active grants/);
await act(async () => { staleRead(initial); });
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
await act(async () => { button('Refresh').click(); });
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -1,75 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
pluginId: string;
onChanged: () => Promise<void>;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
const result = await bridge.getPluginPermissions(pluginId);
if (current === generation.current) setSnapshot(result);
} catch (cause) {
if (current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
}
}, [pluginId, t]);
useEffect(() => {
const requestGeneration = generation;
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const revoke = async () => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
await onChanged();
await refresh();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { pending.current = false; setBusy(false); }
};
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
</div>)}
<details className="text-xs">
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
</section>;
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -3,6 +3,7 @@ import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
@@ -286,6 +287,7 @@ export function PluginSettingField({
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const availableFonts = useAvailableFonts();
@@ -314,10 +316,13 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
</div>
{manager.credentialStorageAvailable === false && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.manager.noVault')}</p>}
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} /></TabsContent>
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
@@ -1,13 +1,9 @@
/* Container breakpoints account for the Settings sidebar, unlike viewport breakpoints. */
.plugin-marketplace { container-type: inline-size; }
.plugin-marketplace-browser { display: grid; gap: 16px; min-width: 0; }
.plugin-marketplace-browser { min-width: 0; }
.plugin-marketplace-catalog { min-width: 0; }
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 12px; }
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: none; }
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 16px; }
/* Details use only the manager's main scrolling surface at every width. */
.plugin-marketplace-detail { overflow-wrap: anywhere; }
@container (min-width: 820px) {
.plugin-marketplace-browser--selected { grid-template-columns: 248px minmax(0, 1fr); align-items: start; }
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: block; max-height: min(680px, 65vh); overflow-y: auto; padding: 2px; }
.plugin-marketplace-browser--selected .plugin-marketplace-grid { grid-template-columns: minmax(0, 1fr); }
.plugin-marketplace-browser--selected .plugin-marketplace-detail { max-height: min(680px, 65vh); overflow-y: auto; }
}
.plugin-card { box-shadow: 0 1px 4px hsl(var(--foreground) / 0.06); }
.plugin-card:hover { box-shadow: 0 2px 8px hsl(var(--foreground) / 0.09); }
.dark .plugin-card { background: hsl(var(--muted) / 0.2); box-shadow: none; }
.dark .plugin-card:hover { background: hsl(var(--muted) / 0.3); box-shadow: none; }
@@ -1,98 +0,0 @@
# Batch 1 aggregate failure comparison
Frozen base: `e5bd294cddbf58dfe04c646b6291933d980498fd`. Current:
`9af123e5d7bdb15771d69fb427faacf32c46dfb3`. Same Node 24.19.0 executable,
same installed dependency tree, same sandbox and environment. Tests use existing
synthetic fixtures. No home/cache permission or native-build bypass was added.
The 39 entries below were observed before stopping the local full suite.
Later runner failures caused by that explicit termination are excluded. This
is not a successful aggregate run and is not proof that every full-suite test
was executed. The full suite was stopped after existing/environmental failures
to avoid presenting an incomplete run as green.
## Bounded comparison result
Targeted test-name patterns selected the original 39 failures across 15 files.
Thirty-eight fail on both frozen base and current with matching failure titles
and key error signatures. The persistence-volume assertion passes on both.
Three additional paired repetitions of that assertion also pass on both: four
passing runs per side including the initial comparison. Its code is unchanged;
the aggregate-only result remains a timing-sensitive limitation, not an
established current-only regression.
The first baseline rerun reproduced 11 entries: rows 1-2 and 4-12. It did not
reproduce row 3. No new source change fixes unrelated baseline failures.
| Rows | Signature and classification |
| --- | --- |
| 1-2, 14-16, 27-33, 35-39 | Host temp-dir creation fails outside writable roots (`ENOENT` from `tempDirBridge`). Both revisions fail. Environment constraint |
| 4-9 | Markdown comparison falls back to source; expected rich-preview elements are absent. Both revisions fail. Existing baseline/dependency behavior; no source repair in this scope |
| 10-12 | Native `pty.node` missing from normal build/prebuild lookup. Both revisions fail. Dependency/native setup constraint |
| 13 | UI algorithm list includes `ssh-ed25519-cert-v01@openssh.com` but installed ssh2 rejects it. Both revisions fail. Existing baseline/dependency behavior |
| 17-26 | Cursor discovery/SDK expectations differ (`false` vs `true`, then expected installation/auth DTO). Both revisions fail with matching assertion signatures. Existing environment/dependency behavior; no real account accessed |
| 34 | Electron bootstrap fetch fails and the binary is not installed. Both revisions fail. Dependency/network setup constraint |
| 3 | Persistence writes exceed aggregate threshold; isolated baseline/current and three repetitions each pass. Aggregate-only timing-sensitive assertion; no current-only failure reproduced |
## Original titles and first error
| # | Test file | Original title | First key error | Paired result |
| --- | --- | --- | --- | --- |
| 1 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=false | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 2 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=true | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 3 | `application/state/sftpTransferCenterStore.test.ts` | large directory progress coalesces synchronous persistence work | AssertionError [ERR_ASSERTION]: expected bounded persistence volume, wrote 2638624 characters for a 588644-character snapshot | base/current pass; aggregate-only |
| 4 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | literal Markdown comparisons render without changing the note source | AssertionError [ERR_ASSERTION]: a comparison must render as ordinary text | base/current same failure |
| 5 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | comparison variants render in paragraphs, tables and alongside HTML | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 6 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | issue 3205 author note survives source, preview and edit without rewriting | AssertionError [ERR_ASSERTION]: preview must render the author's note | base/current same failure |
| 7 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | editing a comparison emits Markdown that can be reopened | assert.ok(editable): actual null | base/current same failure |
| 8 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | preview task checkboxes still save explicit changes without rewriting comparisons | assert.ok(task): actual null | base/current same failure |
| 9 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | switching between a comparison note and an unsupported note keeps the fallback scoped | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 10 | `components/terminal/runtime/serialInputSequences.test.ts` | components/terminal/runtime/serialInputSequences.test.ts | Failed to load native module: pty.node | base/current same failure |
| 11 | `components/terminal/runtime/terminalLinePaste.test.ts` | components/terminal/runtime/terminalLinePaste.test.ts | Failed to load native module: pty.node | base/current same failure |
| 12 | `components/terminal/runtime/terminalPasteCancellation.test.ts` | components/terminal/runtime/terminalPasteCancellation.test.ts | Failed to load native module: pty.node | base/current same failure |
| 13 | `domain/sshAlgorithmList.test.ts` | SUPPORTED_ALGORITHMS_BY_CATEGORY only lists algorithms ssh2 will actually accept | AssertionError [ERR_ASSERTION]: ssh-ed25519-cert-v01@openssh.com (serverHostKey) is in the UI list but ssh2 would reject it | base/current same failure |
| 14 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as sh cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 15 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as renamed-bash cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 16 | `electron/bridges/aiBridge.externalContext.test.cjs` | electron/bridges/aiBridge.externalContext.test.cjs | ENOENT creating host temp directory from sshBridge import | base/current same failure |
| 17 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor Agent CLI on PATH as installed without CLI login | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 18 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor SDK installed but unavailable without an API key | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 19 | `electron/bridges/aiBridge.test.cjs` | resolve-cli separates Cursor SDK installation from API key availability | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 20 | `electron/bridges/aiBridge.test.cjs` | resolve-cli ignores custom Cursor paths and stores the SDK sentinel path | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 21 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when installed and authenticated | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 22 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 23 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports settings as Cursor auth source when settings and env keys both exist | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 24 | `electron/bridges/aiBridge.test.cjs` | resolve-cli can refresh shell env before resolving Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 25 | `electron/bridges/aiBridge.test.cjs` | discover exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 26 | `electron/bridges/aiBridge.test.cjs` | discover can refresh shell env before scanning Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 27 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels resolves a Windows .cmd shim to the copilot JS entry before the SDK sees it | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 28 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels retains the official loader when its platform runtime is unavailable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 29 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotPlatformExecutable resolves the platform package exe from the loader root | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 30 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotExecutableForSdk resolves npm's copilot.ps1 wrapper to the JS entry on Windows | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 31 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | connectCopilotStdioPath directly selects the official loader's native runtime | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 32 | `electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs` | OpenCode releases the process-env gate after spawning separate catalog profiles | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 33 | `electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs` | createOpenCodeProcessEnv prefers an existing opencode binary | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 34 | `electron/bridges/aiEchoSuppressionOrdering.test.cjs` | electron/bridges/aiEchoSuppressionOrdering.test.cjs | fetch failed; Electron failed to install correctly | base/current same failure |
| 35 | `electron/bridges/autoUpdateBridge.test.cjs` | recognizes packaged Linux FPM formats as auto-update capable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 36 | `electron/bridges/autoUpdateBridge.test.cjs` | allows the update check to reach electron-updater for a packaged Linux deb | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 37 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps the manual-update fallback for an unmarked Linux package | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 38 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps AppImage auto-update support on Linux | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 39 | `electron/bridges/autoUpdateBridge.test.cjs` | install handler reports package-manager failures and restores the app state | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
## Reproduction method
For each file above, run the existing test with the same title pattern in each
checkout. Pass the title pattern as an argument, without interpolating it as
shell code. Native/import failures occur before the title filter can execute.
```sh
node --test --import tsx --test-reporter=tap --test-name-pattern "<escaped original title>" <test file>
```
The comparison used bounded 90-second subprocess deadlines. None reached that
deadline. The persistence repeat used the same existing test and a 60-second
deadline. It added no load generator or attack payload.
All 15 test files and the business modules responsible for these signatures
are unchanged by batch 1. This classification does not resolve the two
independently reported grant-management review findings; those receive
separate source fixes and tests.
@@ -1,82 +0,0 @@
# Sandbox capability coverage
This matrix tracks expansion from Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`. Each accepted item requires a
canonical schema, generated SDK, broker, permission policy, quota and audit,
host adapter, lifecycle cleanup, synthetic boundary tests, review, and exact-SHA
CI. Enumeration in a manifest is not a working API. Status `planned` does not
mean complete. Existing APIs remain available.
## Coverage matrix
| Subsystem | Working API at baseline | Enumeration or missing adapter | Accepted bounded entry | Data and action risk | Authorization resource | Revocation, audit, quota | Verification and disposition |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Authorization management | Engine `listGrants`, `revokeAll`, database audit | Manager shows declarations only | Host-only grant list, bounded audit summary, disable and revoke | Paths, session IDs, grant metadata are sensitive | Exact plugin ID, trusted app sender | Serialized manager mutation, disable before revoke, runtime teardown, retained audit | Batch 1 implemented: host/manager/UI tests. Native acceptance pending. No plugin RPC for managing grants |
| Network and credential leases | `network.request`, `credentials.createLease`, single-use host redemption | SDK request omits lease and authorization fields | Canonical request types matching broker | Network mutation and credentials | Exact origin plus secret reference and operation | Existing timeout, redirects, byte quotas, runtime checks, lease TTL | Batch 1 implemented: canonical schema and SDK, synthetic fixture and lease tests, feature negotiation. Native acceptance pending. No plaintext secret API |
| Commands, menus, toolbar, status bar, views | `commands.execute`, settings, context keys, view state and messages, UI contributions | No editor or SFTP menu slots | Existing contribution system | Plugin text and arguments may be sensitive | Namespaced contributions and permissions | Existing lifecycle, rate and message limits | Existing tests. New context menu slots planned with bounded context DTOs |
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
| Native interactions and clipboard | Native permission dialogs | Clipboard enum only. No quick-pick/input/progress/toast SDK | Host-owned prompts, explicit copy/paste with preview | Clipboard and input may contain secrets, prompt spam | Host action context plus clipboard direction | Cancellation, request count and byte caps, runtime cleanup | Planned. No background reads or trusted plugin `userGesture` boolean |
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
| Snippets and scripts | Provider import drafts only | No generic read/run API | Explicit selection and create draft | Script contents may contain secrets. Running executes commands | Selected item, read versus draft write | Preview, byte cap, no automatic execution | Planned evaluation. Script `run` and onConnect modification deferred |
| Connections and authentication | Connection/authentication providers, opaque credentials, streams | Generic host open/connect absent | Existing providers remain | Connect can execute saved onConnect scripts | Provider operation plus live endpoint and credential lease | Existing provider lifecycle, streams and quotas | Existing tests. Generic connect is a write/execution capability, deferred pending explicit execution design |
| Sync and storage | Local namespaced KV, encrypted-object sync providers, opt-in setting sidecars | No general KV cloud sync | Existing opt-in provider protocol | Conflicts, deletes, credential access | Provider/account/object, settings scope | Existing limits and lifecycle, non-cascade metadata | Existing sync tests. General KV sync deferred until conflict/delete/quota/migration policy is defined |
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, source-labelled selector, window-local preview, revision-safe apply/cancel/reset, static terminal linkage | Unreadable colors and misleading security UI. Nineteen UiThemeTokens fields are not independent: primary/accent/ring and foregrounds derive from the effective accent | User explicitly selects a theme. Separate static contribution enablement from code permissions; no generic appearance.write or runtime activation | Immutable version/hash, bounded JSON, preview owner/token/revision, disable/update/uninstall fallback, no implicit sync, fixed host security appearance | [ThemePack design](./theme-packs-design.md) accepted for implementation after current A work; unfinished until contract, security boundary, lifecycle, contrast, samples and native gates pass. No arbitrary DOM/CSS, remote assets, free layout or security UI customization |
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
## Delivery order and stopping rule
1. Batch 1: actual authorization management plus network contract consistency.
Keep the existing manager layout. Add one independent permission panel.
2. Batch 1b: host action context, native interactions, selected context and terminal
sharing/drafts. A plugin-supplied gesture is never authority.
3. Batch 2: SFTP selected-file handles and explicit transfer destinations.
4. Deliver the separately reviewed [declarative ThemePack stage](./theme-packs-design.md), including protected security surfaces, contrast gates, revision-safe preview/apply/reset, and safe fallback.
5. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
using the tested host-action and resource primitives.
Stop only when every row is implemented and tested, reasonably deferred with a
specific boundary and rationale, or awaiting a concrete user decision. A
planned row remains unfinished. Native screenshots and recordings belong to
the parent thread. Real credentials, hosts, or transfers require approval for a
specific target there. Synthetic checks do not establish native acceptance.
## Batch 1 evidence and next workflow
The independent permission panel adds only an import and a detail mount to
`components/settings/tabs/PluginPackageManager.tsx`. It uses the manager hook's
new `refresh()` after host revocation. Marketplace layout remains owned by the
parallel UI layer. Five locale files contain the panel strings.
The next usable workflow is a browser plugin that asks a host quick-pick/input
question, then explicitly copies its result or asks the user to share clipboard
text. The host displays the plugin source and action before reading or writing.
A canonical `HostInteractionRequest` and bounded result carry only text/choice
IDs. The host owns request IDs, requester webContents, runtime/version, TTL,
cancellation, and the consent result. The renderer cannot reply for another
window. Plugin `userGesture` values are ignored. Runtime/revoke shutdown aborts
all outstanding prompts and ignores late replies. A shared prompt primitive
then supports selected host/session context, selection share, and input drafts.
Before proceeding to the sensitive context/terminal and SFTP entries, the
native path must prove consent, cancellation, quota, and identity binding with
synthetic data. UI screenshots and real-resource approval remain parent-owned.
Baseline code references: `electron/plugins/hostService.cjs`,
`secureCapabilities.cjs`, `permissionEngine.cjs`, `terminalProviderService.cjs`,
`filesystemBroker.cjs`, `extensionProviderService.cjs`,
`packages/plugin-sdk/src/index.ts`, and
`packages/plugin-contract/schema/plugin-contract.schema.json`. Host business
implementations under `electron/capabilities/catalog/` are reference material,
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
plugin DTO sanitizer.
+74 -46
View File
@@ -1,56 +1,84 @@
# Official registry client
# Official static distribution client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
Community starts at the fixed main-process endpoint
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
endpoint. All automatic catalog, README and potential package reads allow only
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
source links may still open GitHub in an external browser.
## Catalog contract
## Snapshot contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
SHA-256. The host checks the pointer before requesting its three documents,
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
identity. The three documents must all validate before replacing any cache.
## Selection and installation
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
plugin: this is catalog admission, not a remote installed-code kill switch.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
fixtures described there. All schema references resolve locally; schema validation
does not fetch GitHub or netcatty.com.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Bounds, cache and selection
## Verification boundary
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
entries. Existing manifest identity, license and browser-only policies remain.
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
The host prepares private entries, private delivery resources and all public
selection tokens before one synchronous cache publication. Parse, integrity,
schema, clone, tuple or network failures preserve the last complete good snapshot.
The cache remains **in memory for this app session**; a fresh offline launch lists
bundled examples. No new disk database, sync record or credential is introduced.
A host-generated selection token binds the complete entry and its delivery item.
This prevents silent selection changes; it is **not a signature**. README requests
capture the matched static reference and verify its exact byte count and digest.
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
README HTML and remote images remain inert in the existing safe renderer.
The current v1 distribution cannot install packages. A later publishing/client
schema revision must jointly introduce reviewed package mirroring, keep source
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
ledger before any install. A historical snapshot alone cannot establish availability.
The existing lower-level package downloader accepts only official static URLs and
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
## Network inventory
| Path | Automatic source | Failure behavior |
| --- | --- | --- |
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
The real PR3 bootstrap is retained as a test fixture with its original bytes and
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
remote icon renderer. The static publisher is not a proxy. Client tests do not
prove deployment, mainland reachability or successful native CDN access. Native
QA must independently verify the published endpoint and visible failure state.
@@ -226,30 +226,3 @@ after durable user data can exist.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.
## Inspect and revoke actual grants
The installed package detail separates declared permissions from **Active
grants**. The host-only panel lists resource, scope, session (when applicable),
and grant time. One-use decisions do not appear as retained grants. Refresh
reads the engine again. Recent security events include at most 100 entries and
project only event, time, permission, and scope. Arbitrary database details,
operation IDs, terminal content, and credential material never cross this UI
boundary.
**Disable and revoke all grants** runs through the manager mutation queue. It
persists disabled state before revoking grants, invalidates contributions, and
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
leases, providers, and companions. A containment failure is reported as a
failure and leaves the plugin disabled. Explicit enable requests fresh access.
This management bridge is available only to the trusted app renderer. It is
not a plugin RPC method and grants no plugin authority over another package.
`PluginNetworkRequest` and `PluginNetworkResponse` now derive from the canonical
schema. `credentialLease` and `authorization` describe the existing host lease
injection path. Hosts advertise `network.credential-lease` for discovery without
changing existing API versions or disabling legacy requests. Plugins may
require that feature or handle its absence. Optional authorization defaults to
Bearer. Basic authorization accepts a bounded username. Plaintext is redeemed
only inside the host after origin permission and operation binding checks.
See the [synthetic fixture](../../examples/plugins/lease-http/README.md).
-215
View File
@@ -1,215 +0,0 @@
# Declarative theme packs
Status: proposed implementation plan. No theme contribution or theme permission
is implemented by this document. The baseline is Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`; batch 1 grants and network contracts
are reviewed separately in Draft #3955.
## Scope and existing behavior
A theme pack supplies validated color data. Users preview, apply, cancel, or
reset it through the host Appearance UI. Selecting a theme never starts a
plugin runtime or approves its function permissions. This extends whole-app
appearance alongside existing `terminal.theme` and `terminal.background`
Providers and isolated view styles.
Personal `customCSS` is a user-owned advanced setting, not a third-party plugin
surface. This plan introduces no stylesheet, selector, DOM, font URL, image URL,
script, layout, notification, or background permission.
`UiThemeTokens` has 19 fields. They are not independent controls:
`buildAppThemeCssVars()` assigns one effective accent to primary, accent, and
ring and computes both accent foregrounds. The initial theme contract preserves
this relationship. It must not accept independent primary, primaryForeground,
accentForeground, or ring overrides and then silently discard them.
## Contract and package admission
The proposed feature is `appearance.theme-packs`. Its schema definitions,
generated SDK exports, runtime/package compatibility, and documentation move
together. Plan an internal schema/API revision with legacy negotiation retained;
do not change the current batch 1 API just to publish this proposal. Old hosts
reject a pack that requires the unknown feature before registration.
A proposed `contributes.themes` entry contains:
| Field | Meaning and validation |
| --- | --- |
| `id` | Contribution ID under the owning plugin namespace |
| `label` | Existing bounded localized-text contract |
| `variant` | `light`, `dark`, `hc-light`, or `hc-dark` |
| `path` | Canonical package-relative JSON path inside the immutable installed version |
A theme JSON document contains a schema revision, a variant-matching built-in
base ID, bounded color overrides, and an optional static terminal palette.
It contains no cross-plugin includes, arbitrary file references, inheritance
chains, or remote resources. Resolve the validated base and overrides into one
immutable complete palette before registration. Never retain partially valid
entries from a failed theme document.
The initial allowed UI overrides are `background`, `foreground`, `card`,
`cardForeground`, `popover`, `popoverForeground`, `secondary`,
`secondaryForeground`, `muted`, `mutedForeground`, `accent`, `destructive`,
`destructiveForeground`, `border`, and `input`. Accept only opaque `#RRGGBB`
colors. The host converts them to its internal HSL representation. Reject
unknown keys, alpha, CSS expressions, `url()`, `var()`, `@import`, non-finite
values, and custom properties. Do not pass author strings to CSS parsing.
The optional terminal palette uses the existing `TerminalThemeColorName`
allowlist. It merges with a host default and is validated independently.
Static terminal colors require neither a session snapshot nor a Provider call.
Initial bounds for implementation and tests are 32 themes per package,
64 KiB per JSON file, 2 MiB total theme JSON, depth 8, and one active preview per
window. These are proposed defaults, not existing limits. The schema and host
must share the final constants. No font, image, or video assets enter v1.
### Static admission must remain distinct from code activation
The present manifest requires `main` and browser-only policy expects a browser
entrypoint. Removing either check without a separate package variant is unsafe.
For the first usable slice, a sample can retain a no-op browser entrypoint.
Static theme registration must not invoke it. Theme enablement has a separate
host-owned state from function/code enablement. Installing a pack keeps both
off until a user opts into the corresponding contribution. Enabling its themes
never sets the code-enabled flag, grants permissions, or runs activation events.
Disabling the package disables its theme contributions as well as its code.
A later exclusive data-only manifest variant can remove the no-op entrypoint.
It must reject `main`, activation events, permissions, companions, providers,
commands, and every non-theme contribution. Update canonical validation,
semantic admission, package inspection, catalog policy, manager behavior, and
compatibility tests together. The public catalog must explicitly recognize
this inert variant while retaining browser-only admission for executable packs.
A theme-only exception must not admit Node or an executable mixed package.
## Host registry and persistence
A host registry reads only installed, immutable package versions through the
existing package-store path boundary. Verify real paths, file kinds, byte caps,
and package ownership. Preserve the validated source version and content hash.
No plugin RPC can register, apply, or replace global palette data.
A committed selection references plugin ID, theme ID, variant, package version,
and validated theme hash. Store per-variant selections and a monotonic commit
revision through the settings/persistence layer. Do not persist plugin CSS or
turn this into implicit KV/cloud synchronization. Preserve legacy built-in theme
IDs and existing user settings when no plugin theme is selected.
Theme availability records and applied preferences are user state, separate
from runtime KV and function permissions. Define retained missing-selection
metadata and uninstall behavior explicitly. Missing code or theme files must
not trigger downloads, runtime activation, or new grants.
## Preview, apply, cancel, and reset
A single appearance resolver feeds existing chrome/terminal effects. Do not add
a second root effect that races `useAppThemeStyle`, `useThemeRuntime`, or active
terminal chrome selection.
| Host action | Required behavior |
| --- | --- |
| Preview | Create a host-owned token bound to window, preview owner, selected immutable theme, and current committed revision. Render locally without storage writes, sync events, runtime activation, or other-window changes |
| Apply | Verify owner, token, version/hash, availability, and expected commit revision. Commit the selected theme and explicitly chosen terminal linkage as one settings action. A stale preview produces a conflict, not an overwrite |
| Cancel or close | Clear only the matching local preview token. Render the latest committed state. Never restore a captured old settings snapshot over a newer commit |
| Reset | Clear the selected variant's plugin reference using the same revision check and apply the host default. Preserve font, accent, per-host terminal, and accessibility preferences |
| Another window applies | Advance the shared commit revision. End the old preview or mark it stale; later cancel cannot alter the new selection |
| Disable/uninstall/update | Invalidate matching previews and registry entries. Immediately resolve affected selections to a safe built-in variant, retain a bounded missing-reference notice, and never silently adopt unreviewed replacement bytes |
| Application exit or window destruction | Release local preview ownership. Only committed preferences survive startup |
The preview UI always identifies plugin source/version and has host-controlled
Apply, Cancel, and Restore default controls. Those controls live inside the
security appearance boundary. Theme data cannot control their labels, order,
visibility, click behavior, or layout.
Applying a theme changes only the selected palette and explicit terminal linkage.
It does not overwrite user font family/size, accent override, host terminal
override, reduced-motion choice, or OS accessibility preferences.
## Appearance precedence and terminal behavior
Resolve appearance in this order:
1. Host security surfaces and OS accessibility requirements.
2. Explicit user font, size, accent, and per-host/per-terminal overrides.
3. The selected and validated theme pack.
4. Built-in defaults.
Define one effective accent using the current host rule. Reject impossible
contrast combinations, including a user accent override against the effective
surface, or show an explicit host correction before apply. Do not report an
ignored author field as a successful override.
Follow App may use the pack's static terminal palette. Manual terminal choices
and per-host overrides retain precedence. Reuse the existing active-chrome
selection rather than resetting app appearance on tab changes. Test Vault,
SFTP, terminal, editor, Notes, logs, split panes, and detached windows.
High-contrast flags in plugin environments are not proof that the host already
has complete high-contrast themes. Missing HC variants require a host-owned HC
fallback. Do not downgrade a forced-colors/high-contrast preference to an
ordinary light/dark theme. Respect reduced motion and font scaling.
## Security appearance and contrast gates
Native Electron permission dialogs already sit outside renderer CSS. React
security surfaces need a new explicit boundary before a theme pack ships.
Cover AppLock, host-key trust, credential selection, grant/revoke management,
dangerous confirmations, and theme preview/apply controls. Use host-controlled
colors, font, icons, and layout there. Package colors never style those surfaces.
The existing code has no verified shared boundary covering all of them.
Color validation checks complete resolved/composited pairs, not only body text:
foreground/background, card text/card, popover text/popover, secondary/muted
text, accent/button text, destructive/button text, focus rings, borders, inputs,
and selected/disabled states. Ordinary text needs 4.5:1, large text 3:1, and
required non-text indicators 3:1 against adjacent surfaces. Reject a failing
palette with a named pair and ratio, or show an explicit host correction.
Tests must prove the effective UI, not only a helper's arithmetic.
These gates follow [WCAG text contrast](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
and [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html).
Theme screenshot review supplements contract, lifecycle, and accessibility
checks. It does not establish safe ownership, persistence, or revocation.
## Delivery stages and acceptance
Current batch A grants and SDK consistency reaches review/CI first. Complete
the controlled native interaction/context work as planned; theme work remains
a separate small stage from marketplace card/layout changes.
| Stage | Implementation and evidence |
| --- | --- |
| Contract and pure validation | Canonical schema/generated SDK, feature/revision negotiation, strict color parser, derived-accent semantics, static terminal palette, bounds, named contrast diagnostics, old manifest compatibility |
| Registry and security boundary | Immutable package ownership, static registration without runtime, independent theme/code enablement, fixed security appearance, same-variant/HC fallback, unavailable/tampered version behavior |
| Product workflow | Appearance grouping/source, local preview ownership, revision-safe apply/cancel/reset, safe defaults, system theme changes, terminal linkage and overrides, persistence and window synchronization |
| Samples and final acceptance | Light/dark no-op-browser sample, normal settings import without advanced mode, no runtime/grants on theme apply, later data-only admission only if fully validated, exact-SHA tests/build/package/CI, Codex review/fix/review, parent-owned native recording |
Boundary tests include unknown fields/feature, invalid or excessive JSON,
unsupported variant/base, non-package and changed paths, unreadable colors,
preview cancellation and window close, two-window apply/cancel ordering,
update/disable/uninstall/failure fallback, startup restoration, host and accent
overrides, forced colors, reduced motion, Chinese fonts/fallback, and every
protected security surface. A pending gate keeps the theme row unfinished.
Later controlled candidates are known-font recommendations, radius/density
presets after component support exists, declarative file/folder icons, and
package-local static backgrounds on fixed surfaces with contrast limits. They
need separate contracts and acceptance. Global CSS, free layout, remote fonts,
HTML/video backgrounds, and security-UI customization remain excluded.
## Baseline source references
- `infrastructure/config/uiThemes.ts`: token fields and built-in registry.
- `application/state/settingsStateDefaults.ts`: accent derivation and built-in ID checks.
- `application/state/appearanceSync.ts`, `appearanceChromeStore.ts`, and
`useSettingsState.ts`: persistence and cross-window behavior.
- `application/app/useAppThemeStyle.ts`, `activeChromeTheme.ts`,
`application/state/useThemeRuntime.ts`, and `domain/terminalAppearance.ts`:
effective UI and terminal precedence.
- `packages/plugin-sdk/src/index.ts`: existing terminal theme/background types.
- `electron/plugins/pluginProtocol.cjs`: isolated view CSS/font/network CSP.
- `lib/customCss.ts`: personal user CSS, excluded from plugin authority.
- `domain/pluginRuntimePolicy.cjs` and the canonical manifest schema: executable admission.
- `electron/plugins/nativePermissionDecision.cjs`: native permission prompts.
-2
View File
@@ -217,8 +217,6 @@ function createBridgeRegistrar(context) {
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
permissionEngine: pluginHostService?.permissionEngine,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
+45 -49
View File
@@ -12,23 +12,26 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value) {
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
if (url.origin !== DISTRIBUTION_ORIGIN || url.username || url.password || url.port
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
return url.href;
}
function pinnedReadmeUrl(entry) {
function assertReadmeSource(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
@@ -43,51 +46,49 @@ function validateCatalog(entries) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
assertReadmeSource(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
for (const field of ["artifact"]) {
if (!Object.hasOwn(entry, field)) continue;
const asset = entry[field];
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
throw new Error(`Invalid catalog ${field} metadata`);
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
assertDownloadUrl(asset.url);
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
}
}
return entries;
}
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
async function fetchBounded(url, limit, fetchImpl = fetch) {
const signal = AbortSignal.timeout(30_000);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
url = new URL(response.headers.get("location"), url).href;
continue;
}
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
throw new Error("Catalog redirects are not allowed");
}
throw new Error("Too many catalog redirects");
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
}
class PluginCatalogService {
constructor(options) {
@@ -103,11 +104,6 @@ class PluginCatalogService {
if (!entry) throw new Error("Plugin is not in the curated catalog");
return entry;
}
async readme(id) {
const entry = this.get(id);
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
@@ -127,4 +123,4 @@ class PluginCatalogService {
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
+4 -12
View File
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
const requested = [];
const service = new PluginCatalogService({ entries, fetch: async (url) => {
requested.push(url); return new Response("# First-party example");
} });
assert.equal(await service.readme(entries[0].id), "# First-party example");
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
await assert.rejects(service.readme(entries[0].id), /redirects/);
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
test("static downloader is bounded and never follows redirects", async () => {
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
});
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
let installs = 0;
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
assert.equal(name, "plugin-catalog-");
+16
View File
@@ -0,0 +1,16 @@
# Pinned static distribution schemas
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
(PR 3). The host compiles them with the existing local plugin-contract schema;
no schema reference causes network access.
`../fixtures/static-distribution/` contains that commit's actual
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
four referenced README/PNG assets. Raw-byte SHA-256 checks in
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
hashes; every document and asset is checked against the pointer/sidecar.
The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
@@ -0,0 +1,166 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v1 (source-only bootstrap)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 1
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"type": "null"
},
"artifact": {
"type": "null"
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
@@ -0,0 +1,194 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"plugins"
],
"properties": {
"schemaVersion": {
"const": 1
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"current",
"versions"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"current": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
{
"type": "null"
}
]
},
"versions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"entry",
"distribution",
"withdrawal"
],
"properties": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"name",
"description",
"author",
"repository",
"commit",
"license",
"readme",
"manifest"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"repository": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"commit": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-f0-9]{40}$"
},
"license": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"readme": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"manifest": {
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"url",
"sha256"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
},
"distribution": {
"enum": [
"source-only",
"release"
]
},
"tag": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"withdrawal": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"at",
"reason",
"advisory"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"advisory": {
"type": "string",
"pattern": "^https://github.com/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
"use strict";
const { createHash } = require("node:crypto");
const { isDeepStrictEqual } = require("node:util");
const Ajv2020 = require("ajv/dist/2020").default;
const addFormats = require("ajv-formats").default;
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { formatValidationErrors } = require("./contractValidator.cjs");
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
const SHA256 = /^[a-f0-9]{64}$/u;
const COMMIT = /^[a-f0-9]{40}$/u;
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
addFormats(ajv);
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const validateDelivery = ajv.compile(require("./distribution/delivery.schema.json"));
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
function exactKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function assertReference(ref, path, limit) {
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
throw new Error("Invalid static snapshot resource reference");
}
}
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
const value = JSON.parse(bytes.toString("utf8"));
assertPluginJsonValue(value, { maxBytes: limit });
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
return value;
}
function assertPointer(pointer) {
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
throw new Error("Invalid static snapshot pointer");
}
for (const name of ["catalog", "index", "delivery"]) {
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
}
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
}
function assertAsset(ref, extensions) {
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
throw new Error("Invalid static asset path or format");
}
assertReference(ref, ref.path, 256 * 1024);
}
async function fetchVerified(ref, fetchImpl) {
const bytes = await fetchBounded(`${DISTRIBUTION_ORIGIN}${ref.path}`, ref.size, fetchImpl);
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
return bytes;
}
function validateSet(pointer, entries, index, delivery) {
validateCatalog(entries);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
if (!validateDelivery(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validateDelivery.errors)}`);
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
// v1 publishing deliberately refuses packages and avatars. Do not extend this
// consumer ahead of the reviewed publishing schema or invent installable code.
if (entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
const current = new Map();
const indexIds = new Set();
for (const plugin of index.plugins) {
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
indexIds.add(plugin.id);
const versions = new Map();
for (const version of plugin.versions) {
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
versions.set(version.entry.version, version);
}
if (plugin.current !== null) {
const version = versions.get(plugin.current);
if (!version || version.withdrawal !== null || version.distribution !== "source-only") throw new Error("Current distribution version is missing or withdrawn");
current.set(plugin.id, version.entry);
}
}
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
throw new Error("Catalog does not match the current non-withdrawn index");
}
const resources = new Map();
const catalogTuples = new Set(entries.map(tuple));
for (const plugin of delivery.plugins) {
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl) {
const pointer = parseJson(await fetchBounded(REGISTRY_URL, 16 * 1024, fetchImpl), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery);
return { entries, resources, pointer };
}
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
@@ -0,0 +1,53 @@
# Hello Netcatty
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
command-palette contributions, lazy command and Provider activation,
localization, terminal completion/decoration Providers, and the runtime SDK.
From the repository root:
```bash
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
**Examples: Say Hello** from the command palette. The setting is read through
the host settings broker and the command handler is registered inside the
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch the normal browser-only plugin build using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
Enabling this lazy example does not grant permissions: required permissions are
requested before its first command/provider activation, and optional permissions
when used. Never treat Enabled as a security certification or grant.
@@ -0,0 +1,37 @@
# Plugin Starter
A minimal first-party browser plugin for learning import, settings and lifecycle.
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
network, files, secrets or advanced Node access. It works without OS key storage.
Permission declarations are not grants; follow the app's normal mediation.
From the repository root:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Start the actual app normally with a fresh test profile; no development flag is
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
The panel inserts text using `textContent`, never HTML. It does not write to
a terminal or connect to a host. Denied permissions remain denied.
Disable, restart and re-enable to verify the preference persists. Re-importing
the same package leaves it disabled for review. Uninstall removes code and local
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
The separate Hello Netcatty example demonstrates terminal providers and requests
terminal permissions. Use this Starter for the default no-sensitive-access demo.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
@@ -0,0 +1,195 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
}
]
@@ -0,0 +1,48 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"plugins": [
{
"artifact": null,
"avatar": null,
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"iconDark": {
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
"size": 873
},
"iconLight": {
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"size": 873
},
"id": "com.netcatty.starter",
"readme": {
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
"size": 2050
},
"repository": "binaricat/Netcatty",
"version": "0.1.2"
},
{
"artifact": null,
"avatar": null,
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"iconDark": null,
"iconLight": null,
"id": "com.netcatty.hello",
"readme": {
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
"size": 2951
},
"repository": "binaricat/Netcatty",
"version": "0.1.0"
}
],
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
}
@@ -0,0 +1,218 @@
{
"schemaVersion": 1,
"plugins": [
{
"id": "com.netcatty.starter",
"current": "0.1.2",
"versions": [
{
"entry": {
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
},
{
"id": "com.netcatty.hello",
"current": "0.1.0",
"versions": [
{
"entry": {
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
}
]
}
@@ -0,0 +1,20 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
}
@@ -0,0 +1,40 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
const root = path.join(__dirname, "static-distribution");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const read = (name) => fs.readFileSync(path.join(root, name));
const catalog = JSON.parse(read("catalog.json"));
const delivery = JSON.parse(read("delivery.json"));
function realFiles() {
const pointer = JSON.parse(read("snapshot.json"));
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
return files;
}
function makeFiles(entries = catalog, mutate = () => {}) {
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
mutate(index, sidecar);
const catalogBytes = Buffer.from(JSON.stringify(entries));
const indexBytes = Buffer.from(JSON.stringify(index));
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
const snapshotId = digest(deliveryBytes);
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
const files = realFiles();
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
pointer[name] = ref; files.set(ref.path, bytes);
}
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
return files;
}
function responseFor(files, url) {
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
const bytes = files.get(new URL(url).pathname);
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
}
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
@@ -231,131 +231,6 @@
},
"additionalProperties": false
},
"NetworkCredentialLeaseFeature": {
"const": "network.credential-lease"
},
"PluginNetworkAuthorization": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"enum": [
"Basic",
"Bearer"
]
},
"username": {
"type": "string",
"maxLength": 8192,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"PluginNetworkRequest": {
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD"
]
},
"headers": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"enum": [
"utf8",
"base64"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 300000
},
"credentialLease": {
"$ref": "#/$defs/SecretLeaseRef"
},
"authorization": {
"$ref": "#/$defs/PluginNetworkAuthorization"
}
},
"additionalProperties": false
},
"PluginNetworkResponse": {
"type": "object",
"required": [
"url",
"status",
"headers",
"body"
],
"properties": {
"url": {
"type": "string"
},
"status": {
"type": "integer"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"const": "base64"
},
"data": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"SemanticVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
+2 -4
View File
@@ -1,7 +1,6 @@
"use strict";
const path = require("node:path");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
@@ -93,7 +92,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -224,7 +223,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -354,7 +353,6 @@ function createPluginHostService(options) {
})
: null;
const manager = new PluginManager({
permissionEngine,
database,
packageStore,
runtimeSupervisor,
@@ -1,47 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { MessageChannel } = require("node:worker_threads");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
test("browser lease example negotiates its feature and forwards only an opaque lease", async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const fixture = await import("../../examples/plugins/lease-http/browser.js");
const calls = [];
const secret = { kind: "secret", id: "s".repeat(32), key: "synthetic-token" };
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: Date.now() + 30_000 };
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({
pluginId: "com.netcatty.lease-http",
send: (message) => port1.postMessage(message),
handlers: {
"secrets.set": async (params) => { calls.push("secret"); assert.equal(params.key, secret.key); return { secret }; },
"credentials.createLease": async (params) => {
calls.push("lease"); assert.deepEqual(params.secret, secret); assert.equal(params.operationId, lease.operationId); return lease;
},
"network.request": async (params) => {
calls.push("network");
assert.deepEqual(params, { url: "https://example.invalid/synthetic", credentialLease: lease, authorization: { scheme: "Bearer" } });
assert.equal(JSON.stringify(params).includes("synthetic-acceptance-token"), false);
return { status: 200, url: params.url, headers: {}, body: { encoding: "base64", data: "" } };
},
},
});
port1.on("message", (message) => { void host.accept(message); });
const peer = await startPluginRuntime({
port: port2,
config: { pluginId: "com.netcatty.lease-http", pluginVersion: "0.1.0", netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["network.credential-lease"] },
loadPlugin: async () => fixture,
});
try {
const initialized = await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["network.credential-lease"] });
assert.deepEqual(initialized.enabledFeatures, ["network.credential-lease"]);
await host.request("plugin.activate", {});
assert.deepEqual(await host.request("plugin.command.execute", { command: "com.netcatty.lease-http.request", invocation: { source: "palette" } }), { status: 200 });
assert.deepEqual(calls, ["secret", "lease", "network"]);
} finally {
await peer.dispose();
host.close();
port1.close();
}
});
+10 -6
View File
@@ -1,9 +1,5 @@
"use strict";
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateLease = createDefinitionValidator("SecretLeaseRef");
const validateAuthorization = createDefinitionValidator("PluginNetworkAuthorization");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
@@ -184,7 +180,15 @@ class PluginNetworkBroker {
// network.request lease injection would become dead code.
if (params?.credentialLease !== undefined) {
const lease = params.credentialLease;
if (!validateLease(lease)) {
if (
!lease
|| typeof lease !== "object"
|| Array.isArray(lease)
|| lease.kind !== "secret-lease"
|| typeof lease.id !== "string"
|| typeof lease.operationId !== "string"
|| !Number.isFinite(lease.expiresAt)
) {
throw invalidArgument("Plugin network credential lease is invalid");
}
validated.credentialLease = Object.freeze({
@@ -196,7 +200,7 @@ class PluginNetworkBroker {
}
if (params?.authorization !== undefined) {
const authorization = params.authorization;
if (!validateAuthorization(authorization)) {
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
throw invalidArgument("Plugin network authorization is invalid");
}
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
-11
View File
@@ -172,14 +172,3 @@ test("network broker reports its own timeout as a stable deadline error", async
(error) => error.code === RPC_ERRORS.deadlineExceeded,
);
});
test("credential field validation follows the canonical contract and keeps Bearer default", () => {
const broker = new PluginNetworkBroker({ permissionEngine: {} });
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: 1000 };
const request = { url: "https://example.invalid", credentialLease: lease, authorization: {} };
assert.deepEqual(broker.validate(request).authorization, { scheme: "Bearer" });
for (const authorization of [{ scheme: "Digest" }, { scheme: "Basic", username: "name\n" }, { password: "synthetic" }]) {
assert.throws(() => broker.validate({ ...request, authorization }), /authorization is invalid/);
}
assert.throws(() => broker.validate({ ...request, credentialLease: { ...lease, id: "short" } }), /lease is invalid/);
});
-35
View File
@@ -3,9 +3,6 @@
const fs = require("node:fs/promises");
const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateManagedPluginId = createDefinitionValidator("PluginId");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
INLINE_SYNC_OBJECT_SAFE_BYTES,
@@ -17,8 +14,6 @@ const { assertSecretKey } = require("./secretStore.cjs");
const CHANNELS = Object.freeze({
status: "netcatty:plugins:status",
list: "netcatty:plugins:list",
permissions: "netcatty:plugins:permissions",
revokePermissions: "netcatty:plugins:revoke-permissions",
catalog: "netcatty:plugins:catalog",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogInstall: "netcatty:plugins:catalog-install",
@@ -662,36 +657,6 @@ function registerPluginBridge(ipcMain, options) {
return options.catalogService.install(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.list, async (activeManager) => activeManager.list());
const assertManagedPluginId = (payload) => {
const id = payload?.pluginId;
if (!validateManagedPluginId(id)) {
throw new TypeError("Plugin ID is invalid");
}
return id;
};
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
const groups = options.permissionEngine.listGrants(pluginId);
// Host-only summaries: never return arbitrary audit details, reasons,
// operation IDs, credentials, terminal content or future database columns.
const grants = ["always", "application", "session"].flatMap((scope) => (
(groups[scope] ?? []).map((grant) => ({
permission: grant.permission, resource: grant.resource,
resourceKind: grant.resourceKind, scope,
sessionId: grant.sessionId ?? null, grantedAt: grant.grantedAt,
}))
));
const audit = options.database.listSecurityAudit(pluginId, 100).map((entry) => ({
event: entry.event, createdAt: entry.createdAt,
...(typeof entry.details?.permission === "string" ? { permission: entry.details.permission } : {}),
...(typeof entry.details?.scope === "string" ? { scope: entry.details.scope } : {}),
}));
return { grants, audit };
});
handle(CHANNELS.revokePermissions, async (activeManager, payload) => (
activeManager.revokePermissions(assertManagedPluginId(payload))
));
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
payload?.archivePath,
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
-32
View File
@@ -1635,35 +1635,3 @@ test('brand metadata bridge accepts only trusted UI and forwards only the plugin
await ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: true }, { pluginId: 'com.test.brand', path: '/secret', url: 'https://attacker.invalid/' });
assert.deepEqual(seen, [['com.test.brand']]);
});
test("permission management is trusted-host-only and projects bounded audit summaries", async () => {
const ipcMain = createIpcMain();
let reads = 0;
const revoked = [];
registerPluginBridge(ipcMain, {
manager: { async initialize() {}, async revokePermissions(id) { revoked.push(id); return { enabled: false }; } },
isTrustedSender: (event) => event?.trusted === true,
permissionEngine: { listGrants(id) {
reads++;
assert.equal(id, "com.example.synthetic");
return { always: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", grantedAt: 12, declarationHash: "private" }], application: [], session: [] };
} },
database: { listSecurityAudit(id, limit) {
assert.equal(id, "com.example.synthetic");
assert.equal(limit, 100);
return [{ event: "permission.used", createdAt: 13, details: { permission: "network", scope: "always", operationId: "hidden", secret: "synthetic-private" } }];
} },
});
const payload = { pluginId: "com.example.synthetic" };
for (const channel of [CHANNELS.permissions, CHANNELS.revokePermissions]) {
await assert.rejects(ipcMain.handlers.get(channel)({}, payload), /Untrusted/);
await assert.rejects(ipcMain.handlers.get(channel)({ trusted: true }, { pluginId: "../invalid" }), /Plugin ID/);
}
assert.equal(reads, 0);
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.permissions)({ trusted: true }, payload), {
grants: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", scope: "always", sessionId: null, grantedAt: 12 }],
audit: [{ event: "permission.used", createdAt: 13, permission: "network", scope: "always" }],
});
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.revokePermissions)({ trusted: true }, payload), { enabled: false });
assert.deepEqual(revoked, [payload.pluginId]);
});
-16
View File
@@ -11,7 +11,6 @@ class PluginManager {
onPluginEnabled: (pluginId) => this.runtimeSupervisor.start(pluginId),
};
this.beforeClose = options.beforeClose ?? null;
this.permissionEngine = options.permissionEngine ?? null;
this.initialized = false;
this.initializePromise = null;
this.mutationTail = Promise.resolve();
@@ -129,21 +128,6 @@ class PluginManager {
});
}
revokePermissions(pluginId) {
return this.#mutate(async () => {
await this.#ready();
if (!this.permissionEngine) throw new Error("Plugin permissions are unavailable");
if (!this.database.getActivePlugin(pluginId)) throw new Error("Plugin is not installed");
// Disable before revoking. Lazy activation cannot re-create authority while
// runtime shutdown cancels pending RPC, streams, leases and provider work.
this.database.setEnabled(pluginId, false);
this.permissionEngine.revokeAll(pluginId);
this.contributionService.onPluginDisabled(pluginId);
await this.runtimeSupervisor.stop(pluginId);
return this.database.getActivePlugin(pluginId);
});
}
restart(pluginId) {
return this.#mutate(async () => {
await this.#ready();
-45
View File
@@ -511,48 +511,3 @@ test("explicit enable checks compatibility before clearing quarantine or exposin
await assert.rejects(manager.setEnabled(plugin.id, true), /incompatible/);
assert.deepEqual(calls, []);
});
test("permission revocation disables before clearing grants and waits for runtime cleanup", async () => {
const calls = [];
let releaseStop;
const stopped = new Promise((resolve) => { releaseStop = resolve; });
let enabled = true;
const manager = new PluginManager({
database: {
getActivePlugin: (id) => ({ id, enabled }),
setEnabled(_id, value) { enabled = value; calls.push("disable"); },
},
packageStore: { async initialize() {} },
contributionService: {
async initialize() {},
onPluginDisabled() { calls.push("contributions"); },
},
permissionEngine: { revokeAll() { assert.equal(enabled, false); calls.push("revoke"); } },
runtimeSupervisor: { async stop() { calls.push("stop"); await stopped; calls.push("clean"); } },
});
let complete = false;
const revoke = manager.revokePermissions("com.example.revoke").then((value) => { complete = true; return value; });
const enable = manager.setEnabled("com.example.revoke", false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
assert.equal(complete, false);
releaseStop();
assert.equal((await revoke).enabled, false);
await enable;
assert.deepEqual(calls.slice(4), ["clean", "disable", "contributions", "stop", "clean"]);
});
test("failed runtime containment leaves the revoked plugin disabled and reports failure", async () => {
let enabled = true;
let revoked = false;
const manager = new PluginManager({
database: { getActivePlugin: () => ({ enabled }), setEnabled(_id, value) { enabled = value; } },
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() {} },
permissionEngine: { revokeAll() { revoked = true; } },
runtimeSupervisor: { async stop() { throw new Error("containment failed"); } },
});
await assert.rejects(manager.revokePermissions("com.example.revoke"), /containment failed/);
assert.equal(enabled, false);
assert.equal(revoked, true);
});
+14 -11
View File
@@ -1,20 +1,21 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value) {
function prepareSnapshot(value, resources = new Map()) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
return { entries, publicEntries };
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([entry, resources.get(tuple(entry)) ?? null]) }));
return { entries, publicEntries, resources: structuredClone(resources) };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.resources = new Map();
this.source = "builtin";
this.error = null;
this.refreshing = null;
@@ -24,11 +25,12 @@ class RegistryCatalogService extends PluginCatalogService {
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
const distribution = await fetchDistribution(this.fetch);
const next = prepareSnapshot(distribution.entries, distribution.resources);
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.resources = next.resources;
this.source = "registry";
this.error = null;
return true;
@@ -50,15 +52,16 @@ class RegistryCatalogService extends PluginCatalogService {
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== tokenFor(entry)) {
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
async readme(id, token) {
this.selected(id, token);
// Base method captures the pinned entry synchronously before its first await.
return super.readme(id);
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch)).toString("utf8");
}
async install(id, token) {
this.selected(id, token);
+54 -114
View File
@@ -1,153 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
const builtin = require("./catalog.json");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
let response = () => new Response(JSON.stringify([builtin[0]]));
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
let files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(url, REGISTRY_URL);
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return response();
return responseFor(files, url);
} });
const online = await service.list();
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
response = () => new Response(invalid);
const good = await service.list();
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, online.entries);
assert.deepEqual(cached.entries, good.entries);
}
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
assert.match((await service.list()).error, /size limit/);
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
assert.match((await service.list()).error, /redirects/);
response = () => new Response("[]");
const empty = await service.list();
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
});
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
let calls = 0;
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
assert.match(snapshot.error, /offline/);
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
assert.equal(calls, 1);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
let entries = structuredClone(builtin);
let requests = 0;
const service = new RegistryCatalogService({ fetch: async (url) => {
requests++;
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
return new Response("# Pinned README");
}, manager: { install() { assert.fail("changed selection must not install"); } } });
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
let files = realFiles(); const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
const selected = (await service.list()).entries[0];
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests;
await assert.rejects(service.install(selected.id), /changed/);
assert.equal(requests, before);
entries[0].description += " updated";
const before = requests.length;
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
const changed = structuredClone(catalog); changed[0].description += " changed";
files = makeFiles(changed);
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
entries = [];
const next = (await service.list()).entries;
assert.deepEqual(next, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one request", async () => {
let finish; let calls = 0;
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
const first = service.list(); const second = service.list();
finish(new Response(JSON.stringify(builtin)));
assert.deepEqual(await first, await second); assert.equal(calls, 1);
finish(responseFor(files, REGISTRY_URL));
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
});
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
const { createHash } = require("node:crypto");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic validated-by-manager archive");
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const requests = []; let installs = 0;
const service = new RegistryCatalogService({
getTempFilePath: (name) => path.join(root, name),
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
manager: { async install(archive, options) {
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
assert.deepEqual(options.catalog, entry); return { id: entry.id };
} },
});
const selected = (await service.list()).entries[0];
await service.install(selected.id, selected.selectionToken);
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
});
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
let entries = structuredClone(builtin);
const requested = [];
const service = new RegistryCatalogService({ fetch: async (url) => {
requested.push(url); return new Response(JSON.stringify(entries));
} });
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
let files = realFiles(); let offline = false;
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
const good = await service.list();
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
entries = [{ ...builtin[0], readme }];
const snapshot = await service.list();
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
assert.deepEqual(snapshot.entries, good.entries);
}
entries = [{ ...builtin[0], repository: "example/.." }];
assert.match((await service.list()).error, /escapes the pinned commit/);
assert.ok(requested.every((url) => url === REGISTRY_URL));
});
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
let response = JSON.stringify(builtin);
const service = new RegistryCatalogService({ fetch: async () => {
if (response === null) throw new Error("offline");
return new Response(response);
} });
const good = await service.list();
let nested = null;
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
const nonfinite = structuredClone(builtin[0]);
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
for (const invalid of [
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
JSON.stringify([{ ...builtin[0], unknownField: true }]),
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
]) {
response = invalid;
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
const bad = [
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
];
for (const entry of bad) {
files = makeFiles([entry]); offline = false;
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
response = null;
const offline = await service.list();
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
}
});
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
const good = await service.list();
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
const good = await service.list(); const resources = service.resources;
const originalClone = global.structuredClone;
try {
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
assert.equal(await service.refresh(), false);
} finally { global.structuredClone = originalClone; }
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
finally { global.structuredClone = originalClone; }
assert.equal(service.resources, resources);
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
});
+1 -1
View File
@@ -324,7 +324,7 @@ test("networkBroker.validate preserves credentialLease for registry dispatch", (
url: "https://example.com/vault",
credentialLease: {
kind: "secret-lease",
id: "l".repeat(32),
id: "lease-1",
operationId: "network:https://example.com",
expiresAt: Date.now() + 60_000,
},
@@ -0,0 +1,106 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
const requests = []; const files = realFiles();
const fetch = async (url, options) => {
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
return responseFor(files, url);
};
const service = new RegistryCatalogService({ fetch });
const snapshot = await service.list();
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
for (const entry of snapshot.entries) {
const resource = delivery.plugins.find((item) => item.id === entry.id);
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
assertAsset(icon, ["png", "webp"]);
const bytes = await fetchVerified(icon, fetch);
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
}
assert.equal(entry.artifact, undefined);
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
}
assert.ok(requests.every((url) => !/github/u.test(url)));
});
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
for (const url of [
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
]) {
assert.throws(() => assertDownloadUrl(url));
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
}
});
test("official responses never redirect to GitHub or another official path", async () => {
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
const requests = [];
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
requests.push(url); return new Response(null, { status: 302, headers: { location } });
}), /redirects/);
assert.deepEqual(requests, [REGISTRY_URL]);
}
});
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list(); const goodResources = service.resources;
const mutations = [
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
(index, sidecar) => { sidecar.plugins.pop(); },
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
(index) => { index.plugins.push(index.plugins[0]); },
(index, sidecar) => { sidecar.unknown = true; },
];
for (const mutate of mutations) {
files = makeFiles(catalog, mutate);
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
}
});
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list();
const pointer = JSON.parse(read("snapshot.json"));
for (const name of ["catalog", "index", "delivery"]) {
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
files = realFiles(); files.delete(pointer[name].path);
assert.deepEqual((await service.list()).entries, good.entries);
}
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
assert.match((await service.list()).error, /resource reference/);
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
});
-2
View File
@@ -103,8 +103,6 @@ function createPreloadApi(ctx) {
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
getPluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:permissions", { pluginId }),
revokePluginPermissions: (pluginId) => ipcRenderer.invoke("netcatty:plugins:revoke-permissions", { pluginId }),
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
archivePath,
enable: options?.enable === true,
-25
View File
@@ -1,25 +0,0 @@
# Synthetic credential lease fixture
This browser-only package exercises feature negotiation, plugin-owned secret
references, a one-use host-consumed network lease, and the host grant panel.
It contains only synthetic data and targets `https://example.invalid`.
A synthetic host test supplies the HTTP response. The normal app reports a
network failure at that deliberately non-routable address after permission.
This fixture does not prove a live third-party integration.
Pack the source after building the plugin packages:
```sh
node packages/plugin-cli/dist/cli.js pack examples/plugins/lease-http --out /tmp/lease-http.ncpkg
```
In the normal plugin manager, import the package disabled, inspect its declared
permissions, and enable it. The command palette contains **Synthetic credential
lease request**. Reject a permission prompt to verify that the request stops.
OS secret storage must be available to proceed past `secrets.set`.
The package detail's **Active grants** panel shows retained decisions. One-use
decisions are not retained. **Disable and revoke all grants** disables the
plugin and waits for its runtime cleanup. Re-enable it to request access again.
Recent events show event type and permission without terminal or secret data.
Do not change the destination or substitute real credentials for this check.
-24
View File
@@ -1,24 +0,0 @@
import { definePlugin, PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from '@netcatty/plugin-sdk';
// Isolated acceptance fixture: the target is deliberately non-routable.
// Tests provide a synthetic HTTP transport. Never substitute a real credential.
export default definePlugin({
activate(context) {
context.subscriptions.add(context.commands.registerCommand('com.netcatty.lease-http.request', async () => {
if (!context.enabledFeatures.has(PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE)) {
throw new Error('Host does not advertise network credential leases');
}
const secret = await context.secrets.set('synthetic-token', 'synthetic-acceptance-token');
const credentialLease = await context.credentials.createLease(secret, {
operationId: 'network:https://example.invalid',
purpose: 'Authenticate an isolated synthetic request',
});
const response = await context.network.request({
url: 'https://example.invalid/synthetic',
credentialLease,
authorization: { scheme: 'Bearer' },
});
return { status: response.status };
}));
},
});
@@ -1,25 +0,0 @@
{
"manifestVersion": 1,
"id": "com.netcatty.lease-http",
"name": "lease-http",
"displayName": "Synthetic credential lease",
"description": "Isolated credential lease and grant revocation acceptance fixture.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"features": { "required": ["network.credential-lease"] },
"main": { "browser": "browser.js" },
"activationEvents": ["onCommand:com.netcatty.lease-http.request"],
"permissions": {
"required": ["commands", "menus"],
"optional": [
{ "permission": "secrets", "resources": ["secret:synthetic-token"] },
{ "permission": "network", "resources": ["https://example.invalid"] }
]
},
"contributes": {
"commands": [{ "id": "com.netcatty.lease-http.request", "title": "Synthetic credential lease request" }],
"menus": [{ "command": "com.netcatty.lease-http.request", "location": "commandPalette" }]
}
}
Vendored
-10
View File
@@ -355,14 +355,6 @@ declare global {
artifact?: { url: string; sha256: string };
}
interface NetcattyPluginPermissionSnapshot {
grants: Array<{
permission: string; resource: string; resourceKind: string;
scope: 'always' | 'application' | 'session'; sessionId: string | null; grantedAt: number;
}>;
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
}
interface NetcattyInstalledPlugin {
id: string;
enabled: boolean;
@@ -444,8 +436,6 @@ declare global {
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string): Promise<NetcattyInstalledPlugin>;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
revokePluginPermissions?(pluginId: string): Promise<NetcattyInstalledPlugin>;
installPluginPackage?(archivePath: string, options?: { enable?: boolean }): Promise<NetcattyInstalledPlugin>;
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
@@ -178,131 +178,6 @@
},
"additionalProperties": false
},
"NetworkCredentialLeaseFeature": {
"const": "network.credential-lease"
},
"PluginNetworkAuthorization": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"enum": [
"Basic",
"Bearer"
]
},
"username": {
"type": "string",
"maxLength": 8192,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"PluginNetworkRequest": {
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD"
]
},
"headers": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"enum": [
"utf8",
"base64"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 300000
},
"credentialLease": {
"$ref": "#/$defs/SecretLeaseRef"
},
"authorization": {
"$ref": "#/$defs/PluginNetworkAuthorization"
}
},
"additionalProperties": false
},
"PluginNetworkResponse": {
"type": "object",
"required": [
"url",
"status",
"headers",
"body"
],
"properties": {
"url": {
"type": "string"
},
"status": {
"type": "integer"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"const": "base64"
},
"data": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"SemanticVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
@@ -355,8 +355,6 @@ export type MenuContribution = {
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
export type NetworkCredentialLeaseFeature = "network.credential-lease";
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type NullableRpcId = (RpcId) | (null);
@@ -483,34 +481,6 @@ export type PluginManifestHeader = ({
engines: PluginEngineHeader;
} & Record<string, unknown>);
export type PluginNetworkAuthorization = {
scheme?: "Basic" | "Bearer";
username?: string;
};
export type PluginNetworkRequest = {
url: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
headers?: { [key: string]: string };
body?: {
encoding: "utf8" | "base64";
data: string;
};
timeoutMs?: number;
credentialLease?: SecretLeaseRef;
authorization?: PluginNetworkAuthorization;
};
export type PluginNetworkResponse = {
url: string;
status: number;
headers: { [key: string]: string };
body: {
encoding: "base64";
data: string;
};
};
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
-2
View File
@@ -49,5 +49,3 @@ export {
} from "./streamTransport.js";
export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MAX_BYTES, PLUGIN_BRAND_ICON_MAX_EDGE } from './brandIcon.js';
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
-18
View File
@@ -395,21 +395,3 @@ test("CancellationTokenSource finishes disposal when a cancellation listener fai
assert.throws(() => source.dispose(true), AggregateError);
assert.doesNotThrow(() => source.dispose(true));
});
test("network requests expose canonical host-consumed credential leases without plaintext fields", () => {
assertSdkTypeChecks(`
import type { PluginNetworkRequest, PluginNetworkClient, SecretLeaseRef } from './index.ts';
declare const network: PluginNetworkClient;
declare const lease: SecretLeaseRef;
const request: PluginNetworkRequest = {
url: 'https://example.invalid', credentialLease: lease,
authorization: { scheme: 'Basic', username: 'synthetic' },
};
void network.request(request);
void network.request({ url: 'https://example.invalid' });
// @ts-expect-error Plaintext credential handoff is not supported.
void network.request({ url: 'https://example.invalid', authorization: { scheme: 'Bearer', password: 'secret' } });
// @ts-expect-error Unsupported authentication schemes fail at compile time.
void network.request({ url: 'https://example.invalid', authorization: { scheme: 'Digest' } });
`);
});
+14 -5
View File
@@ -13,8 +13,6 @@ import type {
ConnectionStatusResult,
ConnectionValidateResult,
CredentialRef,
PluginNetworkRequest,
PluginNetworkResponse,
FeatureId,
ImporterDetectPayload,
ImporterDetectResult,
@@ -638,7 +636,20 @@ export interface PluginCredentialBroker {
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
}
export type { PluginNetworkAuthorization, PluginNetworkRequest, PluginNetworkResponse } from "@netcatty/plugin-contract";
export interface PluginNetworkRequest {
readonly url: string;
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
readonly headers?: Readonly<Record<string, string>>;
readonly body?: Readonly<{ encoding: "utf8" | "base64"; data: string }>;
readonly timeoutMs?: number;
}
export interface PluginNetworkResponse {
readonly url: string;
readonly status: number;
readonly headers: Readonly<Record<string, string>>;
readonly body: Readonly<{ encoding: "base64"; data: string }>;
}
export interface PluginNetworkClient {
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
@@ -911,5 +922,3 @@ export function definePlugin<T extends NetcattyPlugin>(plugin: T): T {
export function throwIfCancellationRequested(token: CancellationToken): void {
if (token.isCancellationRequested) throw new CancellationError();
}
export { PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from "@netcatty/plugin-contract";