mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a20a725d | ||
|
|
cac7ff1f3e | ||
|
|
9af123e5d7 |
No files matched your search
@@ -1245,6 +1245,17 @@ 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.",
|
||||
@@ -1258,11 +1269,6 @@ 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",
|
||||
|
||||
@@ -1245,6 +1245,17 @@ 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.",
|
||||
@@ -1258,11 +1269,6 @@ 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",
|
||||
|
||||
@@ -1140,6 +1140,17 @@ 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": "Привилегированные разрешения не заявлены.",
|
||||
@@ -1153,11 +1164,6 @@ 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": "Каталог",
|
||||
|
||||
@@ -1160,6 +1160,17 @@ 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": "未声明特权权限。",
|
||||
@@ -1173,11 +1184,6 @@ 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": "插件市场",
|
||||
|
||||
@@ -1163,6 +1163,17 @@ 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": "未聲明特權權限。",
|
||||
@@ -1182,11 +1193,6 @@ 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": "插件市場",
|
||||
|
||||
@@ -41,7 +41,7 @@ export function usePluginManager() {
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error, credentialStorageAvailable,
|
||||
plugins, available, busy, error, credentialStorageAvailable, refresh,
|
||||
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'] }]);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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,6 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { before, after } from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import test from 'node:test';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
@@ -9,19 +8,6 @@ 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),
|
||||
@@ -53,13 +39,16 @@ 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().length, 0, 'detail replaces the entire grid at every width');
|
||||
assert.equal(renderer.root.findAllByType('input').length, 0);
|
||||
assert.equal(cards()[0].props['aria-current'], 'true');
|
||||
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'))!;
|
||||
@@ -67,12 +56,15 @@ 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().length, 0);
|
||||
await act(async () => back().props.onClick());
|
||||
assert.equal(cards()[1].props['aria-current'], 'true');
|
||||
assert.equal(cards()[0].props['aria-current'], undefined);
|
||||
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 {
|
||||
@@ -89,29 +81,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, /Source example/);
|
||||
assert.match(html, /Build with the local CLI/);
|
||||
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
|
||||
assert.match(html, /Retry/);
|
||||
assert.doesNotMatch(html, /<details/);
|
||||
});
|
||||
|
||||
test('cards use subtle shadows and details have no breakpoint-specific columns or nested scroller', () => {
|
||||
test('responsive layout is scoped to marketplace content width, with a narrow detail-only fallback', () => {
|
||||
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /box-shadow: 0 1px 4px/);
|
||||
assert.match(css, /\.dark \.plugin-card/);
|
||||
assert.doesNotMatch(css, /@container|248px|max-height|overflow-y/);
|
||||
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;/);
|
||||
});
|
||||
|
||||
test('full-page detail starts at the top and back restores the settings scroll and card focus', async () => {
|
||||
test('narrow detail starts at the top and back restores the settings scroll and card focus', async () => {
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
|
||||
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
|
||||
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: 1100 } : pane,
|
||||
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 700 } : pane,
|
||||
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
|
||||
let renderer!: ReactTestRenderer;
|
||||
function Harness() {
|
||||
@@ -119,14 +111,12 @@ test('full-page detail starts at the top and back restores the settings scroll a
|
||||
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' && (element.props as { className?: string }).className?.includes('plugin-marketplace-catalog') ? list : null }); });
|
||||
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' ? list : { scrollTo() {}, focus() {} } }); });
|
||||
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());
|
||||
@@ -141,10 +131,11 @@ 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, /Offline catalog · installation unavailable/);
|
||||
assert.match(html, /Registry offline/);
|
||||
assert.match(html, /Installation is unavailable until refresh succeeds/);
|
||||
assert.match(html, /Compatibility and permissions/);
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
|
||||
assert.match(html, /Notices: 1/);
|
||||
assert.match(html, /Retry/);
|
||||
});
|
||||
|
||||
test('manual refresh remains available after success and is disabled while loading', () => {
|
||||
@@ -153,12 +144,3 @@ 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)'));
|
||||
});
|
||||
@@ -1,6 +1,3 @@
|
||||
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';
|
||||
@@ -8,19 +5,18 @@ 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 = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
export function PluginMarketplace({ installed = [] }: { installed?: NetcattyInstalledPlugin[] }) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const catalog = usePluginCatalog(selected);
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} />;
|
||||
}
|
||||
|
||||
// The browser owns only presentation state; installation and README policy stay in the existing hook.
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
|
||||
noVault?: boolean;
|
||||
toolbarContainer?: HTMLElement | null;
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect }: {
|
||||
catalog: ReturnType<typeof usePluginCatalog>;
|
||||
installed: NetcattyInstalledPlugin[];
|
||||
selected: string | null;
|
||||
@@ -29,19 +25,23 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const detailRef = useRef<HTMLHeadingElement>(null);
|
||||
const detailRef = useRef<HTMLElement>(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) => {
|
||||
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
|
||||
pageScroll.current = pane?.scrollTop ?? 0;
|
||||
listScroll.current = listRef.current?.scrollTop ?? 0;
|
||||
if (!entry) pageScroll.current = listRef.current?.closest('[data-settings-scroll-pane]')?.scrollTop ?? 0;
|
||||
lastSelection.current = id;
|
||||
onSelect(id);
|
||||
requestAnimationFrame(() => {
|
||||
if (pane) pane.scrollTop = 0;
|
||||
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;
|
||||
detailRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
@@ -49,6 +49,7 @@ 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]'))
|
||||
@@ -57,14 +58,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')}>
|
||||
{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>}
|
||||
<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>}
|
||||
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</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>
|
||||
<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>}
|
||||
{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>}
|
||||
@@ -72,26 +73,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="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>
|
||||
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>
|
||||
<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 bg-muted/40 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 border px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
|
||||
</button>;
|
||||
})}</div>
|
||||
</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">
|
||||
</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">
|
||||
<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 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>
|
||||
<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>
|
||||
{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>{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>}
|
||||
{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>}
|
||||
{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,6 +1,7 @@
|
||||
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';
|
||||
@@ -75,13 +76,14 @@ 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 focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
|
||||
{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'}`}>
|
||||
<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)}>
|
||||
@@ -107,6 +109,7 @@ 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>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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,7 +3,6 @@ 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';
|
||||
@@ -287,7 +286,6 @@ 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();
|
||||
@@ -316,13 +314,10 @@ 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>
|
||||
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
{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="installed" className="m-0 space-y-6">
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
{contextualScopes.size > 0 && (
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.plugin-marketplace-browser { min-width: 0; }
|
||||
/* 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-catalog { min-width: 0; }
|
||||
.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-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-detail { overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
@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; }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
@@ -226,3 +226,30 @@ 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).
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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.
|
||||
@@ -217,6 +217,8 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: pluginHostService?.manager,
|
||||
permissionEngine: pluginHostService?.permissionEngine,
|
||||
database: pluginHostService?.database,
|
||||
catalogService: pluginHostService?.catalogService,
|
||||
contributionService: pluginHostService?.contributionService,
|
||||
terminalProviderService: pluginHostService?.terminalProviderService,
|
||||
|
||||
@@ -231,6 +231,131 @@
|
||||
},
|
||||
"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-]+)*)?$"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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");
|
||||
@@ -92,7 +93,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -223,7 +224,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -353,6 +354,7 @@ function createPluginHostService(options) {
|
||||
})
|
||||
: null;
|
||||
const manager = new PluginManager({
|
||||
permissionEngine,
|
||||
database,
|
||||
packageStore,
|
||||
runtimeSupervisor,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"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();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
"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");
|
||||
@@ -180,15 +184,7 @@ class PluginNetworkBroker {
|
||||
// network.request lease injection would become dead code.
|
||||
if (params?.credentialLease !== undefined) {
|
||||
const lease = params.credentialLease;
|
||||
if (
|
||||
!lease
|
||||
|| typeof lease !== "object"
|
||||
|| Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string"
|
||||
|| typeof lease.operationId !== "string"
|
||||
|| !Number.isFinite(lease.expiresAt)
|
||||
) {
|
||||
if (!validateLease(lease)) {
|
||||
throw invalidArgument("Plugin network credential lease is invalid");
|
||||
}
|
||||
validated.credentialLease = Object.freeze({
|
||||
@@ -200,7 +196,7 @@ class PluginNetworkBroker {
|
||||
}
|
||||
if (params?.authorization !== undefined) {
|
||||
const authorization = params.authorization;
|
||||
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
|
||||
if (!validateAuthorization(authorization)) {
|
||||
throw invalidArgument("Plugin network authorization is invalid");
|
||||
}
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
|
||||
@@ -172,3 +172,14 @@ 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/);
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
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,
|
||||
@@ -14,6 +17,8 @@ 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",
|
||||
@@ -657,6 +662,36 @@ 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 },
|
||||
|
||||
@@ -1635,3 +1635,35 @@ 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]);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ 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();
|
||||
@@ -128,6 +129,21 @@ 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();
|
||||
|
||||
@@ -511,3 +511,48 @@ 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);
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test("networkBroker.validate preserves credentialLease for registry dispatch", (
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
id: "l".repeat(32),
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
|
||||
@@ -103,6 +103,8 @@ 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,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
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 };
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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
@@ -355,6 +355,14 @@ 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;
|
||||
@@ -436,6 +444,8 @@ 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,6 +178,131 @@
|
||||
},
|
||||
"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,6 +355,8 @@ 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);
|
||||
@@ -481,6 +483,34 @@ 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;
|
||||
|
||||
@@ -49,3 +49,5 @@ 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;
|
||||
@@ -395,3 +395,21 @@ 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' } });
|
||||
`);
|
||||
});
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
ConnectionStatusResult,
|
||||
ConnectionValidateResult,
|
||||
CredentialRef,
|
||||
PluginNetworkRequest,
|
||||
PluginNetworkResponse,
|
||||
FeatureId,
|
||||
ImporterDetectPayload,
|
||||
ImporterDetectResult,
|
||||
@@ -636,20 +638,7 @@ export interface PluginCredentialBroker {
|
||||
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
|
||||
}
|
||||
|
||||
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 type { PluginNetworkAuthorization, PluginNetworkRequest, PluginNetworkResponse } from "@netcatty/plugin-contract";
|
||||
|
||||
export interface PluginNetworkClient {
|
||||
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
|
||||
@@ -922,3 +911,5 @@ 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";
|
||||
Reference in new issue
Block a user