mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3734d161 | ||
|
|
185eac3849 | ||
|
|
9af123e5d7 | ||
|
|
e5bd294cdd | ||
|
|
b48567d448 | ||
|
|
45ed193579 | ||
|
|
f471a17a75 | ||
|
|
257660bcc1 | ||
|
|
eaf8c9a385 | ||
|
|
ff42d0c1b9 | ||
|
|
311765eaa1 |
No files matched your search
@@ -1245,6 +1245,20 @@ 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.statusUnknown": "Status unknown",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.title": "Saved grants",
|
||||
"settings.plugins.permissions.refresh": "Refresh",
|
||||
"settings.plugins.permissions.loading": "Loading grants...",
|
||||
"settings.plugins.permissions.empty": "No saved 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.",
|
||||
@@ -1255,6 +1269,7 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"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.market.installed": "Installed",
|
||||
|
||||
@@ -1245,6 +1245,20 @@ 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.statusUnknown": "Estado desconocido",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.title": "Autorizaciones guardadas",
|
||||
"settings.plugins.permissions.refresh": "Actualizar",
|
||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
||||
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. 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.",
|
||||
@@ -1255,6 +1269,7 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"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.market.installed": "Instalado",
|
||||
|
||||
@@ -1140,6 +1140,20 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"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": "Привилегированные разрешения не заявлены.",
|
||||
@@ -1150,6 +1164,7 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
|
||||
"settings.plugins.manager.details": "Подробнее",
|
||||
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
|
||||
"settings.plugins.market.installed": "Установлен",
|
||||
|
||||
@@ -1160,6 +1160,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"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": "未声明特权权限。",
|
||||
@@ -1170,6 +1184,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
|
||||
"settings.plugins.manager.details": "查看详情",
|
||||
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
|
||||
"settings.plugins.market.installed": "已安装",
|
||||
|
||||
@@ -1163,6 +1163,20 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"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": "未聲明特權權限。",
|
||||
@@ -1179,6 +1193,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
|
||||
"settings.plugins.manager.details": "查看詳情",
|
||||
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
|
||||
"settings.plugins.market.installed": "已安裝",
|
||||
|
||||
@@ -16,7 +16,7 @@ test('README failures clear on retry/switch and stale requests cannot replace th
|
||||
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
netcatty: {
|
||||
listPluginCatalog: async () => [],
|
||||
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
|
||||
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
|
||||
},
|
||||
} });
|
||||
@@ -50,3 +50,35 @@ test('README failures clear on retry/switch and stale requests cannot replace th
|
||||
await act(async () => calls[4].request.resolve('late content'));
|
||||
assert.equal(renders, unmountedRenders);
|
||||
});
|
||||
|
||||
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
|
||||
let offline = true;
|
||||
const reads: unknown[][] = []; const installs: unknown[][] = [];
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
dispatchEvent() {}, netcatty: {
|
||||
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
|
||||
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
|
||||
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
|
||||
},
|
||||
} });
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
context.after(() => {
|
||||
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginCatalog>;
|
||||
function Probe() { state = usePluginCatalog(entry.id); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
|
||||
assert.equal(state.source, 'cached');
|
||||
offline = false;
|
||||
await act(async () => state.retryList());
|
||||
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second']]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type ReadmeState = { id: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
export function usePluginCatalog(selectedId: string | null) {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [listAttempt, setListAttempt] = useState(0);
|
||||
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
|
||||
@@ -22,29 +23,30 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
if (active) { setEntries(list); setListStatus('ready'); }
|
||||
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
let active = true;
|
||||
const base = { id: selectedId, attempt: readmeAttempt, text: '', error: null };
|
||||
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
|
||||
setReadmeState({ ...base, status: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId);
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, readmeAttempt]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
}, [selectedId, selectionToken, readmeAttempt]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError,
|
||||
entries, listStatus, listError, source,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
@@ -59,7 +61,7 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id);
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
|
||||
finally { installing.current = false; if (mounted.current) setBusy(false); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { usePluginManager } from './usePluginManager';
|
||||
|
||||
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let fail = false;
|
||||
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
addEventListener() {}, removeEventListener() {}, netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
|
||||
},
|
||||
} });
|
||||
context.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginManager>;
|
||||
function Probe() { state = usePluginManager(); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, true);
|
||||
fail = true;
|
||||
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
|
||||
assert.equal(state.statusKnown, false);
|
||||
assert.match(state.error ?? '', /synthetic status read failed/);
|
||||
fail = false; plugin.enabled = false;
|
||||
await act(async () => { await state.refresh(); });
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, false);
|
||||
} finally { await act(async () => { renderer.unmount(); }); }
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [statusKnown, setStatusKnown] = useState(false);
|
||||
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -11,13 +12,22 @@ export function usePluginManager() {
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setStatusKnown(true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
} catch (cause) {
|
||||
if (current === generation.current) {
|
||||
setStatusKnown(false);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
@@ -41,7 +51,7 @@ export function usePluginManager() {
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error, credentialStorageAvailable,
|
||||
plugins, available, statusKnown, 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'] }]);
|
||||
|
||||
@@ -53,6 +53,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
|
||||
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
|
||||
assert.equal(cards().length, 0);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
|
||||
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
|
||||
assert.equal(unknown.length, 2);
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
|
||||
await act(async () => renderer.unmount());
|
||||
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
|
||||
});
|
||||
@@ -14,7 +14,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
|
||||
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
|
||||
})) as NetcattyPluginCatalogEntry[];
|
||||
const catalog = {
|
||||
entries, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
|
||||
} as ReturnType<typeof usePluginCatalog>;
|
||||
|
||||
@@ -125,3 +125,22 @@ test('narrow detail starts at the top and back restores the settings scroll and
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test('offline catalog warning keeps details visible and disables market installation', () => {
|
||||
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], listError: 'Registry offline', source: 'cached' }}
|
||||
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
|
||||
assert.match(html, /Registry offline/);
|
||||
assert.match(html, /Installation is unavailable until refresh succeeds/);
|
||||
assert.match(html, /Compatibility and permissions/);
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
|
||||
assert.match(html, /Retry/);
|
||||
});
|
||||
|
||||
test('manual refresh remains available after success and is disabled while loading', () => {
|
||||
const ready = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
|
||||
assert.match(ready, />Refresh<\/button>/);
|
||||
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
|
||||
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
|
||||
});
|
||||
@@ -59,7 +59,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
});
|
||||
};
|
||||
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.market.note')}</p>
|
||||
<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 ? ' plugin-marketplace-browser--selected' : ''}`}>
|
||||
<div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
|
||||
@@ -89,7 +90,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
|
||||
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
|
||||
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
|
||||
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p><Button size="sm" disabled={catalog.busy} 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>}
|
||||
{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>}
|
||||
|
||||
@@ -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';
|
||||
@@ -90,13 +91,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" type="button"
|
||||
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
|
||||
style={plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
|
||||
aria-pressed={plugin.enabled} aria-busy={manager.busy}
|
||||
aria-label={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
|
||||
title={t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
|
||||
disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
|
||||
{t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
|
||||
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
|
||||
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
|
||||
aria-pressed={manager.statusKnown === false ? undefined : plugin.enabled} aria-busy={manager.busy}
|
||||
aria-label={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
|
||||
title={t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
|
||||
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
|
||||
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
|
||||
</Button>
|
||||
</div>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
@@ -108,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,145 @@
|
||||
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, matchesCurrentActivation: false }],
|
||||
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/);
|
||||
assert.match(document.body.textContent!, /Inactive for the current activation/);
|
||||
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 saved 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('partial revocation failure refreshes actual manager and engine state without clearing the original error', 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>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'IS_REACT_ACT_ENVIRONMENT']) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : key === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[key as keyof typeof dom.window] });
|
||||
}
|
||||
const { act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
|
||||
const { PluginManager } = await import('../../../electron/plugins/pluginManager.cjs');
|
||||
const { PluginPermissionEngine } = await import('../../../electron/plugins/permissionEngine.cjs');
|
||||
const { defaultSecurityPrincipal, normalizePermissionDeclarations, permissionDeclarationHash } = await import('../../../electron/plugins/permissionResources.cjs');
|
||||
const manifest = { id: 'com.example.synthetic', publisher: 'example', permissions: { optional: ['storage'] } };
|
||||
const principal = defaultSecurityPrincipal(manifest);
|
||||
const grant = { pluginId: manifest.id, permission: 'storage', resource: '*', resourceKind: 'exact', grantedAt: 1,
|
||||
declarationHash: permissionDeclarationHash(normalizePermissionDeclarations(manifest).get('storage'), principal) };
|
||||
let enabled = true, running = true, stopped = false, readFails = false;
|
||||
let grants = [grant];
|
||||
let reads = 0, changed = 0;
|
||||
const database = {
|
||||
getActivePlugin: () => ({ id: manifest.id, enabled, manifest }),
|
||||
listPlugins: () => [{ id: manifest.id, enabled, manifest }],
|
||||
setEnabled(_id: string, value: boolean) { enabled = value; },
|
||||
listPermissionGrants: () => grants,
|
||||
deleteAllPermissionGrants() { grants = []; },
|
||||
recordSecurityAudit() { throw new Error('synthetic audit write failed'); },
|
||||
};
|
||||
const engine = new PluginPermissionEngine({ database });
|
||||
const manager = new PluginManager({ database, permissionEngine: engine,
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: { async initialize() {}, onPluginDisabled() {} },
|
||||
runtimeSupervisor: { async stop() { running = false; stopped = true; } },
|
||||
});
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginPermissions() {
|
||||
reads++;
|
||||
if (readFails) throw new Error('synthetic grants refresh failed');
|
||||
const groups = engine.describeGrants(manifest.id, running ? { pluginId: manifest.id, manifest, securityPrincipal: principal } : null);
|
||||
return { grants: groups.always, audit: [] };
|
||||
},
|
||||
async revokePluginPermissions(id: string) { return manager.revokePermissions(id); },
|
||||
} });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const refreshManager = async () => { changed++; assert.equal((await manager.list())[0].enabled, false); };
|
||||
const revoke = () => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === 'Disable and revoke all grants')!;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="first" pluginId={manifest.id} onChanged={refreshManager} /></I18nProvider>));
|
||||
assert.match(document.body.textContent!, /Matches the current activation/);
|
||||
await act(async () => { revoke().click(); });
|
||||
assert.equal(enabled, false); assert.equal(stopped, true);
|
||||
assert.equal(changed, 1); assert.equal(reads, 2);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic audit write failed/);
|
||||
assert.match(document.body.textContent!, /No saved grants/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
|
||||
// A failed state read must also remove the old grant snapshot while
|
||||
// retaining the mutation error and reporting the refresh failure.
|
||||
enabled = true; running = true; stopped = false; grants = [grant];
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="second" pluginId={manifest.id} onChanged={async () => { await refreshManager(); readFails = true; throw new Error('synthetic manager refresh failed'); }} /></I18nProvider>));
|
||||
assert.match(document.body.textContent!, /Matches the current activation/);
|
||||
await act(async () => { revoke().click(); });
|
||||
assert.equal(enabled, false); assert.equal(stopped, true);
|
||||
assert.equal(changed, 2); assert.equal(reads, 4);
|
||||
const error = document.querySelector('[role="alert"]')?.textContent ?? '';
|
||||
assert.match(error, /synthetic audit write failed/);
|
||||
assert.match(error, /synthetic manager refresh failed/);
|
||||
assert.match(error, /synthetic grants refresh failed/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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 (preserveError = false) => {
|
||||
const current = ++generation.current;
|
||||
if (!preserveError) 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) {
|
||||
setSnapshot(null);
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError((previous) => preserveError && previous ? `${previous}\n${message}` : message);
|
||||
}
|
||||
}
|
||||
}, [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);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await onChanged(); }
|
||||
catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError((previous) => previous ? `${previous}\n${message}` : message);
|
||||
}
|
||||
await refresh(true);
|
||||
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(grant.matchesCurrentActivation ? 'settings.plugins.permissions.current' : 'settings.plugins.permissions.inactive')}</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>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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 | Contributions, scoped setting API, environment updates | No arbitrary application settings write | Existing namespaced contributions. Candidate: declarative global ThemePack using the 19 semantic tokens in `uiThemes.ts`, with preview/apply/reset and uninstall fallback | Settings can contain sensitive plugin values. A theme must preserve contrast and security UI | Plugin namespace, declared scope | Existing persistence and lifecycle, explicit sync flag | Existing tests. ThemePack design pending parent review of allowed tokens, accessibility and fallback. No arbitrary host DOM/CSS, remote CSS/fonts or security UI hiding. Arbitrary host settings mutation deferred |
|
||||
| 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. 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.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Official registry client
|
||||
|
||||
The Community page refreshes the fixed endpoint
|
||||
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
|
||||
in the main process. The renderer cannot choose a registry URL. This endpoint
|
||||
must be published separately; client implementation and synthetic tests do not
|
||||
prove the repository or an online artifact is available.
|
||||
|
||||
## Catalog contract
|
||||
|
||||
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
|
||||
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
|
||||
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
|
||||
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
|
||||
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
|
||||
including manifest defaults, uses the existing 128-level / 100,000-value budget
|
||||
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
|
||||
and license checks apply. Source-only entries remain build-and-import examples.
|
||||
No download counts, ratings, or timestamps are invented. Brand previews remain
|
||||
bundled and reviewed; arbitrary author image URLs are never loaded.
|
||||
|
||||
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
|
||||
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
|
||||
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
|
||||
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
|
||||
when the last refresh succeeded; it is disabled while a request is pending.
|
||||
The last good catalog is retained **in memory for this application session**;
|
||||
a fresh offline launch uses bundled examples. There is no new disk cache,
|
||||
database migration, cloud sync record, or credential requirement.
|
||||
|
||||
## Selection and installation
|
||||
|
||||
Each returned entry has a host-generated `selectionToken` covering its complete
|
||||
JSON contents. This is a concurrency check, **not a signature or trust proof**.
|
||||
README requests must still match that selection and fetch its pinned source
|
||||
commit. A changed entry requires refresh and renewed review.
|
||||
|
||||
A manual install must first refresh the official catalog successfully and match
|
||||
the selected token again. Failed refresh is browse-only, even if the cached
|
||||
entry has an artifact. Withdrawal, version changes, permission changes, README
|
||||
changes, or artifact hash changes cannot silently replace the selected package.
|
||||
Archive download then uses the existing approved-host, size, exact SHA-256,
|
||||
manifest and package validation pipeline. Installation leaves the plugin disabled;
|
||||
existing authorization gates still apply on activation/use.
|
||||
|
||||
`index.json` release history and withdrawal records are not consumed by this
|
||||
first client. Removing an entry does **not** disable, uninstall, update, or delete
|
||||
data from an already installed plugin. There are no background code updates.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
Service regressions use synthetic network responses and package bytes; the
|
||||
existing package manager tests cover archive admission. No third-party plugin,
|
||||
real credential, real sync account, or live registry write is used. Native QA
|
||||
must separately verify the visible offline/retry state and eventual real catalog
|
||||
availability once the repository is published.
|
||||
@@ -226,3 +226,33 @@ 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 **Saved
|
||||
grants**. The host-only panel lists resource, scope, session (when applicable),
|
||||
and grant time. Each row indicates whether its declaration hash and security
|
||||
principal match the host-owned current activation. Saved grants from an older
|
||||
package or a stopped/disabled activation remain visible as inactive. 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. Every cleanup stage is attempted if grant deletion, audit persistence, or
|
||||
contribution invalidation fails. Multiple errors are aggregated and reported;
|
||||
the UI never claims successful revocation when persistence or cleanup failed. 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).
|
||||
@@ -217,6 +217,9 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: pluginHostService?.manager,
|
||||
permissionEngine: pluginHostService?.permissionEngine,
|
||||
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
|
||||
database: pluginHostService?.database,
|
||||
catalogService: pluginHostService?.catalogService,
|
||||
contributionService: pluginHostService?.contributionService,
|
||||
terminalProviderService: pluginHostService?.terminalProviderService,
|
||||
|
||||
@@ -27,3 +27,5 @@ require("./plugins/utilityPluginRuntime.test.cjs");
|
||||
require('./plugins/brandIconService.test.cjs');
|
||||
|
||||
require('./plugins/settingsViewFixture.test.cjs');
|
||||
|
||||
require('./plugins/registryCatalogService.test.cjs');
|
||||
@@ -8,6 +8,9 @@ const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
|
||||
const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs");
|
||||
const validateManifest = createDefinitionValidator("PluginManifest");
|
||||
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
|
||||
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
function assertDownloadUrl(value) {
|
||||
@@ -17,11 +20,22 @@ function assertDownloadUrl(value) {
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function pinnedReadmeUrl(entry) {
|
||||
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|
||||
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid or unpinned README path");
|
||||
}
|
||||
const prefix = `/${entry.repository}/${entry.commit}/`;
|
||||
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
|
||||
return url.href;
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
|
||||
const ids = new Set();
|
||||
for (const entry of entries) {
|
||||
if (!entry || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
|
||||
if (!entry || Object.keys(entry).some((key) => !CATALOG_FIELDS.has(key)) || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
|
||||
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(entry.repository)
|
||||
|| !/^[a-f0-9]{40}$/u.test(entry.commit)
|
||||
|| !entry.license || !entry.author || !entry.name
|
||||
@@ -29,10 +43,16 @@ function validateCatalog(entries) {
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog entry");
|
||||
}
|
||||
pinnedReadmeUrl(entry);
|
||||
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
|
||||
assertCatalogManifest(entry.manifest, entry);
|
||||
ids.add(entry.id);
|
||||
if (entry.artifact) {
|
||||
if (Object.hasOwn(entry, "artifact")) {
|
||||
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|
||||
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
|
||||
throw new Error("Invalid catalog artifact metadata");
|
||||
}
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
}
|
||||
@@ -71,7 +91,7 @@ async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
this.entries = validateCatalog(structuredClone(options.entries ?? catalog));
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
|
||||
this.fetch = options.fetch ?? fetch;
|
||||
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
|
||||
this.manager = options.manager;
|
||||
@@ -85,7 +105,7 @@ class PluginCatalogService {
|
||||
}
|
||||
async readme(id) {
|
||||
const entry = this.get(id);
|
||||
const url = `https://raw.githubusercontent.com/${entry.repository}/${entry.commit}/${entry.readme}`;
|
||||
const url = pinnedReadmeUrl(entry);
|
||||
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
|
||||
}
|
||||
async install(id) {
|
||||
@@ -107,4 +127,4 @@ class PluginCatalogService {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
@@ -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");
|
||||
@@ -17,7 +18,7 @@ const {
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { PluginCatalogService } = require("./catalogService.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.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,
|
||||
@@ -369,7 +371,7 @@ function createPluginHostService(options) {
|
||||
},
|
||||
});
|
||||
return {
|
||||
catalogService: new PluginCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -660,6 +660,22 @@ class PluginPermissionEngine {
|
||||
});
|
||||
}
|
||||
|
||||
describeGrants(pluginId, context) {
|
||||
const groups = this.listGrants(pluginId);
|
||||
const declarations = context ? normalizePermissionDeclarations(context.manifest) : new Map();
|
||||
return Object.fromEntries(Object.entries(groups).map(([scope, grants]) => [scope, grants.map((grant) => {
|
||||
const declaration = declarations.get(grant.permission);
|
||||
const matchesCurrentActivation = Boolean(
|
||||
context?.pluginId === pluginId
|
||||
&& context.securityPrincipal
|
||||
&& declaration
|
||||
&& declarationAllowsResource(declaration, grant.resource)
|
||||
&& grant.declarationHash === permissionDeclarationHash(declaration, context.securityPrincipal),
|
||||
);
|
||||
return Object.freeze({ ...grant, matchesCurrentActivation });
|
||||
})]));
|
||||
}
|
||||
|
||||
revokeAlways(pluginId, permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
const canonicalResource = canonicalizePermissionResource(permission, resource);
|
||||
|
||||
@@ -631,3 +631,25 @@ test("permission middleware rejects unclassified methods and permits explicit pu
|
||||
}, async () => "ok"), "ok");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("grant descriptions distinguish current activation from old package and changed declarations", async (context) => {
|
||||
const database = createDatabase(context);
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({
|
||||
requestId: request.requestId, decision: "allow", scope: "always",
|
||||
}) });
|
||||
const pluginManifest = manifest({ optional: [{ permission: "network", resources: ["https://example.invalid"] }] });
|
||||
const first = runtimeContext(pluginManifest, { securityPrincipal: defaultSecurityPrincipal(pluginManifest, "a".repeat(64)) });
|
||||
const descriptor = { permission: "network", resources: ["https://example.invalid"], reason: "Synthetic request" };
|
||||
await engine.authorize(first, descriptor);
|
||||
assert.equal(engine.describeGrants(first.pluginId, first).always[0].matchesCurrentActivation, true);
|
||||
assert.equal(engine.describeGrants(first.pluginId, null).always[0].matchesCurrentActivation, false);
|
||||
const upgraded = { ...first, securityPrincipal: defaultSecurityPrincipal(pluginManifest, "b".repeat(64)) };
|
||||
assert.equal(engine.describeGrants(first.pluginId, upgraded).always[0].matchesCurrentActivation, false);
|
||||
await assert.rejects(engine.authorize(upgraded, { ...descriptor, interactive: false }), /permission/i);
|
||||
const changed = { ...first, manifest: { ...pluginManifest, permissions: { optional: [{ permission: "network", resources: ["https://example.invalid", "https://other.invalid"] }] } } };
|
||||
assert.equal(engine.describeGrants(first.pluginId, changed).always[0].matchesCurrentActivation, false);
|
||||
await assert.rejects(engine.authorize(changed, { ...descriptor, interactive: false }), /permission/i);
|
||||
assert.equal(engine.describeGrants(first.pluginId, { ...first, manifest: { ...pluginManifest, permissions: {} } }).always[0].matchesCurrentActivation, false);
|
||||
engine.shutdown();
|
||||
database.close();
|
||||
});
|
||||
@@ -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",
|
||||
@@ -647,16 +652,55 @@ function registerPluginBridge(ipcMain, options) {
|
||||
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
|
||||
};
|
||||
});
|
||||
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? []);
|
||||
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.readme(payload?.id);
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken);
|
||||
});
|
||||
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.install(payload?.id);
|
||||
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 plugin = options.database.getActivePlugin(pluginId);
|
||||
const identity = options.runtimeSupervisor?.getRuntimeIdentity(pluginId);
|
||||
// A stored grant is effective only for its matching host-owned activation.
|
||||
// Disabled/lazy/stopped plugins retain saved grants without claiming they
|
||||
// currently apply. Never infer a principal from plugin-provided metadata.
|
||||
const context = plugin?.enabled && identity?.pluginVersion === plugin.activeVersion
|
||||
? { ...identity, manifest: plugin.manifest }
|
||||
: null;
|
||||
const groups = options.permissionEngine.describeGrants(pluginId, context);
|
||||
// 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,
|
||||
matchesCurrentActivation: grant.matchesCurrentActivation,
|
||||
}))
|
||||
));
|
||||
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,62 @@ 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: { describeGrants(id, context) {
|
||||
assert.equal(context, null);
|
||||
reads++;
|
||||
assert.equal(id, "com.example.synthetic");
|
||||
return { always: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", grantedAt: 12, matchesCurrentActivation: false, declarationHash: "private" }], application: [], session: [] };
|
||||
} },
|
||||
database: { getActivePlugin() { return null; }, 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, matchesCurrentActivation: false }],
|
||||
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]);
|
||||
});
|
||||
|
||||
test("grant classification uses only the current host activation and stops matching after disable or upgrade", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
let plugin = { enabled: true, activeVersion: "1.0.0", manifest: { id: "com.example.synthetic" } };
|
||||
let identity = { pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "runtime-host", securityPrincipal: "host-owned-principal" };
|
||||
const contexts = [];
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {} }, isTrustedSender: () => true,
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity },
|
||||
permissionEngine: { describeGrants(_id, context) { contexts.push(context); return { always: [], application: [], session: [] }; } },
|
||||
database: { getActivePlugin: () => plugin, listSecurityAudit: () => [] },
|
||||
});
|
||||
const payload = { pluginId: "com.example.synthetic", securityPrincipal: "plugin-forgery", manifest: {} };
|
||||
const read = ipcMain.handlers.get(CHANNELS.permissions);
|
||||
await read({}, payload);
|
||||
assert.deepEqual(contexts[0], { ...identity, manifest: plugin.manifest });
|
||||
plugin = { ...plugin, enabled: false };
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[1], null);
|
||||
plugin = { ...plugin, enabled: true, activeVersion: "2.0.0" };
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[2], null, "old activation cannot label upgraded package grants as current");
|
||||
identity = null;
|
||||
await read({}, payload);
|
||||
assert.equal(contexts[3], null, "stopped and lazy plugins show saved grants as inactive");
|
||||
});
|
||||
@@ -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,26 @@ 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.
|
||||
const errors = [];
|
||||
// Every containment stage must be attempted even when persistence,
|
||||
// revocation audit, or contribution invalidation fails.
|
||||
try { this.database.setEnabled(pluginId, false); } catch (error) { errors.push(error); }
|
||||
try { this.permissionEngine.revokeAll(pluginId); } catch (error) { errors.push(error); }
|
||||
try { this.contributionService.onPluginDisabled(pluginId); } catch (error) { errors.push(error); }
|
||||
try { await this.runtimeSupervisor.stop(pluginId); } catch (error) { errors.push(error); }
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Plugin grant revocation or cleanup failed");
|
||||
return this.database.getActivePlugin(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
restart(pluginId) {
|
||||
return this.#mutate(async () => {
|
||||
await this.#ready();
|
||||
|
||||
@@ -511,3 +511,75 @@ 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);
|
||||
});
|
||||
|
||||
test("revocation attempts every cleanup stage and aggregates independent failures", async () => {
|
||||
for (const failedStages of [["revoke"], ["contributions"], ["disable"], ["stop"], ["disable", "revoke", "contributions", "stop"]]) {
|
||||
const calls = [];
|
||||
const fail = (stage) => {
|
||||
calls.push(stage);
|
||||
if (failedStages.includes(stage)) throw new Error(`${stage} failed`);
|
||||
};
|
||||
const manager = new PluginManager({
|
||||
database: {
|
||||
getActivePlugin: () => ({ id: "com.example.revoke", enabled: false }),
|
||||
setEnabled() { fail("disable"); },
|
||||
},
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: { async initialize() {}, onPluginDisabled() { fail("contributions"); } },
|
||||
permissionEngine: { revokeAll() { fail("revoke"); } },
|
||||
runtimeSupervisor: { async stop() { fail("stop"); } },
|
||||
});
|
||||
await assert.rejects(manager.revokePermissions("com.example.revoke"), (error) => {
|
||||
if (failedStages.length === 1) return error.message === `${failedStages[0]} failed`;
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors.map((entry) => entry.message), failedStages.map((stage) => `${stage} failed`));
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(calls, ["disable", "revoke", "contributions", "stop"]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
|
||||
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
|
||||
|
||||
function prepareSnapshot(value) {
|
||||
const entries = structuredClone(validateCatalog(value));
|
||||
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
|
||||
return { entries, publicEntries };
|
||||
}
|
||||
|
||||
class RegistryCatalogService extends PluginCatalogService {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
|
||||
this.source = "builtin";
|
||||
this.error = null;
|
||||
this.refreshing = null;
|
||||
}
|
||||
async refresh() {
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = (async () => {
|
||||
try {
|
||||
// No redirects: only this exact registry endpoint can replace the catalog.
|
||||
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
|
||||
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
|
||||
// Publish only after full validation, clones and every token succeeded.
|
||||
this.entries = next.entries;
|
||||
this.publicEntries = next.publicEntries;
|
||||
this.source = "registry";
|
||||
this.error = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (this.source === "registry") this.source = "cached";
|
||||
this.error = String(error.message ?? error).slice(0, 1024);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
try { return await this.refreshing; } finally { this.refreshing = null; }
|
||||
}
|
||||
async list() {
|
||||
await this.refresh();
|
||||
return {
|
||||
entries: structuredClone(this.publicEntries),
|
||||
source: this.source,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
selected(id, token) {
|
||||
const entry = this.get(id);
|
||||
if (typeof token !== "string" || token !== tokenFor(entry)) {
|
||||
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
async readme(id, token) {
|
||||
this.selected(id, token);
|
||||
// Base method captures the pinned entry synchronously before its first await.
|
||||
return super.readme(id);
|
||||
}
|
||||
async install(id, token) {
|
||||
this.selected(id, token);
|
||||
if (!await this.refresh()) throw new Error("Registry refresh failed. Cached catalogs are browse-only; retry when online.");
|
||||
this.selected(id, token);
|
||||
return super.install(id);
|
||||
}
|
||||
}
|
||||
module.exports = { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES };
|
||||
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
|
||||
const builtin = require("./catalog.json");
|
||||
|
||||
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
|
||||
let response = () => new Response(JSON.stringify([builtin[0]]));
|
||||
const service = new RegistryCatalogService({ fetch: async (url, options) => {
|
||||
assert.equal(url, REGISTRY_URL);
|
||||
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
|
||||
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
|
||||
return response();
|
||||
} });
|
||||
const online = await service.list();
|
||||
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
|
||||
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
|
||||
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
|
||||
response = () => new Response(invalid);
|
||||
const cached = await service.list();
|
||||
assert.equal(cached.source, "cached"); assert.ok(cached.error);
|
||||
assert.deepEqual(cached.entries, online.entries);
|
||||
}
|
||||
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
|
||||
assert.match((await service.list()).error, /size limit/);
|
||||
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
|
||||
assert.match((await service.list()).error, /redirects/);
|
||||
response = () => new Response("[]");
|
||||
const empty = await service.list();
|
||||
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
|
||||
});
|
||||
|
||||
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
|
||||
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
|
||||
install() { assert.fail("offline must not install"); },
|
||||
} });
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
|
||||
assert.match(snapshot.error, /offline/);
|
||||
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
|
||||
});
|
||||
|
||||
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
|
||||
let entries = structuredClone(builtin);
|
||||
let requests = 0;
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => {
|
||||
requests++;
|
||||
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
|
||||
return new Response("# Pinned README");
|
||||
}, manager: { install() { assert.fail("changed selection must not install"); } } });
|
||||
const selected = (await service.list()).entries[0];
|
||||
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
|
||||
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
|
||||
const before = requests;
|
||||
await assert.rejects(service.install(selected.id), /changed/);
|
||||
assert.equal(requests, before);
|
||||
entries[0].description += " updated";
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
|
||||
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
|
||||
entries = [];
|
||||
const next = (await service.list()).entries;
|
||||
assert.deepEqual(next, []);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
|
||||
});
|
||||
|
||||
test("concurrent list refreshes share one request", async () => {
|
||||
let finish; let calls = 0;
|
||||
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
|
||||
const first = service.list(); const second = service.list();
|
||||
finish(new Response(JSON.stringify(builtin)));
|
||||
assert.deepEqual(await first, await second); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
|
||||
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
|
||||
const { createHash } = require("node:crypto");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic validated-by-manager archive");
|
||||
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const requests = []; let installs = 0;
|
||||
const service = new RegistryCatalogService({
|
||||
getTempFilePath: (name) => path.join(root, name),
|
||||
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
|
||||
manager: { async install(archive, options) {
|
||||
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
|
||||
assert.deepEqual(options.catalog, entry); return { id: entry.id };
|
||||
} },
|
||||
});
|
||||
const selected = (await service.list()).entries[0];
|
||||
await service.install(selected.id, selected.selectionToken);
|
||||
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
|
||||
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
|
||||
});
|
||||
|
||||
|
||||
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
|
||||
let entries = structuredClone(builtin);
|
||||
const requested = [];
|
||||
const service = new RegistryCatalogService({ fetch: async (url) => {
|
||||
requested.push(url); return new Response(JSON.stringify(entries));
|
||||
} });
|
||||
const good = await service.list();
|
||||
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
|
||||
entries = [{ ...builtin[0], readme }];
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
|
||||
assert.deepEqual(snapshot.entries, good.entries);
|
||||
}
|
||||
entries = [{ ...builtin[0], repository: "example/.." }];
|
||||
assert.match((await service.list()).error, /escapes the pinned commit/);
|
||||
assert.ok(requested.every((url) => url === REGISTRY_URL));
|
||||
});
|
||||
|
||||
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
|
||||
let response = JSON.stringify(builtin);
|
||||
const service = new RegistryCatalogService({ fetch: async () => {
|
||||
if (response === null) throw new Error("offline");
|
||||
return new Response(response);
|
||||
} });
|
||||
const good = await service.list();
|
||||
let nested = null;
|
||||
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
|
||||
const nonfinite = structuredClone(builtin[0]);
|
||||
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
|
||||
for (const invalid of [
|
||||
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
|
||||
JSON.stringify([{ ...builtin[0], unknownField: true }]),
|
||||
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
|
||||
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
|
||||
]) {
|
||||
response = invalid;
|
||||
const rejected = await service.list();
|
||||
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
|
||||
assert.deepEqual(rejected.entries, good.entries);
|
||||
response = null;
|
||||
const offline = await service.list();
|
||||
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
|
||||
}
|
||||
});
|
||||
|
||||
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
|
||||
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
|
||||
const good = await service.list();
|
||||
const originalClone = global.structuredClone;
|
||||
try {
|
||||
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
|
||||
assert.equal(await service.refresh(), false);
|
||||
} finally { global.structuredClone = originalClone; }
|
||||
service.fetch = async () => { throw new Error("offline"); };
|
||||
assert.deepEqual((await service.list()).entries, good.entries);
|
||||
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -100,9 +100,11 @@ function createPreloadApi(ctx) {
|
||||
return {
|
||||
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
|
||||
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
|
||||
getPluginCatalogReadme: (id) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id }),
|
||||
installCatalogPlugin: (id) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id }),
|
||||
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 **Saved 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
+21
-3
@@ -334,7 +334,14 @@ declare global {
|
||||
credentialStorageAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface NetcattyPluginCatalogSnapshot {
|
||||
entries: NetcattyPluginCatalogEntry[];
|
||||
source: "registry" | "cached" | "builtin";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface NetcattyPluginCatalogEntry {
|
||||
selectionToken?: string;
|
||||
id: string;
|
||||
version: string;
|
||||
name: string;
|
||||
@@ -348,6 +355,15 @@ 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;
|
||||
matchesCurrentActivation: boolean;
|
||||
}>;
|
||||
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
|
||||
}
|
||||
|
||||
interface NetcattyInstalledPlugin {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
@@ -425,10 +441,12 @@ declare global {
|
||||
|
||||
interface NetcattyBridge {
|
||||
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogEntry[]>;
|
||||
getPluginCatalogReadme?(id: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string): Promise<NetcattyInstalledPlugin>;
|
||||
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
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