Compare commits

..
24 changed files with 316 additions and 363 deletions

No files matched your search

+5 -2
View File
@@ -1245,10 +1245,13 @@ Highlight the focused split pane:
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.title": "Active grants",
"settings.plugins.permissions.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 active grants. One-use decisions are not retained.",
"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",
+5 -2
View File
@@ -1245,10 +1245,13 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.title": "Autorizaciones activas",
"settings.plugins.permissions.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 activas. Las decisiones de un solo uso no se conservan.",
"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",
+5 -2
View File
@@ -1140,10 +1140,13 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.title": "Действующие разрешения",
"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.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
+5 -2
View File
@@ -1160,10 +1160,13 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.title": "实际授权",
"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.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
+5 -2
View File
@@ -1163,10 +1163,13 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.title": "實際授權",
"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.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
@@ -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(); }); }
});
+18 -8
View File
@@ -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, refresh,
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; }
});
@@ -91,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>}
@@ -19,7 +19,7 @@ test('permission panel refreshes real grants, revokes once, and keeps stale read
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 }],
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;
@@ -43,6 +43,7 @@ test('permission panel refreshes real grants, revokes once, and keeps stale read
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();
@@ -52,7 +53,7 @@ test('permission panel refreshes real grants, revokes once, and keeps stale read
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No active grants/);
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'); } } });
@@ -67,3 +68,78 @@ test('permission panel refreshes real grants, revokes once, and keeps stale read
}
}
});
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); }
}
});
@@ -15,16 +15,20 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async () => {
const refresh = useCallback(async (preserveError = false) => {
const current = ++generation.current;
setError(null);
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) setError(cause instanceof Error ? cause.message : String(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(() => {
@@ -41,10 +45,16 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
await onChanged();
await refresh();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { pending.current = false; setBusy(false); }
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">
@@ -58,6 +68,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
{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>)}
@@ -1,98 +0,0 @@
# Batch 1 aggregate failure comparison
Frozen base: `e5bd294cddbf58dfe04c646b6291933d980498fd`. Current:
`9af123e5d7bdb15771d69fb427faacf32c46dfb3`. Same Node 24.19.0 executable,
same installed dependency tree, same sandbox and environment. Tests use existing
synthetic fixtures. No home/cache permission or native-build bypass was added.
The 39 entries below were observed before stopping the local full suite.
Later runner failures caused by that explicit termination are excluded. This
is not a successful aggregate run and is not proof that every full-suite test
was executed. The full suite was stopped after existing/environmental failures
to avoid presenting an incomplete run as green.
## Bounded comparison result
Targeted test-name patterns selected the original 39 failures across 15 files.
Thirty-eight fail on both frozen base and current with matching failure titles
and key error signatures. The persistence-volume assertion passes on both.
Three additional paired repetitions of that assertion also pass on both: four
passing runs per side including the initial comparison. Its code is unchanged;
the aggregate-only result remains a timing-sensitive limitation, not an
established current-only regression.
The first baseline rerun reproduced 11 entries: rows 1-2 and 4-12. It did not
reproduce row 3. No new source change fixes unrelated baseline failures.
| Rows | Signature and classification |
| --- | --- |
| 1-2, 14-16, 27-33, 35-39 | Host temp-dir creation fails outside writable roots (`ENOENT` from `tempDirBridge`). Both revisions fail. Environment constraint |
| 4-9 | Markdown comparison falls back to source; expected rich-preview elements are absent. Both revisions fail. Existing baseline/dependency behavior; no source repair in this scope |
| 10-12 | Native `pty.node` missing from normal build/prebuild lookup. Both revisions fail. Dependency/native setup constraint |
| 13 | UI algorithm list includes `ssh-ed25519-cert-v01@openssh.com` but installed ssh2 rejects it. Both revisions fail. Existing baseline/dependency behavior |
| 17-26 | Cursor discovery/SDK expectations differ (`false` vs `true`, then expected installation/auth DTO). Both revisions fail with matching assertion signatures. Existing environment/dependency behavior; no real account accessed |
| 34 | Electron bootstrap fetch fails and the binary is not installed. Both revisions fail. Dependency/network setup constraint |
| 3 | Persistence writes exceed aggregate threshold; isolated baseline/current and three repetitions each pass. Aggregate-only timing-sensitive assertion; no current-only failure reproduced |
## Original titles and first error
| # | Test file | Original title | First key error | Paired result |
| --- | --- | --- | --- | --- |
| 1 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=false | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 2 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=true | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 3 | `application/state/sftpTransferCenterStore.test.ts` | large directory progress coalesces synchronous persistence work | AssertionError [ERR_ASSERTION]: expected bounded persistence volume, wrote 2638624 characters for a 588644-character snapshot | base/current pass; aggregate-only |
| 4 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | literal Markdown comparisons render without changing the note source | AssertionError [ERR_ASSERTION]: a comparison must render as ordinary text | base/current same failure |
| 5 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | comparison variants render in paragraphs, tables and alongside HTML | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 6 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | issue 3205 author note survives source, preview and edit without rewriting | AssertionError [ERR_ASSERTION]: preview must render the author's note | base/current same failure |
| 7 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | editing a comparison emits Markdown that can be reopened | assert.ok(editable): actual null | base/current same failure |
| 8 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | preview task checkboxes still save explicit changes without rewriting comparisons | assert.ok(task): actual null | base/current same failure |
| 9 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | switching between a comparison note and an unsupported note keeps the fallback scoped | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 10 | `components/terminal/runtime/serialInputSequences.test.ts` | components/terminal/runtime/serialInputSequences.test.ts | Failed to load native module: pty.node | base/current same failure |
| 11 | `components/terminal/runtime/terminalLinePaste.test.ts` | components/terminal/runtime/terminalLinePaste.test.ts | Failed to load native module: pty.node | base/current same failure |
| 12 | `components/terminal/runtime/terminalPasteCancellation.test.ts` | components/terminal/runtime/terminalPasteCancellation.test.ts | Failed to load native module: pty.node | base/current same failure |
| 13 | `domain/sshAlgorithmList.test.ts` | SUPPORTED_ALGORITHMS_BY_CATEGORY only lists algorithms ssh2 will actually accept | AssertionError [ERR_ASSERTION]: ssh-ed25519-cert-v01@openssh.com (serverHostKey) is in the UI list but ssh2 would reject it | base/current same failure |
| 14 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as sh cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 15 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as renamed-bash cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 16 | `electron/bridges/aiBridge.externalContext.test.cjs` | electron/bridges/aiBridge.externalContext.test.cjs | ENOENT creating host temp directory from sshBridge import | base/current same failure |
| 17 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor Agent CLI on PATH as installed without CLI login | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 18 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor SDK installed but unavailable without an API key | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 19 | `electron/bridges/aiBridge.test.cjs` | resolve-cli separates Cursor SDK installation from API key availability | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 20 | `electron/bridges/aiBridge.test.cjs` | resolve-cli ignores custom Cursor paths and stores the SDK sentinel path | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 21 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when installed and authenticated | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 22 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 23 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports settings as Cursor auth source when settings and env keys both exist | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 24 | `electron/bridges/aiBridge.test.cjs` | resolve-cli can refresh shell env before resolving Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 25 | `electron/bridges/aiBridge.test.cjs` | discover exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 26 | `electron/bridges/aiBridge.test.cjs` | discover can refresh shell env before scanning Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 27 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels resolves a Windows .cmd shim to the copilot JS entry before the SDK sees it | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 28 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels retains the official loader when its platform runtime is unavailable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 29 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotPlatformExecutable resolves the platform package exe from the loader root | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 30 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotExecutableForSdk resolves npm's copilot.ps1 wrapper to the JS entry on Windows | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 31 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | connectCopilotStdioPath directly selects the official loader's native runtime | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 32 | `electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs` | OpenCode releases the process-env gate after spawning separate catalog profiles | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 33 | `electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs` | createOpenCodeProcessEnv prefers an existing opencode binary | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 34 | `electron/bridges/aiEchoSuppressionOrdering.test.cjs` | electron/bridges/aiEchoSuppressionOrdering.test.cjs | fetch failed; Electron failed to install correctly | base/current same failure |
| 35 | `electron/bridges/autoUpdateBridge.test.cjs` | recognizes packaged Linux FPM formats as auto-update capable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 36 | `electron/bridges/autoUpdateBridge.test.cjs` | allows the update check to reach electron-updater for a packaged Linux deb | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 37 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps the manual-update fallback for an unmarked Linux package | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 38 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps AppImage auto-update support on Linux | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 39 | `electron/bridges/autoUpdateBridge.test.cjs` | install handler reports package-manager failures and restores the app state | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
## Reproduction method
For each file above, run the existing test with the same title pattern in each
checkout. Pass the title pattern as an argument, without interpolating it as
shell code. Native/import failures occur before the title filter can execute.
```sh
node --test --import tsx --test-reporter=tap --test-name-pattern "<escaped original title>" <test file>
```
The comparison used bounded 90-second subprocess deadlines. None reached that
deadline. The persistence repeat used the same existing test and a 60-second
deadline. It added no load generator or attack payload.
All 15 test files and the business modules responsible for these signatures
are unchanged by batch 1. This classification does not resolve the two
independently reported grant-management review findings; those receive
separate source fixes and tests.
+2 -3
View File
@@ -27,7 +27,7 @@ mean complete. Existing APIs remain available.
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, source-labelled selector, window-local preview, revision-safe apply/cancel/reset, static terminal linkage | Unreadable colors and misleading security UI. Nineteen UiThemeTokens fields are not independent: primary/accent/ring and foregrounds derive from the effective accent | User explicitly selects a theme. Separate static contribution enablement from code permissions; no generic appearance.write or runtime activation | Immutable version/hash, bounded JSON, preview owner/token/revision, disable/update/uninstall fallback, no implicit sync, fixed host security appearance | [ThemePack design](./theme-packs-design.md) accepted for implementation after current A work; unfinished until contract, security boundary, lifecycle, contrast, samples and native gates pass. No arbitrary DOM/CSS, remote assets, free layout or security UI customization |
| 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 |
@@ -41,8 +41,7 @@ mean complete. Existing APIs remain available.
2. Batch 1b: host action context, native interactions, selected context and terminal
sharing/drafts. A plugin-supplied gesture is never authority.
3. Batch 2: SFTP selected-file handles and explicit transfer destinations.
4. Deliver the separately reviewed [declarative ThemePack stage](./theme-packs-design.md), including protected security surfaces, contrast gates, revision-safe preview/apply/reset, and safe fallback.
5. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
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
@@ -229,9 +229,11 @@ after durable user data can exist.
## Inspect and revoke actual grants
The installed package detail separates declared permissions from **Active
The installed package detail separates declared permissions from **Saved
grants**. The host-only panel lists resource, scope, session (when applicable),
and grant time. One-use decisions do not appear as retained grants. Refresh
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
@@ -240,8 +242,9 @@ boundary.
**Disable and revoke all grants** runs through the manager mutation queue. It
persists disabled state before revoking grants, invalidates contributions, and
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
leases, providers, and companions. A containment failure is reported as a
failure and leaves the plugin disabled. Explicit enable requests fresh access.
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.
-215
View File
@@ -1,215 +0,0 @@
# Declarative theme packs
Status: proposed implementation plan. No theme contribution or theme permission
is implemented by this document. The baseline is Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`; batch 1 grants and network contracts
are reviewed separately in Draft #3955.
## Scope and existing behavior
A theme pack supplies validated color data. Users preview, apply, cancel, or
reset it through the host Appearance UI. Selecting a theme never starts a
plugin runtime or approves its function permissions. This extends whole-app
appearance alongside existing `terminal.theme` and `terminal.background`
Providers and isolated view styles.
Personal `customCSS` is a user-owned advanced setting, not a third-party plugin
surface. This plan introduces no stylesheet, selector, DOM, font URL, image URL,
script, layout, notification, or background permission.
`UiThemeTokens` has 19 fields. They are not independent controls:
`buildAppThemeCssVars()` assigns one effective accent to primary, accent, and
ring and computes both accent foregrounds. The initial theme contract preserves
this relationship. It must not accept independent primary, primaryForeground,
accentForeground, or ring overrides and then silently discard them.
## Contract and package admission
The proposed feature is `appearance.theme-packs`. Its schema definitions,
generated SDK exports, runtime/package compatibility, and documentation move
together. Plan an internal schema/API revision with legacy negotiation retained;
do not change the current batch 1 API just to publish this proposal. Old hosts
reject a pack that requires the unknown feature before registration.
A proposed `contributes.themes` entry contains:
| Field | Meaning and validation |
| --- | --- |
| `id` | Contribution ID under the owning plugin namespace |
| `label` | Existing bounded localized-text contract |
| `variant` | `light`, `dark`, `hc-light`, or `hc-dark` |
| `path` | Canonical package-relative JSON path inside the immutable installed version |
A theme JSON document contains a schema revision, a variant-matching built-in
base ID, bounded color overrides, and an optional static terminal palette.
It contains no cross-plugin includes, arbitrary file references, inheritance
chains, or remote resources. Resolve the validated base and overrides into one
immutable complete palette before registration. Never retain partially valid
entries from a failed theme document.
The initial allowed UI overrides are `background`, `foreground`, `card`,
`cardForeground`, `popover`, `popoverForeground`, `secondary`,
`secondaryForeground`, `muted`, `mutedForeground`, `accent`, `destructive`,
`destructiveForeground`, `border`, and `input`. Accept only opaque `#RRGGBB`
colors. The host converts them to its internal HSL representation. Reject
unknown keys, alpha, CSS expressions, `url()`, `var()`, `@import`, non-finite
values, and custom properties. Do not pass author strings to CSS parsing.
The optional terminal palette uses the existing `TerminalThemeColorName`
allowlist. It merges with a host default and is validated independently.
Static terminal colors require neither a session snapshot nor a Provider call.
Initial bounds for implementation and tests are 32 themes per package,
64 KiB per JSON file, 2 MiB total theme JSON, depth 8, and one active preview per
window. These are proposed defaults, not existing limits. The schema and host
must share the final constants. No font, image, or video assets enter v1.
### Static admission must remain distinct from code activation
The present manifest requires `main` and browser-only policy expects a browser
entrypoint. Removing either check without a separate package variant is unsafe.
For the first usable slice, a sample can retain a no-op browser entrypoint.
Static theme registration must not invoke it. Theme enablement has a separate
host-owned state from function/code enablement. Installing a pack keeps both
off until a user opts into the corresponding contribution. Enabling its themes
never sets the code-enabled flag, grants permissions, or runs activation events.
Disabling the package disables its theme contributions as well as its code.
A later exclusive data-only manifest variant can remove the no-op entrypoint.
It must reject `main`, activation events, permissions, companions, providers,
commands, and every non-theme contribution. Update canonical validation,
semantic admission, package inspection, catalog policy, manager behavior, and
compatibility tests together. The public catalog must explicitly recognize
this inert variant while retaining browser-only admission for executable packs.
A theme-only exception must not admit Node or an executable mixed package.
## Host registry and persistence
A host registry reads only installed, immutable package versions through the
existing package-store path boundary. Verify real paths, file kinds, byte caps,
and package ownership. Preserve the validated source version and content hash.
No plugin RPC can register, apply, or replace global palette data.
A committed selection references plugin ID, theme ID, variant, package version,
and validated theme hash. Store per-variant selections and a monotonic commit
revision through the settings/persistence layer. Do not persist plugin CSS or
turn this into implicit KV/cloud synchronization. Preserve legacy built-in theme
IDs and existing user settings when no plugin theme is selected.
Theme availability records and applied preferences are user state, separate
from runtime KV and function permissions. Define retained missing-selection
metadata and uninstall behavior explicitly. Missing code or theme files must
not trigger downloads, runtime activation, or new grants.
## Preview, apply, cancel, and reset
A single appearance resolver feeds existing chrome/terminal effects. Do not add
a second root effect that races `useAppThemeStyle`, `useThemeRuntime`, or active
terminal chrome selection.
| Host action | Required behavior |
| --- | --- |
| Preview | Create a host-owned token bound to window, preview owner, selected immutable theme, and current committed revision. Render locally without storage writes, sync events, runtime activation, or other-window changes |
| Apply | Verify owner, token, version/hash, availability, and expected commit revision. Commit the selected theme and explicitly chosen terminal linkage as one settings action. A stale preview produces a conflict, not an overwrite |
| Cancel or close | Clear only the matching local preview token. Render the latest committed state. Never restore a captured old settings snapshot over a newer commit |
| Reset | Clear the selected variant's plugin reference using the same revision check and apply the host default. Preserve font, accent, per-host terminal, and accessibility preferences |
| Another window applies | Advance the shared commit revision. End the old preview or mark it stale; later cancel cannot alter the new selection |
| Disable/uninstall/update | Invalidate matching previews and registry entries. Immediately resolve affected selections to a safe built-in variant, retain a bounded missing-reference notice, and never silently adopt unreviewed replacement bytes |
| Application exit or window destruction | Release local preview ownership. Only committed preferences survive startup |
The preview UI always identifies plugin source/version and has host-controlled
Apply, Cancel, and Restore default controls. Those controls live inside the
security appearance boundary. Theme data cannot control their labels, order,
visibility, click behavior, or layout.
Applying a theme changes only the selected palette and explicit terminal linkage.
It does not overwrite user font family/size, accent override, host terminal
override, reduced-motion choice, or OS accessibility preferences.
## Appearance precedence and terminal behavior
Resolve appearance in this order:
1. Host security surfaces and OS accessibility requirements.
2. Explicit user font, size, accent, and per-host/per-terminal overrides.
3. The selected and validated theme pack.
4. Built-in defaults.
Define one effective accent using the current host rule. Reject impossible
contrast combinations, including a user accent override against the effective
surface, or show an explicit host correction before apply. Do not report an
ignored author field as a successful override.
Follow App may use the pack's static terminal palette. Manual terminal choices
and per-host overrides retain precedence. Reuse the existing active-chrome
selection rather than resetting app appearance on tab changes. Test Vault,
SFTP, terminal, editor, Notes, logs, split panes, and detached windows.
High-contrast flags in plugin environments are not proof that the host already
has complete high-contrast themes. Missing HC variants require a host-owned HC
fallback. Do not downgrade a forced-colors/high-contrast preference to an
ordinary light/dark theme. Respect reduced motion and font scaling.
## Security appearance and contrast gates
Native Electron permission dialogs already sit outside renderer CSS. React
security surfaces need a new explicit boundary before a theme pack ships.
Cover AppLock, host-key trust, credential selection, grant/revoke management,
dangerous confirmations, and theme preview/apply controls. Use host-controlled
colors, font, icons, and layout there. Package colors never style those surfaces.
The existing code has no verified shared boundary covering all of them.
Color validation checks complete resolved/composited pairs, not only body text:
foreground/background, card text/card, popover text/popover, secondary/muted
text, accent/button text, destructive/button text, focus rings, borders, inputs,
and selected/disabled states. Ordinary text needs 4.5:1, large text 3:1, and
required non-text indicators 3:1 against adjacent surfaces. Reject a failing
palette with a named pair and ratio, or show an explicit host correction.
Tests must prove the effective UI, not only a helper's arithmetic.
These gates follow [WCAG text contrast](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
and [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html).
Theme screenshot review supplements contract, lifecycle, and accessibility
checks. It does not establish safe ownership, persistence, or revocation.
## Delivery stages and acceptance
Current batch A grants and SDK consistency reaches review/CI first. Complete
the controlled native interaction/context work as planned; theme work remains
a separate small stage from marketplace card/layout changes.
| Stage | Implementation and evidence |
| --- | --- |
| Contract and pure validation | Canonical schema/generated SDK, feature/revision negotiation, strict color parser, derived-accent semantics, static terminal palette, bounds, named contrast diagnostics, old manifest compatibility |
| Registry and security boundary | Immutable package ownership, static registration without runtime, independent theme/code enablement, fixed security appearance, same-variant/HC fallback, unavailable/tampered version behavior |
| Product workflow | Appearance grouping/source, local preview ownership, revision-safe apply/cancel/reset, safe defaults, system theme changes, terminal linkage and overrides, persistence and window synchronization |
| Samples and final acceptance | Light/dark no-op-browser sample, normal settings import without advanced mode, no runtime/grants on theme apply, later data-only admission only if fully validated, exact-SHA tests/build/package/CI, Codex review/fix/review, parent-owned native recording |
Boundary tests include unknown fields/feature, invalid or excessive JSON,
unsupported variant/base, non-package and changed paths, unreadable colors,
preview cancellation and window close, two-window apply/cancel ordering,
update/disable/uninstall/failure fallback, startup restoration, host and accent
overrides, forced colors, reduced motion, Chinese fonts/fallback, and every
protected security surface. A pending gate keeps the theme row unfinished.
Later controlled candidates are known-font recommendations, radius/density
presets after component support exists, declarative file/folder icons, and
package-local static backgrounds on fixed surfaces with contrast limits. They
need separate contracts and acceptance. Global CSS, free layout, remote fonts,
HTML/video backgrounds, and security-UI customization remain excluded.
## Baseline source references
- `infrastructure/config/uiThemes.ts`: token fields and built-in registry.
- `application/state/settingsStateDefaults.ts`: accent derivation and built-in ID checks.
- `application/state/appearanceSync.ts`, `appearanceChromeStore.ts`, and
`useSettingsState.ts`: persistence and cross-window behavior.
- `application/app/useAppThemeStyle.ts`, `activeChromeTheme.ts`,
`application/state/useThemeRuntime.ts`, and `domain/terminalAppearance.ts`:
effective UI and terminal precedence.
- `packages/plugin-sdk/src/index.ts`: existing terminal theme/background types.
- `electron/plugins/pluginProtocol.cjs`: isolated view CSS/font/network CSP.
- `lib/customCss.ts`: personal user CSS, excluded from plugin authority.
- `domain/pluginRuntimePolicy.cjs` and the canonical manifest schema: executable admission.
- `electron/plugins/nativePermissionDecision.cjs`: native permission prompts.
+1
View File
@@ -218,6 +218,7 @@ function createBridgeRegistrar(context) {
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
permissionEngine: pluginHostService?.permissionEngine,
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
+16
View File
@@ -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();
});
+10 -1
View File
@@ -672,7 +672,15 @@ function registerPluginBridge(ipcMain, options) {
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
const groups = options.permissionEngine.listGrants(pluginId);
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) => (
@@ -680,6 +688,7 @@ function registerPluginBridge(ipcMain, options) {
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) => ({
+31 -4
View File
@@ -1643,12 +1643,13 @@ test("permission management is trusted-host-only and projects bounded audit summ
registerPluginBridge(ipcMain, {
manager: { async initialize() {}, async revokePermissions(id) { revoked.push(id); return { enabled: false }; } },
isTrustedSender: (event) => event?.trusted === true,
permissionEngine: { listGrants(id) {
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, declarationHash: "private" }], application: [], session: [] };
return { always: [{ permission: "network", resource: "https://example.invalid", resourceKind: "exact", grantedAt: 12, matchesCurrentActivation: false, declarationHash: "private" }], application: [], session: [] };
} },
database: { listSecurityAudit(id, limit) {
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" } }];
@@ -1661,9 +1662,35 @@ test("permission management is trusted-host-only and projects bounded audit summ
}
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 }],
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");
});
+9 -4
View File
@@ -136,10 +136,15 @@ class PluginManager {
if (!this.database.getActivePlugin(pluginId)) throw new Error("Plugin is not installed");
// Disable before revoking. Lazy activation cannot re-create authority while
// runtime shutdown cancels pending RPC, streams, leases and provider work.
this.database.setEnabled(pluginId, false);
this.permissionEngine.revokeAll(pluginId);
this.contributionService.onPluginDisabled(pluginId);
await this.runtimeSupervisor.stop(pluginId);
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);
});
}
+27
View File
@@ -556,3 +556,30 @@ test("failed runtime containment leaves the revoked plugin disabled and reports
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"]);
}
});
+1 -1
View File
@@ -18,7 +18,7 @@ permissions, and enable it. The command palette contains **Synthetic credential
lease request**. Reject a permission prompt to verify that the request stops.
OS secret storage must be available to proceed past `secrets.set`.
The package detail's **Active grants** panel shows retained decisions. One-use
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.
Vendored
+1
View File
@@ -359,6 +359,7 @@ declare global {
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 }>;
}