mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3734d161 | ||
|
|
185eac3849 |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "До закрытия сеанса",
|
||||
|
||||
@@ -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": "直到会话关闭",
|
||||
|
||||
@@ -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(); }); }
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 }>;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user