Compare commits

..
Author SHA1 Message Date
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +00:00
陈奇 e325742abf fix(example-assistant): follow host theme and accent environment 2026-10-27 02:22:20 +00:00
陈奇 9ca70f3290 docs(plugin-ai): record cold-start evidence and native verification limits 2026-10-27 02:03:21 +00:00
陈奇 092438803b fix(plugin-ai): prepare host provider transport independently of the AI sidebar 2026-10-27 02:00:37 +00:00
陈奇 ba09461676 test(plugin-ai): verify repeated selected Note expiry recovery and update QA 2026-10-26 19:55:58 +00:00
陈奇 9e7c9df58e fix(example-assistant): explicitly recover expired selected Note references 2026-10-26 19:53:18 +00:00
陈奇 07eb68c5e7 Merge current source and stream base into plugin AI corrections 2026-10-26 19:34:20 +00:00
陈奇 d81896b0d3 docs(plugin-ai): record bounded review repairs and replacement sample hash 2026-10-26 19:25:57 +00:00
陈奇 e1cb95ee87 fix(example-assistant): recover expired model sessions without inference retries 2026-10-26 19:23:22 +00:00
陈奇 ee5a4a4067 fix(plugin-ai): preserve source budgets across runtime replacement 2026-10-26 19:23:22 +00:00
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
陈大猫 c4433d4961 feat(plugins): integrate source-bound byte streams and additive AI contract 2026-10-26 23:04:54 +08:00
陈奇 264f1d983c feat(plugin-ai): define registered service and assistant runtime adapters 2026-10-26 14:47:58 +00:00
陈奇 35e3f9f99f Merge branch 'feat/plugin-ai-context-core' into feat/plugin-ai-context 2026-10-26 14:47:58 +00:00
陈奇 4882e0501c fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:37 +00:00
陈奇 c3a5f810ce fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:30 +00:00
陈大猫 dd41f9d8d0 feat(plugins): prepare bounded byte transport and database fixture 2026-10-26 22:28:28 +08:00
陈奇 6392ac9532 feat(plugin-ai): reuse host model transport and add assistant panel fixture 2026-10-26 14:25:34 +00:00
陈奇 afe831bfc5 feat(plugin-ai): add source-bound model and selected context brokers 2026-10-26 14:25:18 +00:00
133 changed files with 8863 additions and 2688 deletions

No files matched your search

+2
View File
@@ -26,6 +26,7 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -856,6 +857,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
-26
View File
@@ -1,32 +1,6 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
-26
View File
@@ -1,32 +1,6 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
-26
View File
@@ -1,32 +1,6 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common
'common.save': '保存',
'common.cancel': '取消',
-26
View File
@@ -1,32 +1,6 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common
'common.save': '儲存',
'common.cancel': '取消',
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const activeTabId = useActiveTabId();
const latest = useRef({ activeTabId, sessions, workspaces });
latest.current = { activeTabId, sessions, workspaces };
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
const [noteId, setNoteId] = useState('');
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
const bridge = {
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
respondPluginAIHost: api.respondPluginAIHost.bind(api),
};
let selectedNoteId: string | null = null;
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
requests.get(String(request.payload.requestId))?.controller.abort();
return;
}
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
return { choice, noteId, setNoteId, noteChoices };
}
-49
View File
@@ -1,49 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+1 -2
View File
@@ -10,8 +10,7 @@ export function usePluginManager() {
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const refresh = useCallback(async () => {
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
-52
View File
@@ -1,52 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
/** Manual catalog checks only; no scheduler, install or permission mutation. */
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const generation = useRef(0);
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
plugin.source?.kind, plugin.source?.origin,
])), [plugins]);
useEffect(() => {
generation.current++;
setResult(null);
setError(null);
const requests = generation;
return () => { requests.current++; };
}, [identityKey]);
useEffect(() => {
const invalidate = () => {
generation.current++;
setResult(null);
setError(null);
};
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
}, []);
const check = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = generation.current;
setBusy(true); setError(null); setResult(null);
try {
const value = await checkPluginUpdates(netcattyBridge.require());
if (mounted.current && current === generation.current) setResult(value);
} catch (cause) {
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pending.current = false;
// This component may still be mounted with a refreshed package list.
if (mounted.current) setBusy(false);
}
}, []);
return { result, error, busy, check };
}
@@ -26,6 +26,7 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
+223
View File
@@ -0,0 +1,223 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
// evidence. All state is synthetic, with no real provider, key, Note or host.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('localStorage', dom.window.localStorage);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs');
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
let service: ReturnType<typeof createPluginHostService> | undefined;
let panel: JSDOM | undefined;
const root = createRoot(document.getElementById('root')!);
let locked = false; let contentAllowed = false; let reviewAllowed = false;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
const sdkErrorNames: string[] = [];
let diagnosticPhase = 'setup';
const originalConsoleError = console.error;
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
// asynchronously through console.error. Let module warnings reach normal
// stderr before observing SDK/renderer errors; do not suppress that warning.
await new Promise<void>((resolve) => setImmediate(resolve));
// The expected pre-fix SDK error contains request details. Keep diagnostics
// categorical even on the failing-before path; never print payloads/headers.
console.error = (value: unknown) => {
let category = 'unknown';
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
else if (typeof value === 'string') category = 'other-string';
else if (value && typeof value === 'object') {
const name = (value as { name?: unknown }).name;
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
}
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
};
t.after(() => { console.error = originalConsoleError; });
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
const endpoint = `http://127.0.0.1:${address.port}/v1`;
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const providerContext = {
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
// Only credential injection is synthetic. Admission, sync and the HTTP
// transport are the production implementations, not a direct-fetch seam.
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
},
shouldSkipTLSVerify: () => false,
streamRequest: aiBridge._streamRequestForTests,
};
registerProviderHandlers(providerContext);
assert.equal(providerContext.providerConfigs.length, 0);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
if (channel === 'netcatty:ai:chat:stream') {
const status = result as { ok: boolean; statusCode?: number; error?: string };
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
}
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
Object.defineProperty(dom.window, 'netcatty', { value: api });
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: reviewAllowed ? 0 : 1 }; } } },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => request.permission === 'context.selected.read' && !contentAllowed
? { requestId: request.requestId, decision: 'deny' }
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5000;
while (!condition()) {
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent === 'Ready');
assert.match(element('model').textContent!, /QA fixture/);
const reference = element('context') as HTMLSelectElement;
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
diagnosticPhase = 'deny-content';
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
diagnosticPhase = 'cancel-review';
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
reviewAllowed = true;
diagnosticPhase = 'complete-stream';
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
diagnosticPhase = 'stop-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
element('stop').dispatchEvent(new panel.window.Event('click'));
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
diagnosticPhase = 'lock-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
for (const listener of lockListeners) listener({ locked: true });
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
assert.deepEqual(sdkErrorNames, []);
} finally {
console.error = originalConsoleError;
panel?.window.close();
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,22 @@
import React from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { usePluginAIContextHost } from '../../application/state/usePluginAIContextHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const { choice, noteId, setNoteId, noteChoices } = usePluginAIContextHost({ sessions, workspaces });
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="">
<DialogHeader><DialogTitle>Choose a reference for the plugin</DialogTitle>
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Reading content requires a separate permission and text review.</DialogDescription></DialogHeader>
<label className="text-sm" htmlFor="plugin-ai-note">Note</label>
<select id="plugin-ai-note" value={noteId} onChange={(event) => setNoteId(event.target.value)} className="rounded border border-border bg-background p-2 text-foreground">
<option value="">No Note</option>
{noteChoices.map((note) => <option key={note.id} value={note.id}>{note.title}</option>)}
</select>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(noteId)}>Share selected summaries</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
+2 -5
View File
@@ -201,9 +201,8 @@ export const SettingCard: React.FC<SettingCardProps> = ({
);
interface SettingRowProps {
leading?: React.ReactNode;
label?: React.ReactNode;
description?: React.ReactNode;
label?: string;
description?: string;
children: React.ReactNode;
align?: "center" | "start";
/** Stable catalog id for settings search jump targets. */
@@ -211,7 +210,6 @@ interface SettingRowProps {
}
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
description,
children,
@@ -227,7 +225,6 @@ export const SettingRow: React.FC<SettingRowProps> = ({
align === "start" ? "items-start" : "items-center",
)}
>
{leading && <div className="shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
@@ -1,78 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginLogSettings } = await import('./PluginLogSettings');
const root = createRoot(document.getElementById('root')!);
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
const actions: string[] = [];
let failConfigure = false;
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return status; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
async openPluginLogFolder() { actions.push('open'); return status; },
async exportPluginLogs() { actions.push('export'); return status; },
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
} });
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const switches = () => [...document.querySelectorAll<HTMLButtonElement>('[role="switch"]')];
try {
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
const configure = button('Configure');
configure.focus();
await act(async () => configure.click());
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
assert.equal(switches()[0].getAttribute('aria-checked'), 'false');
assert.equal(switches()[0].disabled, false, 'StrictMode initialization completes');
assert.equal(switches()[1].disabled, true);
assert.match(document.body.textContent!, /no upload or sync/);
assert.match(document.body.textContent!, /Dropped: 2/);
await act(async () => switches()[0].click());
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
await act(async () => switches()[1].click());
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
await act(async () => button('Cancel').click());
assert.equal(settings.length, 1, 'cancelling risk consent leaves verbose off');
await act(async () => switches()[1].click());
await act(async () => button('Enable temporarily').click());
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
await act(async () => button('Open folder').click());
await act(async () => button('Export').click());
await act(async () => button('Clear logs…').click());
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
failConfigure = true;
await act(async () => switches()[0].click());
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
assert.equal(switches()[0].getAttribute('aria-checked'), 'false', 'failed configuration refreshes actual OFF host state');
assert.equal(switches()[1].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -1,58 +0,0 @@
import React, { useState } from 'react';
import { usePluginLogs } from '../../../application/state/usePluginLogs';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { ConfirmDialog } from '../../ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
import { SettingRow, Toggle } from '../settings-ui';
/** Host settings row; detailed controls stay inside the standard host dialog. */
export function PluginLogSettings() {
const { t, resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { status, error, busy, run } = usePluginLogs();
const [open, setOpen] = useState(false);
const [confirmVerbose, setConfirmVerbose] = useState(false);
const title = zh ? '本地插件日志' : 'Local plugin logs';
const saveLabel = zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally';
const verboseLabel = zh ? '包含插件详细诊断文字' : 'Include verbose plugin text';
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div data-host-security-surface="">
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground" role="status">{status ? (status.saveToDisk ? t('common.enabled') : t('common.disabled')) : error ? (zh ? '不可用' : 'Unavailable') : (zh ? '载入中' : 'Loading')}</span>
<DialogTrigger asChild><Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm">{zh ? '配置' : 'Configure'}</Button></DialogTrigger>
</div>
</SettingRow>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="divide-y divide-border">
<SettingRow label={saveLabel} description={zh ? '保存宿主确认的操作和授权结果,不保存敏感正文。' : 'Save host-observed operations and authorization outcomes without sensitive bodies.'}>
<Toggle ariaLabel={saveLabel} checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(saveToDisk) => void run('configure', { saveToDisk, verbose: false })} />
</SettingRow>
<SettingRow label={verboseLabel} description={zh ? '插件文字可能包含敏感数据;只为排查问题临时开启。' : 'Plugin text may contain sensitive data. Enable temporarily for troubleshooting.'}>
<Toggle ariaLabel={verboseLabel} checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(verbose) => verbose ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />
</SettingRow>
</div>
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
</div>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -17,7 +17,7 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useEffect, useRef, useState } = await import('react');
const { default: React, act, useEffect } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
@@ -69,28 +69,6 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
const opener = useRef<HTMLElement | null>(null);
return <>
<button ref={browse}>Fallback browse</button>
<button onClick={(event) => { opener.current = event.currentTarget; setOpen(true); }}>External plugin settings</button>
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={browse}><input aria-label="Controlled search" /></PluginManagementDialog>
</>;
}
await act(async () => root.render(<I18nProvider locale="en"><ControlledEntry /></I18nProvider>));
const external = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === 'External plugin settings')!;
external.focus();
await click(external);
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement, external, 'controlled entry restores the actual row opener');
await click(external);
external.disabled = true;
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement?.textContent, 'Fallback browse', 'a disabled or removed opener uses the stable browse action');
} finally {
await act(async () => root.unmount());
dom.window.close();
@@ -4,25 +4,18 @@ import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children, trigger, open: controlledOpen, onOpenChange, restoreFocusTo, fallbackFocusTo }: {
trigger?: React.ReactNode | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
export function PluginManagementDialog({ children }: {
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const [open, setOpen] = useState(false);
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
@@ -31,13 +24,6 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -49,9 +49,9 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
@@ -1,149 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
async function fixture() {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
const listeners = new Set<() => void>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [installed]; },
onPluginContributionsChanged(callback: () => void) { listeners.add(callback); return () => listeners.delete(callback); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const perform = async (action: () => void) => { await act(async () => { action(); await settle(); }); };
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
await f.close();
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -1,79 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Uses the real settings primitives and verifies that the host manager owns state.
test('landing rows expose real sources/status/settings and quick toggles preserve host decisions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const root = createRoot(document.getElementById('root')!);
const plugins = [{
id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0',
source: { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, description: 'Synthetic description', contributes: { settings: [{ id: 'com.example.alpha.value', label: 'Value', control: 'text', scope: 'application' }] } },
runtime: { status: 'running', kind: 'browser', lastError: null, quarantinedAt: null },
}, {
id: 'com.example.beta', enabled: false, activeVersion: '2.0.0',
source: { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.beta', name: 'Beta', version: '2.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
}] as NetcattyInstalledPlugin[];
const changes: Array<[string, boolean]> = [];
const opened: Array<string | null> = [];
let refreshes = 0;
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null as string | null, credentialStorageAvailable: true,
async refresh() { refreshes++; }, async setEnabled(id: string, enabled: boolean) { changes.push([id, enabled]); }, async importPackage() {}, async uninstall() {} };
Object.assign(dom.window, { netcatty: { async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; } } });
const render = async (value = manager, settingsAvailable = new Set(['com.example.alpha'])) => act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={value} settingsAvailable={settingsAvailable} onManage={(id) => opened.push(id)} /></I18nProvider>));
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const byLabel = (label: string) => document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
try {
await render();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
assert.deepEqual(opened, [null, 'com.example.alpha']);
await act(async () => byLabel('Disable Alpha').click());
assert.deepEqual(changes, [['com.example.alpha', false]]);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true', 'no optimistic flip before host approval');
await render({ ...manager, error: 'Synthetic permission denied' });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic permission denied/);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true');
await act(async () => byLabel('Refresh installed plugins').click());
assert.equal(refreshes, 1);
await render({ ...manager, statusKnown: false });
assert.equal(byLabel('Disable Alpha').disabled, true);
await act(async () => byLabel('Disable Alpha').click());
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
assert.match(document.body.textContent!, /Loading/);
await render({ ...manager, plugins: [], available: false });
assert.match(document.body.textContent!, /unavailable in this session/);
await render({ ...manager, plugins: [{ ...plugins[0], source: undefined }] });
assert.match(document.body.textContent!, /Source unknown/);
assert.doesNotMatch(document.body.textContent!, /Source: Local/);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -1,89 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
type Manager = ReturnType<typeof usePluginManager>;
export function PluginSettingsLanding({ manager, settingsAvailable, settingsError, managerButtonRef, updates, onManage }: {
manager: Manager;
settingsAvailable: ReadonlySet<string>;
settingsError?: string;
managerButtonRef?: React.Ref<HTMLButtonElement>;
updates?: ReturnType<typeof usePluginUpdateCheck>;
onManage: (pluginId: string | null, opener: HTMLElement) => void;
}) {
const { t, resolvedLocale } = useI18n();
const [search, setSearch] = useState('');
const localized = useCallback((value: string | Record<string, string> | undefined) => typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '', [resolvedLocale]);
const nameOf = (plugin: NetcattyInstalledPlugin) => localized(plugin.manifest?.displayName) || plugin.manifest?.name || plugin.id;
const visible = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
return manager.plugins.filter((plugin) => [localized(plugin.manifest?.displayName), plugin.manifest?.name, plugin.id, plugin.manifest?.publisher, localized(plugin.manifest?.description), plugin.source?.name].some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}, [manager.plugins, search, localized]);
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
<SettingCard divided>
<SettingRow label={t('settings.plugins.landing.manager')} description={t('settings.plugins.landing.managerDescription')}>
<Button ref={managerButtonRef} className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={(event) => onManage(null, event.currentTarget)}>{t('settings.plugins.landing.browse')}</Button>
</SettingRow>
<PluginLogSettings />
</SettingCard>
</SettingsSection>
<SettingsSection title={`${t('settings.plugins.manager.installed')} · ${manager.plugins.length}`} actions={<>
{updates && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={updates.busy || manager.busy || !manager.available || !manager.statusKnown || manager.plugins.length === 0} onClick={() => void updates.check()}>{t(updates.busy ? 'settings.plugins.landing.checkingUpdates' : 'settings.plugins.landing.checkUpdates')}</Button>}
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy} aria-label={t('settings.plugins.landing.refresh')} onClick={() => void manager.refresh({ clearError: true }).catch(() => {})}><RefreshCw size={16} /></Button>
</>}>
{updates?.error && <p role="alert" className="text-sm text-destructive">{updates.error}</p>}
{updates?.result && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.landing.updateSummary').replace('{checked}', String(updates.result.checked)).replace('{updates}', String(updates.result.updates.length)).replace('{skipped}', String(updates.result.skipped))}{updates.result.failedSources > 0 && ` ${t('settings.plugins.landing.updateSourcesFailed').replace('{count}', String(updates.result.failedSources))}`} {t('settings.plugins.landing.updateReview')}</p>}
{settingsError && <p role="alert" className="text-sm text-destructive">{settingsError}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.statusKnown && !manager.error && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.loading')}</p>}
{manager.statusKnown && !manager.available && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.unavailable')}</p>}
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
{manager.plugins.length > 0 && <>
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
})}
</SettingCard>}
</>}
</SettingsSection>
</>;
}
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('manual update action coalesces clicks, never runs on mount, and reports incomplete source checks honestly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { usePluginUpdateCheck } = await import('../../../application/state/usePluginUpdateCheck');
const root = createRoot(document.getElementById('root')!);
const source: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
const plugin = { id: 'com.example.alpha', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64), source,
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } } as NetcattyInstalledPlugin;
const plugins = [plugin];
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true, async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let catalogReads = 0; let mutations = 0; let offline = false;
let release: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
Object.assign(dom.window, { netcatty: {
async listPlugins() { return plugins; }, async listPluginSources() { return [source]; },
async listPluginCatalog(id: string) { assert.equal(id, source.id); catalogReads++; await gate; return { entries: [{ id: plugin.id, version: '1.1.0', artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }], source: offline ? 'cached' : 'registry', error: offline ? 'synthetic offline' : null }; },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
} });
function Page() { const updates = usePluginUpdateCheck(plugins); return <PluginSettingsLanding manager={manager} updates={updates} settingsAvailable={new Set()} onManage={() => {}} />; }
const check = () => [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Check for updates')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><Page /></I18nProvider>));
assert.equal(catalogReads, 0, 'rendering settings does not schedule checks');
await act(async () => { const button = check(); button.click(); button.click(); });
assert.equal(catalogReads, 1);
assert.equal([...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Checking…')!.disabled, true);
await act(async () => release());
assert.match(document.body.textContent!, /Published updates: 1/);
assert.match(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
await act(async () => window.dispatchEvent(new Event('netcatty:plugin-packages-changed')));
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/, 'source/package changes invalidate prior results');
offline = true;
await act(async () => check().click());
assert.match(document.body.textContent!, /Checked: 0/);
assert.match(document.body.textContent!, /Not checked: 1/);
assert.match(document.body.textContent!, /Unavailable sources: 1/);
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -1,74 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('landing settings entry targets the declared plugin and a manager tab roundtrip clears selection consistently', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const root = createRoot(document.getElementById('root')!);
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [plugin]; },
async setPluginEnabled() { throw new Error('Synthetic management failure'); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions() { return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', description: 'Synthetic plugin', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] }; },
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const selectTab = async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
assert.ok(tab, name);
await act(async () => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await settle(); });
};
try {
await act(async () => { root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>); await settle(); });
const settings = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
assert.equal(toggle.getAttribute('aria-checked'), 'true', 'failed mutation retains actual host state');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh installed plugins"]')!;
await act(async () => { refresh.click(); await settle(); });
assert.equal(document.querySelector('[role=alert]'), null, 'explicit successful refresh clears an acknowledged manager error');
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+14 -57
View File
@@ -1,13 +1,11 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
@@ -287,15 +285,11 @@ export function PluginSettingField({
);
}
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
@@ -308,31 +302,6 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
@@ -342,7 +311,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -358,7 +327,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -383,7 +352,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
@@ -425,27 +394,15 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
export default function SettingsPluginsTab() {
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
const { t } = useI18n();
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
</PluginManagementDialog>
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
+309
View File
@@ -0,0 +1,309 @@
# Plugin AI and selected context: implementation checkpoint
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: source-bound services, default trusted main/renderer adapters, app preload,
the AppView host chooser, canonical private IPC types, and an installable minimal
example are connected. Complete independent review and native acceptance remain
pending. This source is not ready to merge or release.
## Public boundaries
The parent approved `ai.model-invocation` and `context.selected-read`. The
transport task owns the public schema, SDK, and runtime peer. Its
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
routes and is merged into this branch. The parent assigned this task the additive
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
The base later advanced to `f1410f8bb`; its source/stream changes are incorporated
by ordinary merge. Three overlaps retain this branch's permission explanations,
bounded-stream assertions, and six-file assistant staging. The source database
and sidecar changes come from their owning task, without local modifications.
The model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn through the registered SDK
and runtime routes.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
expected version. Summary enumeration never calls the content reader. Supported
selected scopes are workspace, tab, session, host, and Note. There is no inventory
or arbitrary entity-ID lookup.
## File ownership
| Owner | Files or responsibility |
| --- | --- |
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
| Transport task | Generic TCP and TLS streams |
| Logging task | Optional local plugin logs |
The existing `feat/plugin-terminal-sharing` commit
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
`terminal-sharing-wip.md` describes incomplete selection publication and review.
This branch does not claim that workflow is complete.
## Implemented authority
`PluginAISourceBinding` authenticates the current browser runtime before reading
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
database and sync implementation are unchanged. Source origin does not imply
that a package is officially maintained.
`ModelInvocationBroker` requires host-provided `resolveDataOwner`,
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
budget owner from the authenticated source key and opaque private owner. It never
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
runtime binding objects still govern handle access; their replacement does not
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
Each handle is bound to a data owner, a runtime ID, and a plugin version.
Every read rechecks runtime authority, the current model, handle lifetime, and
selected context versions. Revocation aborts pending turns. Late provider results
are discarded. Another source with the same plugin ID cannot read, cancel, or
close the original source's handles.
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
call it directly. Its descriptor accepts only a scope, a title, a version, a text
reader, and a current-selection guard. Unknown Host fields are rejected. Content
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
reader must project safe fields explicitly and exclude passwords, private keys,
TOTP values, identity credentials, startup commands, and environment variables.
Free-form Notes can contain secrets that field projection cannot detect. The
trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
total deadline. Host-owned request tags allow sender/runtime cancellation
without cancelling the internal Agent's ordinary streams. SDK retries are
disabled. Only text and allowlisted token totals reach the broker. Provider
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
raw host IPC, or global renderer store is exposed to plugins. A model tool request
ends the turn without executing an action. Notes edits and terminal input are
outside this checkpoint. No command is written to a terminal or followed by Enter.
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
input and output bytes, output token caps, event count, requests per minute, and
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
bytes for framing and the output token cap. This is a conservative estimate, not
a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Connected host path
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
the existing interaction window, app-lock subscription, and native dialog. It
registers the trusted ready/response IPC handlers. App preload exposes the request
subscription and validated responses only to the trusted application renderer;
the sandbox view preload does not expose these methods.
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
provider adapter and selected readers in renderer-local maps; only canonical,
validated request/results cross the main/renderer IPC boundary. The default
adapter authenticates the current main-window sender, uses one-use nonces,
rechecks the current owner after awaits, and invalidates on lock, navigation,
hide, destruction, or renderer replacement. Plugin permission revocation,
disable, and runtime release abort that runtime's turns and transport requests.
Summary enumeration, content reads, and model invocation require separate
permissions. Before selected text becomes model input, a native host dialog
shows that exact text and version with Cancel as the default. Model invocation
permission grants neither context nor action access. Menu IDs are never data
authority. While the app-owned chooser is present, the active native modal/tab
plugin view yields so it cannot cover the chooser. Existing embedded views use
their host-overlay occlusion observer. Native stacking still requires desktop QA.
The selected-context authority is read-only text. Its constructor and lifecycle
API are documented in [Selected context authority](selected-context-authority.md).
Host metadata handles cannot be promoted into edit/connect authority; those
actions require separate typed host targets, grants, and confirmations.
## QA evidence
The domain suites and transport suite use synthetic data only. The transport
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
checks this path:
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
-> local fixture -> text and usage.
This is not real model inference or paid API testing. It uses no paid keys, real
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
the existing model factory replaces it with its IPC placeholder. The test does
not exercise native credential storage or production credential injection.
The tests cover source, runtime, and version denial; separate summaries and
content reads; context invalidation; provider cancellation and late results;
deadlines; request, token, event, input, output, and stream-byte limits; and
allowlisted response serialization. Raw provider diagnostics remain inside the
host.
Commands:
```sh
node --test electron/plugins/modelInvocation.test.cjs
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The installed assistant command test passes on this branch's ordinary merge with
transport head `c4433d496`. It installs and
activates the six-file sample through the actual package manager and runtime peer,
then checks canonical SDK/RPC, independent model/content denial, text-review
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
The in-process browser harness is a Node test transport, not native sandbox proof.
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
app preload factory, mounted host chooser, installed sample command/runtime peer,
existing provider factory, and existing main HTTP transport to the loopback
fixture. It checks content denial, review cancellation, streaming usage, Stop,
lock revocation, and absence of keys/configuration/policy on the trusted wire.
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
and teardown without restoring an old view. DOM tests do not establish native
WebContentsView stacking or Chromium sandbox behavior.
The trusted main/renderer suites cover nonce replay, sender substitution, owner
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
safe field projection, and late selected-content results. The main transport test
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
stream running and aborts an oversized plugin stream.
Failed aggregate runs remain evidence, not passing acceptance records:
| Log in `/tmp` | Result |
| --- | --- |
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
The ten Cursor failures in the second full run came from its missing SQLite
native dependency. Rebuilding the existing dependency with the standard
`node-gyp --devdir` option repaired them; the focused rerun passed 42 with 3
skipped. The sole connected full-run failure was a test expectation missing
`network.stream` after `network`. The runtime failure was permission-copy coverage
missing descriptions for `ai.invoke`, `context.selected.list`,
`context.selected.read`, and `network.stream`. The parent reassigned these two
hunks here. Commit `0ba3ea606988e95df256d449395adb7190b46fa0` adds accurate English
and Chinese descriptions and updates the resource-scope test. The test checks
both bounded optional stream declarations and rejection of the unbounded stream
shorthand. Transport can consume this isolated two-file commit.
The permission-copy test, complete 88-test plugin contract/SDK/CLI suite, and
610-test runtime aggregate now pass (with 2 existing runtime skips). The earlier
whole-repository red totals remain historical records; no passing whole-repository
rerun is claimed from these focused repairs. Exact-head CI is recorded on the PR.
The final connected UI/visibility run passes all 3 tests. The 47 focused
service/preload/runtime checks and 7 connected provider/renderer checks also pass.
`npm run check:plugin-contract` passes. The six-file package validates and retains
the SHA-256 recorded in the native recipe.
The connected AppView production build passes. Full lint after the permission
repair reports no errors and
seven existing warnings. The repository typecheck remains red with 773 diagnostics;
the new adapter, component, hook, and visibility modules have no diagnostics. Native packaging's
standard Electron rebuild stops at its hardcoded `/home/agent/.electron-gyp`
cache outside this executor's writable roots. No dependency patch or home-directory
override was used to bypass it.
The package build validates six files. The final artifact and native procedure
are recorded in [the native QA recipe](assistant-native-qa.md). The source staging
excludes build tooling and has no Node entry, network, filesystem, secret, storage,
or terminal permission.
Independent review confirmed the malformed-reference revocation defect on
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
The repair is retained in #3985 head `4882e0501`. Further review was initially
blocked. After receiving newer exact maintainer security-task evidence, the same
worker completed its bounded read-only review of `173ef5abb` without a repeated
restriction or alternate route/model. It confirmed the earlier repair and found
two additional P2 defects: runtime-bound budget keys and the sample's expired
chat recovery.
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
identity. Its fake-clock regression uses the actual source-binding class,
replaces runtime binding objects, tests request and reserved-token budgets,
isolates another source with the same public plugin ID, and checks the minute
boundary. Both budget subtests fail on the reviewed source and pass after repair.
Missing or invalid host budget identities fail closed.
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
**Refresh model** for changed-model or failed sessions. Refresh preserves the
draft and does not send inference. A failed send/read is never automatically
retried. Stop or page teardown invalidates pending opens and closes late sessions.
Four controlled-time DOM regressions fail before the repair and pass afterward,
including an uncertain inference outcome and Stop during reopening. They use no
network or paid provider. Failed-before logs are
`/tmp/netcatty-ai-budget-review-before.log` and
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
`-after.log` names. Correction review independently confirmed the stable budget
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
The corrected broker suite passes all 17 tests, the sample's controlled-time
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
lint still reports zero errors and seven existing warnings; codegen drift and
the production build pass. Two independent package builds reproduce SHA-256
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
for that earlier sample checkpoint.
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
summary expiry locally, blocks an expired selection before Send, and never sends
that local metadata as an authorization field. Refresh model never re-shares a
Note. Re-selection opens the existing host chooser and resets the dropdown to
No reference; the user selects the fresh handle and clicks Send explicitly.
The service's identical-selection cache now reuses only complete live summaries.
After an explicit host re-selection, expired/revoked handles remain invalid and
fresh eligible handles are issued. No authority lifetime or revocation rule is
changed. Summary selection does not read text. Every new content handle still
requires the existing per-context grant and exact-text review.
The new fake-clock panel regression uses the actual host service and selected
context authority with a real synthetic Note handle. It covers expiry, model
refresh without re-sharing, explicit re-selection, denial of the fresh content
grant, cancellation of text review, and one successful explicit send with the
draft preserved. It repeats the expiry/re-selection with both chat and Note
lifetimes expired, then succeeds without a manual model refresh. A separate
service regression reproduces the formerly empty
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
suite now passes all 10 tests; the connected installed UI/provider suite passes
all 4. The replacement six-file artifact has SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Independent verification of the latest correction and native acceptance remain
pending. CI is not a substitute for either.
Native Electron launch on this executor aborts because `chrome-sandbox` is not
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
screenshots, video, and installed UI acceptance belong to the parent's working
cloud GUI machine on the integrated exact head. No native acceptance is claimed here.
+108
View File
@@ -0,0 +1,108 @@
# Plugin model transport cold start
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
dependency on the internal AI sidebar. It does not establish the cause or outcome
of the original native failure; that still requires an authorized native retest.
## Reproduced boundary
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
selector reads the saved host provider, while main's provider registry starts
empty. The existing sidebar synchronizes that registry when visible or sending.
The plugin startup path did not. A nondefault configured loopback port can
therefore fail main's provider allowlist before HTTP transport starts.
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
mounts the trusted host component, uses the real application preload, and routes
model requests through `registerProviderHandlers` and its actual allowlist. It
does not mount the internal sidebar or manually alter the allowlist. Credential
injection uses synthetic fixture markers; the HTTP edge and deterministic local
fixture use the existing transport. No paid service, real key, Note, or host is
used. This remains DOM/in-process evidence, not native sandbox acceptance.
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
```text
ok: false
allowlistRejected: true
statusCode: undefined
```
The corrected regression passes and preserves both the selected fixture provider
and a second host-configured synthetic provider. It checks that provider markers
and configuration do not enter plugin-facing model/context envelopes. Expected
SDK error output is suppressed during this diagnostic so request payloads and
headers are not logged.
## Correction
Before publishing a selected model handle, the trusted renderer synchronizes the
current host-owned provider settings through the existing `aiSyncProviders`
application API. It checks that the selected model revision matches the snapshot
being synchronized. The runtime rechecks activity, cancellation, model revision,
and pending-selection ownership after the await. Pending selections count toward
the model-handle cap; release and shutdown invalidate late results.
Plugin requests supply no URL, credential, or provider configuration. The existing
main sender validation, endpoint allowlist, source binding, and model invocation
budgets are unchanged. No new schema feature, plugin permission, or consent
dialog is added. The assistant package is unchanged and retains SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
overlap host-selection work; preserve both when integrating the separate policy
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
correction.
## Native and policy status
The native open/close sidebar comparison was incomplete. Execution auto-review
denied the Application-scope `context.selected.list` grant; that action did not
execute. No post-sidebar inference was performed, the prompt expired, and the
sample was disabled while the worker cleaned up. This is not native confirmation
of the startup cause. The original `677aac33` app remains frozen for comparison;
this fix does not authorize retrying the denied execution action.
In the earlier permission engine, **Application** grants are memory-only for the
application lifetime; **Always** grants are persisted. An explicitly authorized
Application grant can fit bounded temporary QA. A one-use prompt on every stream
read is not an unavoidable target UX. The separate policy task implements the
new direction: default Allow for declared supported capabilities, per-plugin
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
separate from execution-tool authorization and must not be used to evade a tool
denial. This transport correction does not redesign that policy.
Native outcome, independent verification, and the sample's light-theme appearance
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
model invocation, main merge, or release was performed for this correction.
## Node 22 CI observer correction
The new cold-start test failed on exact head `9ca70f329` in test run
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
The sole failure was the empty SDK/renderer-error assertion receiving one
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
not an unrelated baseline failure.
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
Reproduction on the exact CI version identified the safe category
`setup:node-sqlite-experimental`, before any model operation. The existing
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
`console.error`. The newly added observer was installed before that warning
drained. The same test passed on Node 24, which did not emit that warning.
The test now lets pending module warnings reach normal stderr before installing
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
assertion is unchanged. Safe phase/error categories make future failures
diagnosable without request payloads or headers. No production behavior, timeout,
permission, or cancellation assertion changed. Both Node versions pass the
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
16 tests with concurrency 4.
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
records the parallel check. The appearance-only sample change is independently
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
+155
View File
@@ -0,0 +1,155 @@
# Test the selected Note assistant on an isolated desktop
This recipe tests a sandbox plugin against a deterministic loopback provider.
The provider does not run a model or contact a paid service. The profile contains
only synthetic data. The parent owns native screenshots and video on the working
cloud GUI machine. The local executor's sandbox-helper failure remains a separate
failed launch record.
## Prepare the integrated head
Use a head containing the market source API, #3985's malformed-context repair,
#3986, and the transport task's canonical AI/context registration. Record the exact
commit and artifact hashes. Keep all PRs Draft. Do not merge or release them.
Run the build and tests with the repository's supported Node version:
```sh
git rev-parse HEAD
npm run build
npm run build:plugin-runtime-deps
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/assistant-example.ncpkg
sha256sum /tmp/assistant-example.ncpkg
```
The package contains six files: its manifest, README, three panel files, and one
browser runtime entry. It has no Node entry, networking, filesystem, secrets,
storage, terminal, or command-execution permission. Its `commands` permission
registers its own view callbacks. It cannot execute terminal commands.
The six-file artifact at this checkpoint has SHA-256
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
update. The host provider cold-start correction remains a separate app change.
## Start the deterministic fixture
Start `node scripts/plugin-assistant-provider-fixture.mjs` from the repository.
The server prints its loopback URL. It listens only on `127.0.0.1`; use a fresh
unused port. Its response is fixed synthetic text, split into delayed SSE chunks.
The response includes fixed usage metadata. It accepts no request tools or system
messages. It keeps only request counts and prints no request content or headers.
Launch the exact integrated app on the working desktop with a fresh disposable
user-data directory. Preserve Chromium sandboxing. Do not use a real app profile,
cloud account, credential, host, Note, or chat history.
On that working desktop, use a separate shell for each long-running process:
```sh
node scripts/plugin-assistant-provider-fixture.mjs --port 9217
```
```sh
netcattyAssistantQaProfile=$(mktemp -d /tmp/netcatty-assistant-qa.XXXXXX)
env -u ELECTRON_RUN_AS_NODE node node_modules/electron/cli.js . --user-data-dir="$netcattyAssistantQaProfile"
```
The fixture's counts are available from `http://127.0.0.1:9217/fixture/status`.
Use a different unused loopback port if 9217 is occupied and update the provider
URL accordingly. These launch commands are for the working GUI machine; they do
not override this executor's failed sandbox-helper check.
In AI Settings, create a custom OpenAI-compatible provider with the fixture's
`/v1` URL, model `deterministic-fixture`, and the literal synthetic credential marker
`fixture-only`. This marker exercises the existing credential injection path
without a real API credential. Select that
provider and model through the host UI. Do not enable an external Agent or tools.
Create one Note named `Synthetic retry plan` containing:
```text
Synthetic retry plan: add a retry button and show a connection status label.
```
## Record the installed-plugin path
1. Import the package. Record its ID `com.netcatty.assistant`, version `0.1.0`,
source, SHA-256, browser runtime, required permissions, and optional permissions.
Installation must not grant optional access. Source labels do not imply that
an author or package is officially maintained.
2. Enable the plugin and open its `Selected Note Assistant` modal from the plugin
view catalog. Allow only its required view and command registration permissions.
3. Grant `ai.invoke` and `context.selected.list` when the host prompts. Record the
host-selected model label. The host's Note chooser must show app-owned chrome.
Verify the native plugin modal yields while this chooser is open and restores
after dismissal; the plugin must not cover the host's selection controls.
Select only `Synthetic retry plan` and click **Share selected summaries**.
4. Verify that the plugin reference dropdown starts at **No reference**. Select
`Synthetic retry plan` explicitly, then click **Send**.
5. Deny `context.selected.read`. No fixture request may be sent. The optional
model permission must not authorize Note access.
6. Retry. Allow the single selected-context read. The separate host text dialog
must show the exact synthetic text, scope, and version, with **Cancel** selected
by default. Cancel once and confirm that no fixture request is sent.
7. Retry and click **Share this text**. Record streamed text, completion, and token
usage. Capture the full host window, not only the plugin iframe.
8. Click **Send** again, share the same synthetic text, and click **Stop** while
the delayed fixture is streaming. The text must stop changing; the fixture's
aborted-connection counter must increase. Record this sequence as video.
9. Change the Note during a pending review and attempt to share the old dialog.
The expected-version check must reject the result. Do not send stale content.
10. Revoke the plugin's AI/context permission while a turn is active. Disable,
re-enable, and close its view. Old handles and late results must not resume.
Lock/unlock the app and navigate or reload the main window in the isolated
profile. Record a fresh successful run after reauthorization.
11. Keep the fixture request counter and aborted-connection counter in the
evidence. Do not include prompt bodies, headers, credentials, or Note content
other than the synthetic text above in logs.
12. Type a synthetic draft and click **Refresh model** while idle. The model
session must reopen, the draft must remain, and the fixture request count
must not increase. Change the selected configured model in host Settings,
refresh again, and check the displayed model before explicitly sending.
Refresh and failed requests must never automatically resend inference.
13. Keep a selected synthetic Note until its five-minute handle expires. Send
must explain that the reference expired without reading text or calling the
fixture. Refresh model must not share the Note again. Click **Choose
reference**, confirm the Note in the host chooser, select its fresh reference
in the dropdown, and click Send yourself. Deny the fresh content permission
once and cancel text review once before allowing a successful fixture run.
The draft must survive all steps, and no request may be silently retried.
14. With the sample idle, switch the host between light and dark and change its
accent. The page, controls, muted surfaces, text, focus ring, and Send button
must follow the existing view environment tokens without model requests or
new permission prompts. Capture both appearances on the same host window.
This check does not change or bypass the separately owned permission policy.
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
panel and actual host service/selected-context authority clocks and advances past
300,000 ms using a real selected Note handle, without network calls. It checks
fresh host selection, old-handle rejection, independent content permission and
exact-text review, and a successful explicit send. The broker budget
regression controls its clock across runtime replacement and the minute boundary.
Do not change the host clock or production limits to reproduce those cases in
native QA.
The automated suites cover source substitution, runtime identity, two-handle
revocation after malformed input, late replies, byte/token/time bounds, and
no-key serialization. Native execution must still verify the real prompts,
installed runtime, configured provider transport, and UI lifecycle.
## Record remaining limits
Independent review found the malformed-context revocation bug. The regression
reproduced and the repair passed focused tests. Further review was initially
blocked; after newer exact maintainer security-task evidence, the same worker
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
or alternate route/model. Two additional P2 findings were reproduced and repaired
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
Correction review independently confirmed the stable source-budget fix, then
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
handles after host confirmation. The real-Note panel and service regressions
failed before repair and pass afterward. Independent verification of this latest
correction remains pending. CI and native evidence do not replace it.
@@ -0,0 +1,86 @@
# Byte stream acceptance evidence
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
The transport delta does not edit the source database or sync implementation.
Draft PR #3988 records the published review head. No merge or release is authorized.
## Completed local checks
The delegated environment uses Node 24.19.0 on Linux.
- `npm run test:plugin-contract`: 90 passed, zero skipped.
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
Database-specific testing is now entirely in the example directory.
- Final focused transport, source-isolation, installed entrypoint and disposable
database run before the test-file move: 32 passed, zero skipped. After the
move, the same installed database test passed again at its example path.
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
real MariaDB result and server-side INSERT denial passed through both the
core and the ordinary installed example entrypoint. No host ports, fixture
override, real hosts, or real credentials were used by this database test.
Its container, network, package and profile were removed.
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
clearing before dispatch and safe text rendering passed.
- Production `npm run build`, generated contract drift, changed-source lint,
and whitespace checks passed.
Real broker TLS tests validate trust and the original requested hostname using
a disposable fixture CA. Database traffic is plain TCP inside an internal
Docker network. Neither check proves native BrowserWindow sandbox behavior,
database TLS, device testing, or real model inference. The registered AI/context
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
wiring owned by the AI task.
The EOF and last-await cancellation regressions each failed before their fix
and passed after it with real loopback sockets. Source changes before grant
publication, source changes during a pending read, and a stream returned after
runtime release have separate passing regressions.
## Reproducible native sample
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
Two separate staging directories produced the same five-file package:
- Archive bytes: 11,621.
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
The artifact contains no credentials or server setup. Changing its approved
fixture endpoint requires a newly prepared package and a recorded new hash.
## Checks that did not pass locally
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
Electron build caches under `/workspace` passed that step, then failed while
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
directory packaging remains failed locally and needs the normal CI or native
QA build environment.
The broad `npm test` run reported failures in existing transfer-resume,
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
tests, then stalled without a final aggregate. Its owned process tree was
terminated. It is not reported as a passing full test run.
An independent source-baseline worktree at `4213d8e` ran five affected files in
the same environment with shared dependencies: 145 tests, 118 passed,
24 failed, three skipped. It reproduced failures in transfer-resume,
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
This diagnoses that tested subset only. It does not classify every broad-suite
failure, replace exact-head CI, or make any failed result successful.
## Pending acceptance
Exact published-head CI and independent review remain pending. The parent owns
sequential native GUI acceptance for streams and the database example, then AI,
then logging integration. The native recipe covers ordinary install/source and
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
password, audit inspection, screenshots, and a short recording. No native GUI
run or screenshot/video result is claimed by this delegated executor.
@@ -0,0 +1,111 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
## Registered transport
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
`network.byte-streams` negotiates the capability. The separate `network.stream`
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
payload limits are unchanged.
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
`dispose()`. The control methods are `network.stream.connect`,
`network.stream.attach`, and `network.stream.close`. Descriptors, request
shapes, feature names, limits, generated SDK types, and host validators share
the canonical schema. Unknown request properties are rejected.
The registered service authenticates the host runtime before source lookup.
Runtime startup captures the existing opaque source owner. Every asynchronous
commit revalidates the runtime, active and retained source, and opaque owner
through the source task's APIs. Caller-supplied owner/source parameters are
rejected. Audit commits also verify the source binding.
Connection and stream IDs are host-issued and bound to that runtime and owner.
The SDK prepares its incoming waiter before attach starts the host producer.
Both directions reuse the existing binary stream router and credit windows.
Input credit returns after the socket write callback. Output backpressure
bounds the producer. The router has one optional producer `onClose` hook;
peer cancellation releases the socket even while it is idle.
The host validates all DNS answers and pins a literal IP. Production rejects
loopback, link-local, unspecified, multicast, mapped local addresses, selected
IPv6 tunnel forms, and the machine's own interface addresses. The production
host constructor never forwards the core's explicit local-test override.
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
plugin runtime, or filesystem escape.
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
seconds. Close tombstones are capped at 256 small identity records. Revocation,
runtime stop, disable, shutdown, and stream cancellation release resources.
TLS requires trust and original-hostname validation with TLS 1.2 or later.
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Additive AI interface for the parallel owner
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
and optional lifecycle callbacks. These callbacks are never exposed to plugin
views. The default adapter rejects model invocation, returns no selected
resources, and refuses text review. The AI owner still owns real renderer
wiring and model/context UX. A registered synthetic adapter test is not proof
of that remaining production wiring.
## Database example
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
endpoint. The output contains a browser bundle, a normal manifest, and view
assets. The preparation tool and its Node dependencies are not in the plugin.
Only the example contains the database protocol: common protocol 4.1,
`mysql_native_password`, and
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
The view accepts session-only fixture credentials and clears its password
field before dispatch. It does not save view state, settings, logs, or secrets.
The host never exposes SSH or AI credentials. JavaScript does not promise
memory zeroization. The account's server-side SELECT grant enforces read-only
access. The fixed query is not a SQL security classifier.
The example has no query editor, schema browser, pool, arbitrary SQL, or host
connection-management subsystem. See the example README for native setup.
## Evidence and limits
- Both source-reviewed races have executable real-loopback regressions here.
Delayed final-byte consumption previously lost EOF after the socket closed.
Double-microtask cancellation previously created an untracked late socket.
Each regression failed before its respective fix and passed afterward.
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
untrusted certificate and wrong-hostname denial. Connect and idle deadline
cases use mock clocks around real sockets or delayed DNS.
- The installed entrypoint test builds and installs a normal package, executes
its actual browser entry module through SDK/RPC/binary streams, and exercises
grant denial, exact resources, unknown-field rejection, close, revoke, and
disable. It also exercises registered AI/context SDK routes with a synthetic
trusted adapter. Its runtime harness is Node and is explicitly not a browser
sandbox or OS sandbox test.
- The disposable MariaDB test checks a synthetic SELECT result and server-side
INSERT denial. It creates an internal network, publishes no host ports, and
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
sample hash, and remaining native/exact-head review work. Do not disable the
Chromium sandbox to obtain that evidence.
@@ -1,87 +0,0 @@
# Local logging validation
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
All data used in these checks is synthetic and each persistence test creates its
own temporary profile. No real logs or credentials were opened.
| Check | Result |
| --- | --- |
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
| `npm run build` | Passed; existing large-chunk warning |
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
| `npm run check:plugin-contract` | Passed, no generated drift |
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
| `git diff --check` | Passed |
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
field exclusion, host source attribution, unknown-source fallback, URL rejection,
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
limits, rotation, expiration, drop/error counts, export, clear confirmation,
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
regressions cover direct paths, symlink aliases, a symlinked profile directory,
and revocation during the final protected-storage check.
## Remaining native checks
Electron launch failed before application/profile startup. The executable at
`node_modules/electron/dist/electron --version` aborted with:
> The SUID sandbox helper binary was found, but is not configured correctly.
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
switch, privilege escalation, permission repair, or native retry followed. Native
smoke execution, screenshots, and a short recording remain pending. The acceptance
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
`npm run pack:dir` completed its production build, then failed during serialport
native rebuilding: node-gyp returned ENOENT for mkdir
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
A native package was not produced.
## Parent integration
The only shared settings-page change is one import and one `<PluginLogSettings />`
element. Preserve that element when integrating the marketplace task's page.
There are no Installed, Official, Third-party, source-database, or unrelated host
data changes.
The default host-owned resolver is wired through `createPluginHostService`. It
consumes #3981's confirmed API without editing its database or sync implementation.
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
transport-registration change is needed beyond the existing logging registration.
## Source and review follow-up
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
the logging backend overlay. Its logging/source/host regression command passed
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
shutdown-drain case. The product branch does not import marketplace changes or bump its database
schema.
Independent review of the implementation follow-up found no remaining blocking
findings. Independent runs passed 50 focused regressions with one expected source
skip, 20 real-source integration regressions with no skips, and the logging UI
interaction test. The reviewer made no edits and did not launch Electron. A further independent
review of the newer encoded-local-owner contract found no issues; all six
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
with an additional probe confirming the local encoded owner.
Fixed review findings have deterministic regressions: 31 days of low-volume
appends and export cannot extend segment retention via mtime; stale sources cannot
rotate away existing evidence; settings-write failure shows actual host state;
log draining still completes when another shutdown stage rejects; and activation
permission denial produces a fixed host record before runtime creation.
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
Those runs do not validate the follow-up head. Its exact-head CI is reported in
the PR after pushing.
The native fixture build is reproducible: 3,270 bytes, SHA-256
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
Native screenshots and recording remain assigned to the parent's working cloud
GUI machine. No local sandbox bypass or packaging retry is authorized by this
handoff.
-181
View File
@@ -1,181 +0,0 @@
# Local plugin logging
Local file persistence defaults to off. The existing permission panel keeps its
bounded security history independently. Changing or clearing local log files does
not clear that history, grant permissions, or change a plugin's source.
## Saved records
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
They stay on the device. No cloud upload or sync path includes these files or the
local preference at `<userData>/plugins/local-logging.json`.
Host audit records contain these facts:
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
- Source ID, source kind, and namespace from an injected host provenance resolver.
Missing provenance is `null`. Manifest claims never imply an official source.
- Registered host RPC operation, authorization outcome, result, numeric error code,
elapsed milliseconds, and available input/output `data` field byte counts.
- Target identifiers from host authorization descriptors, replaced with a keyed
SHA-256 digest. The random key stays in memory, so target correlation lasts only
for the current app process.
- Cumulative dropped-record and write-error counts at enqueue time.
Runtime start, running, stop, failure, protocol rejection, and permission revocation
produce separate host facts. Required and advanced activation permission failures
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
validation or authorization fails. Authorization has separate `allowed`, `denied`,
`not-required`, and `not-reached` states. A handler error after approval preserves
`allowed` and records the failed result. Unknown values remain explicit.
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
binary `data` field. They are not total wire traffic or decoded base64 size.
Missing measurements are `null`. Streaming traffic outside these calls does not
become a complete per-byte trace. Host facts describe what the host observed;
plugin-returned success cannot prove an external side effect occurred.
Host audit records never copy request bodies, response bodies, error messages,
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
content. Error codes come from the host RPC error set. No audit field accepts
plugin-supplied source identity.
## Optional plugin text
Verbose diagnostics require a separate confirmation and turn off after app
restart. Console messages, utility stdout/stderr, and `logger.write` text use
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
adds identity outside the nested `diagnostic` object. Plugin fields named
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
Text and structured fields have traversal, length, depth and total-record limits.
Control characters are removed and known sensitive field names are replaced.
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
under an unexpected key or inside a sentence. Review files before sharing them.
No plugin text is persisted when verbose diagnostics are off.
## Limits and lifecycle
The shared sink retains at most three files of 1 MiB each, for at most seven days.
Expiry uses the first host timestamp in each segment, read within the existing
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
the same cutoff. A segment is removed when its first record expires; capacity
rotation or segment expiry can remove later records sooner. Retention runs on
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
The asynchronous queue accepts at most 256 KiB and records drops when full.
Plugin text can occupy only a quarter of that queue, leaving capacity for host
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
records per minute, with a 240-record initial burst), including console output. Each
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
both drop and error counts and never fail the plugin operation.
Turning saving off stops ingress, discards queued records that have not started,
and waits for any current write. Runtime cleanup closes its diagnostic logger.
App shutdown drains accepted writes through the existing manager shutdown path,
including when another cleanup stage rejects. A failed settings write refreshes
the checkbox from actual host state while retaining the original error.
Clearing pauses ingress, discards queued records, waits for a current write and
removes only the sink's known files. Cancellation leaves them intact. Export takes
a bounded snapshot on the write queue and uses the native Save dialog.
Files use mode 0600 and the directory uses 0700 on systems that support them.
The host filesystem broker rejects writes to the audit directory and preferences,
including canonical paths reached through symlinks. Plugin views cannot invoke
log management IPC. No plugin API exposes audit mutation. These local files are
not a tamper-evident ledger against the OS account or the development-only utility
runtime, which already has full Node and filesystem authority.
## Integration boundaries
`PluginLogSettings` presents a compact settings row on `PluginSettingsLanding`.
Its standard host dialog contains the save and verbose switches plus local file
actions. The landing page also shows installed-plugin quick controls using the
existing host manager. The Installed, Official and Third-party manager pages keep
their existing layout; declared settings links open the relevant installed detail.
The logging backend and source database are unchanged by this UI restructuring.
`createPluginHostService` now injects the host-owned
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
- `privateOwner(pluginId)` supplies the opaque namespace.
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
The adapter receives host activation identity only, after the existing caller
guards. It never accepts source or owner fields from plugin request parameters.
Missing APIs, an absent installation, changed version/hash, a retained-source
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
bare-plugin-ID fallback. The current host API encodes every owner, including
local imports; a bare ID returned as an owner remains unknown.
Each logger snapshots the source binding and supplies a host-only `isCurrent`
check. The sink checks it at enqueue, after asynchronous preflight, before
rotation mutations and immediately before appending. Records from a replaced or
uninstalled source are dropped and counted. They are never relabelled as the new
source. Already saved records keep their original identity.
The optional `resolvePluginProvenance` override remains host-owned. Its result
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
periods, underscores, colons and hyphens. Opaque namespaces accept the same
characters up to 256 bytes. URLs, display names and plugin claims are not source
identity. `official` describes the host-bound source, not official maintenance.
The source database, marketplace page and sync implementation are unchanged by
this adapter. It also needs no new shared host-registration change for transport.
## Native QA with synthetic data
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
synthetic fixture below. Do not retry the blocked local Electron launch.
Do not open real logs or credentials. Capture screenshots of the settings card,
verbose disclosure, and native Clear confirmation. Record a short flow:
1. Open Plugins settings and choose **Configure** in the local logging row.
Confirm both options are off and no host-v1 files exist.
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
host identity, outcomes, target digests, and absence of synthetic secret bodies.
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
the disclosure, write synthetic text, and verify the unverified record type.
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
app and verify that local saving remains selected but verbose text is off.
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
panel's separate security history remains. Turn local saving off.
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
```sh
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
```
Package: 3,270 bytes. SHA-256:
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
It declares only `commands` and `menus`; its command-palette entries are under
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
to exercise drop accounting. **Test undeclared network denial** must return denial
without a prompt or network I/O because the manifest declares no network access.
No request body or credential is supplied. Do not grant additional permissions.
Capture the compact settings card, verbose disclosure and native Clear dialog,
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
build, a normal local import must log the source and opaque owner returned by its
host database. On the older base, both stay explicitly unknown. Do not forge an
Official source through fixture metadata. The real-source replacement case is
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
integration worktree; it uses only temporary synthetic database state.
The execution environment for this change could not launch Electron: its SUID
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
any profile. No sandbox-disable retry or system-permission change was attempted.
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
system-directory change or packaging retry was attempted.
Native screenshots and recording therefore remain pending in the parent desktop
QA queue. Component interaction tests are separate evidence and do not replace
that acceptance.
@@ -0,0 +1,44 @@
# Selected-context authority reference
`electron/plugins/context/selectedContextAuthority.cjs` implements text disclosure.
It does not issue typed Host targets or authorize edit, connect, or command actions.
The constructor is `new SelectedContextAuthority({ resolveDataOwner, clock?,
limits? })`. `resolveDataOwner(context)` returns the authenticated host-owned source
binding. Runtime identity comes from the host RPC context, never plugin arguments.
| Method | Meaning |
| --- | --- |
| `share({scope,title,version,readText,isCurrent}, context)` | Trusted host issues an opaque, expiring text handle. Returns a summary. |
| `summaries(context)` | Enumerates only this source/runtime's shared summaries. Does not call `readText`. |
| `read({handle,version}, context)` | Checks identity, expected version, current owner, lifetime, and resource currency before and after the bounded read. |
| `assertCurrent({handle,version}, context)` | Checks currency without reading content. |
| `onDidInvalidate(listener)` | Observes handle revocation. One failed listener cannot interrupt cleanup. |
| `release(handle)` | Revokes one text handle. |
| `releaseRuntime(runtimeId)` | Revokes that runtime's text handles. |
| `releaseOwner(owner)` | Revokes that exact owner binding's text handles. |
| `shutdown()` | Revokes all handles. |
The summary contains `handle`, `scope`, `title`, `version`, and `expiresAt`.
`scope: "host"` means curated host-related text. It is not a Host entity reference.
A summary, menu ID, title, or host-related text cannot become an action target.
`PluginAISourceBinding` in `electron/plugins/ai/sourceBinding.cjs` consumes the
market owner's source API. `resolve(context)` first authenticates the current
browser runtime and then returns a stable frozen object containing `runtimeId`,
`pluginId`, `pluginVersion`, `sourceKey`, and `privateOwner`. It revalidates source
binding after asynchronous authentication. The object never crosses plugin RPC.
`sourceKey` binds the source ID, kind, and origin. It is not a trust endorsement.
This helper supplies neither resource selection nor action authorization.
Resource currency remains a trusted `isCurrent()` callback. The callback binds
the selected resource, expected revision, current host window, and its lifecycle.
The authority stores the issued version separately and never accepts a later
plugin-supplied revision as authoritative.
The Host/Vault action task requires a separate typed selection authority. Such a
target binds the host-owned source identity, runtime, plugin version, window owner
and generation, exact Host ID, expected Host revision, expiry, and allowed action.
Each action needs its own grant and host confirmation. Text-read handles must not
implicitly gain edit or connect authority. Host draft review and connection
confirmation remain separate workflows owned by that task.
-46
View File
@@ -1,46 +0,0 @@
# Plugin settings
Settings > Plugins uses the same sections, divided cards, rows and toggles as
other Netcatty settings. The first two rows open the plugin manager and configure
[local logging](local-logging.md). Logging details live in a standard host dialog;
opening it does not enable saving or verbose diagnostics.
The installed list shows each plugin's icon, name, version, publisher, description,
host-bound source and runtime status. Search filters this metadata. Quick toggles
call the existing host manager, which retains compatibility checks, permission
prompts and runtime lifecycle rules. Failed mutations leave the actual enabled
state visible. A settings button appears only for declared settings or settings
views, and is enabled only when the host exposes those contributions.
## Manual update checks
**Check for updates** reads installed packages, registered sources and each bound
source's catalog through the existing host bridge. It never installs code or
changes permissions. Open **Browse** to review and install an update through the
existing manager flow.
Only a fresh registry response with a published artifact and valid semantic
version counts as checked. Cached, built-in, failed and incomplete responses do
not imply that installed plugins are current. Local, unknown, removed and disabled
sources are not checked. The summary reports checked packages, available updates,
unchecked packages and unavailable sources separately.
The checker matches source ID, kind and origin, then revalidates source, installed
version and archive hash after asynchronous reads. Source or package changes
invalidate displayed results. Checks run only when requested; there is no automatic
checking preference, automatic installation or global pause switch.
## Native acceptance
Use a fresh isolated profile and synthetic plugins. Capture the compact landing
page and logging dialog at normal and narrow window sizes, including keyboard
focus, scrolling, loading, empty and error states. Verify a denied quick-enable
request leaves the switch off, and a successful request shows the real host state.
Open declared plugin settings, switch manager tabs, return to Installed and reopen
the settings. Close the dialog and verify focus returns to its opener.
Run a manual update check against a synthetic source, then an unavailable source;
verify the summary distinguishes unchecked packages and never installs anything.
Complete the separate local-logging acceptance flow, including verbose consent,
native Clear cancellation/confirmation and export. Do not use real logs or
credentials.
+4
View File
@@ -11,6 +11,9 @@ const PERMISSION_DESCRIPTIONS = {
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
@@ -26,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
+31 -1
View File
@@ -241,6 +241,7 @@ let registeredVaultAgentBridge = null;
// Active streaming requests (for cancellation)
const activeStreams = new Map();
const pluginModelStreamOwners = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
@@ -613,7 +614,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
let lifecycleFinished = false;
let idleTimer;
@@ -623,6 +628,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(totalTimer);
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -636,6 +642,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
}, totalTimeoutMs);
noteStreamActivity();
activeStreams.set(requestId, controller);
if (pluginRuntimeId) pluginModelStreamOwners.set(requestId, { sender: event.sender, runtimeId: pluginRuntimeId });
if (controller.signal.aborted) {
finishLifecycle();
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
@@ -782,10 +789,19 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
settleResolve({ statusCode, statusText });
let buffer = "";
let pluginResponseBytes = 0;
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
return;
}
}
const previousBufferLength = buffer.length;
buffer += chunk.toString();
// Guard against unbounded buffer growth
@@ -1005,12 +1021,25 @@ function registerHandlers(ipcMain) {
}
}
// This entry point is main-process only. Plugin views cannot choose a sender or
// access it through IPC. Internal Agent streams have no plugin request tag.
function cancelPluginAIStreamsForSender(sender, runtimeId) {
let cancelled = 0;
for (const [requestId, owner] of [...pluginModelStreamOwners]) {
if (owner.sender !== sender || (runtimeId !== undefined && owner.runtimeId !== runtimeId)) continue;
activeStreams.get(requestId)?.abort(createAbortError());
cancelled++;
}
return cancelled;
}
// Abort active streams and child processes on shutdown
function cleanup() {
for (const [id, controller] of activeStreams) {
try { controller.abort(); } catch {}
}
activeStreams.clear();
pluginModelStreamOwners.clear();
// Abort active SDK agent streams (set by registerSdkStreamHandlers on ctx).
if (registeredContext && registeredContext.sdkActiveStreams) {
@@ -1056,6 +1085,7 @@ module.exports = {
buildExternalAgentSystemContext,
buildExternalAgentContextualPrompt,
_streamRequestForTests: streamRequest,
cancelPluginAIStreamsForSender,
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
_getActiveStreamCountForTests: () => activeStreams.size,
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
-1
View File
@@ -245,7 +245,6 @@ function createBridgeRegistrar(context) {
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
logService: pluginHostService?.logService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
@@ -0,0 +1,73 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const schema = require("../generated/plugin-contract.schema.json");
if (!schema.$defs.PluginAIChatResult) {
test("assistant installed runtime requires the shared canonical AI/context contract", { skip: "Run on the parent-coordinated integration head" }, () => {});
} else {
test("installed assistant uses canonical SDK/RPC, independent content grants, reviewed version and revocation", { timeout: 15_000 }, async (t) => {
const { createPluginHostService } = require("../hostService.cjs");
const { inProcessBrowserRuntime } = require("../fixtures/inProcessBrowserRuntime.cjs");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-assistant-entry-"));
let service; let contentAllowed = false; let modelAllowed = true; let reviewed = true; let invoked = 0; let versionCurrent = true;
const decisions = [];
t.after(async () => { await service?.manager.shutdown(); await fs.rm(root, { recursive: true, force: true }); });
service = createPluginHostService({
app: { getAppPath: () => path.resolve(__dirname, "../../.."), getPath: () => root, getVersion: () => "0.0.0" }, electron: {}, env: {},
pluginAIHostAdapter: { assertActive: (context) => context.assertActive(),
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: () => true, invoke: async () => {
invoked++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; yield { kind: "usage", inputTokens: 12, outputTokens: 6, totalTokens: 18 }; })();
} }),
listSelected: async () => [{ key: "synthetic-note", scope: "note", title: "Synthetic retry plan", version: "v1",
readText: () => "Synthetic retry plan: add a retry button", isCurrent: () => versionCurrent }],
reviewText: async () => reviewed,
},
requestPermissionDecision: async (request) => {
decisions.push(request);
if ((request.permission === "context.selected.read" && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
return { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
},
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const sample = path.resolve(__dirname, "../../../examples/plugins/assistant");
// Use an explicit six-file fixture snapshot, not source/build tooling.
const staging = path.join(root, "package"); await fs.mkdir(path.join(staging, "dist"), { recursive: true });
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
await fs.copyFile(path.join(sample, "src/index.js"), path.join(staging, "dist/index.js"));
const { buildPluginPackage, validatePluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "assistant.ncpkg"); const built = await buildPluginPackage(staging, archive);
assert.equal(built.fileCount, 6); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
await service.manager.install(archive, { enable: true });
const id = "com.netcatty.assistant";
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
modelAllowed = false; await assert.rejects(run("open")); assert.equal(invoked, 0);
modelAllowed = true;
const chat = await run("open"); const references = await run("contexts"); assert.equal(references.length, 1);
assert.equal(references[0].scope, "note"); assert.equal(references[0].text, undefined);
const input = { chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: references[0].handle, version: references[0].version }] };
await assert.rejects(run("send", input)); assert.equal(invoked, 0);
contentAllowed = true; reviewed = false; await assert.rejects(run("send", input)); assert.equal(invoked, 0);
reviewed = true;
const turn = await run("send", input);
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "text", sequence: 0, text: "Synthetic summary" });
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "usage", sequence: 1, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "end", sequence: 2 });
await assert.rejects(run("send", { ...input, privateOwner: "spoofed" }));
await assert.rejects(run("send", { ...input, contexts: [null] }));
await assert.rejects(run("read", { turnHandle: turn.turnHandle, source: "spoofed" }));
const pending = await run("send", input);
service.permissionEngine.revokeApplication(id, "ai.invoke", "*");
assert.equal(service.aiContextService.broker.turns.size, 0);
await assert.rejects(run("read", { turnHandle: pending.turnHandle }));
assert.ok(decisions.some((decision) => decision.permission === "context.selected.read" && decision.resources[0].startsWith("selected:")));
const fresh = await run("open"); const [reference] = await run("contexts");
versionCurrent = false;
await assert.rejects(run("send", { chatHandle: fresh.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }));
await service.manager.setEnabled(id, false); assert.equal(service.aiContextService.broker.chats.size, 0);
});
}
+179
View File
@@ -0,0 +1,179 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
const ROUTES = Object.freeze([
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
]);
class PluginAIContextService {
constructor(options) {
this.host = options.hostAdapter;
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
}
this.source = new PluginAISourceBinding(options);
this.permissionEngine = options.permissionEngine;
this.resolveOwner = (context) => this.source.resolve(context);
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
this.runtimeContexts = new Map();
this.registrations = [];
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
resolveBudgetOwner: (binding) => JSON.stringify([binding.sourceKey, binding.privateOwner]),
selectModel: async (context) => {
await this.#guard(context);
const model = await this.host.selectModel(context);
await this.#guard(context);
return model;
},
contexts: {
read: (reference, context) => this.readSelected(reference, context),
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
releaseOwner: (owner) => this.selected.releaseOwner(owner),
},
});
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
}
async #guard(context) {
try {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
} catch (error) {
const binding = this.source.bindings.get(context.runtimeId);
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
}
#authorization(permission, params) {
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
return { permission, resources: [resource],
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
: permission === "context.selected.list" ? "List summaries of the current selected context"
: "Review and share this selected context version",
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
};
}
registerRpcCapabilities(registry) {
if (this.registrations.length) throw new Error("AI/context routes are already registered");
// Canonical validators are created only when transport integration registers
// this service. There is no second plugin wire schema in these modules.
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
method, permission, operation,
validateParams: createDefinitionValidator(paramsType),
validateResult: createDefinitionValidator(resultType),
}));
for (const route of prepared) {
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
await this.#guard(context);
const result = await this[route.operation](params, context);
await this.#guard(context);
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
return result;
}, {
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
validateParams: (params) => {
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
return Object.freeze(structuredClone(params));
},
authorization: async (params, context) => {
await this.#guard(context);
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
return this.#authorization(route.permission, params);
},
}));
}
}
open(_params, context) { return this.broker.open(context); }
start(params, context) { return this.broker.start(params, context); }
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
async list(_params, context) {
const owner = await this.#guard(context);
const selections = await this.host.listSelected(context);
await this.#guard(context);
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
throw fail("resourceExhausted", "Selected scope limit reached");
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
const items = await this.selected.summaries(context);
if (items.length === selections.length) return { items };
// The host has just explicitly selected these resources again. Expired
// or revoked handles stay invalid; issue fresh handles for this selection.
}
this.selected.releaseRuntime(context.runtimeId);
const created = [];
try {
for (const { key, scope, title, version, readText, isCurrent } of selections) {
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
created.push(summary.handle);
}
await this.#guard(context);
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
return { items: await this.selected.summaries(context) };
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
}
async readSelected(reference, context) {
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
const authorization = this.#authorization("context.selected.read", reference);
// Direct RPC reads have already passed the registry permission middleware.
// A model turn must obtain its separate content grant here.
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
await this.permissionEngine.authorize(context, authorization);
}
await this.#guard(context);
const selected = await this.selected.read(reference, context);
await this.#guard(context);
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
await this.#guard(context);
await this.selected.assertCurrent(reference, context);
return selected;
}
releaseRuntime(runtimeId) {
this.broker.releaseRuntime(runtimeId);
this.runtimeContexts.delete(runtimeId);
this.source.releaseRuntime(runtimeId);
this.host.releaseRuntime?.(runtimeId);
}
releasePlugin(pluginId) {
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
this.source.releasePlugin(pluginId);
}
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
shutdown() {
for (const registration of this.registrations.splice(0)) registration.dispose();
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
}
}
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
@@ -0,0 +1,79 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { PluginAIContextService } = require("./aiContextService.cjs");
function fixture() {
let current = true; let allowed = true; let reviewed = true; let sourceId = "source-a"; let revoke; let invoked = 0; let reads = 0;
const authorizations = [];
const context = { pluginId: "com.example.ai", runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
const source = () => ({ id: sourceId, kind: "third-party", origin: "https://source.example" });
const database = { getActivePlugin: () => ({ source: source(), enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: source, privateOwner: () => `opaque-${sourceId}`, publicPluginId: () => context.pluginId };
const hostAdapter = { assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic", isCurrent: () => current, invoke: () => { invoked++; return (async function* () { yield { kind: "text", text: "Result" }; })(); } }),
listSelected: async () => [{ key: "selected-note", scope: "note", title: "Synthetic", version: "1", isCurrent: () => current, readText: () => { reads++; return "Synthetic body"; } }],
reviewText: async () => reviewed,
};
const permissionEngine = { onDidRevoke: (listener) => { revoke = listener; return { dispose() {} }; },
authorize: async (_context, authorization) => { authorizations.push(authorization); if (!allowed) throw Error("Denied"); } };
const service = new PluginAIContextService({ database, permissionEngine, hostAdapter, getRuntimeIdentity: () => ({ ...context, runtimeKind: "browser" }) });
return { service, context, hostAdapter, authorizations, invoked: () => invoked, reads: () => reads,
allow: (v) => { allowed = v; }, review: (v) => { reviewed = v; }, current: (v) => { current = v; }, source: (v) => { sourceId = v; }, revoke: () => revoke({ pluginId: context.pluginId }) };
}
test("model invocation independently requests content permission and reviewed text", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); assert.equal(f.reads(), 0);
const chat = await f.service.open({}, f.context);
f.allow(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.invoked(), 0);
f.allow(true); f.review(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
f.review(true);
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context);
assert.equal((await f.service.read(turn, f.context)).text, "Result");
assert.ok(f.authorizations.every((authorization) => authorization.permission === "context.selected.read" && authorization.allowedScopes[0] === "once"));
});
test("source change during host review suppresses text and inference", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
f.hostAdapter.reviewText = async () => { f.source("source-b"); return true; };
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
});
test("permission revocation clears all handles for the plugin, including chats without context", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.revoke(); assert.equal(f.service.broker.turns.size, 0); assert.equal(f.service.selected.handles.size, 0);
await assert.rejects(f.service.read(turn, f.context));
});
test("cancelling one host request does not revoke another chat in the same runtime", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const chat = await f.service.open({}, f.context);
let resolve; f.hostAdapter.listSelected = () => new Promise((done) => { resolve = done; });
const controller = new AbortController();
const listing = f.service.list({}, { ...f.context, signal: controller.signal });
const cancelled = assert.rejects(listing);
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
});
test("an explicit host re-selection issues fresh handles after identical selected Note summaries expire", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
let now = 0; f.service.selected.clock = () => now;
const { items: [old] } = await f.service.list({}, f.context);
now = 300_001;
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
const { items } = await f.service.list({}, f.context);
assert.equal(items.length, 1);
assert.notEqual(items[0].handle, old.handle); assert.equal(items[0].expiresAt, 600_001);
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.authorizations.length, 0); assert.equal(f.invoked(), 0);
});
@@ -0,0 +1,287 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
function boundedLimits(overrides) {
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
const value = overrides[key] ?? max;
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
return [key, value];
})));
}
function abortable(promise, signal) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, resolveBudgetOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof resolveBudgetOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
this.resolveDataOwner = resolveDataOwner;
this.resolveBudgetOwner = resolveBudgetOwner;
this.selectModel = selectModel;
this.contexts = contexts;
this.clock = clock;
this.limits = boundedLimits(limits);
this.chats = new Map();
this.openings = new Set();
this.turns = new Map();
this.rates = new Map();
this.closed = false;
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
for (const turn of [...this.turns.values()]) {
if (turn.references.some((reference) => reference.handle === handle)) {
this.#end(turn, fail("cancelled", "Selected context was revoked"));
}
}
});
}
async #binding(context) {
await context.assertActive();
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
// Handle identity remains runtime-specific. Budget identity is separately
// supplied by the host and must survive runtime replacement/regrant.
const budgetOwner = await this.resolveBudgetOwner(owner, context);
await context.assertActive();
if (typeof budgetOwner !== "string" || !budgetOwner || Buffer.byteLength(budgetOwner) > 8_192) {
throw fail("permissionDenied", "Host budget owner is unavailable");
}
return { owner, budgetOwner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #ownedChat(chat, context) {
const binding = await this.#binding(context);
if (this.closed || this.chats.get(chat.handle) !== chat
|| chat.owner !== binding.owner || chat.budgetOwner !== binding.budgetOwner
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
async #check(chat, context) {
await this.#ownedChat(chat, context);
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
const currentOwner = await this.resolveDataOwner(context);
await context.assertActive();
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
#prune() {
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
}
async open(context) {
const binding = await this.#binding(context);
this.#prune();
if (this.closed || this.chats.size + this.openings.size >= this.limits.chats
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Plugin chat limit reached");
}
// Reserve before awaiting the host picker. Revoke/close can invalidate this
// admission even if a host callback ignores cancellation and returns late.
const opening = { ...binding, active: true };
this.openings.add(opening);
let model;
try {
model = await this.selectModel(context);
const current = await this.#binding(context);
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
} catch (error) {
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
throw this.#safe(error);
} finally { this.openings.delete(opening); }
}
async start({ chatHandle, prompt, contexts = [] }, context) {
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
throw fail("invalidArgument", "Invalid model input");
}
const references = contexts.map((reference) => {
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
throw fail("invalidArgument", "Invalid context reference");
}
return Object.freeze({ handle: reference.handle, version: reference.version });
});
const chat = this.chats.get(chatHandle);
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
await this.#check(chat, context);
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
const controller = new AbortController();
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
chat.turn = turn;
this.turns.set(turn.handle, turn);
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
try {
await this.#guard(turn, context);
const selected = [];
let bytes = Buffer.byteLength(prompt);
for (const reference of references) {
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
bytes += Buffer.byteLength(result.text);
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
selected.push(result.text);
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.budgetOwner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
// not refund unknown provider usage after cancellation or disconnect.
const reserved = bytes + 1_024 + this.limits.outputTokens;
if ((!this.rates.has(chat.budgetOwner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
throw fail("resourceExhausted", "Model request/token budget reached");
}
rate.count++; rate.tokens += reserved; this.rates.set(chat.budgetOwner, rate);
await this.#guard(turn, context);
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
await this.#guard(turn, context);
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
turn.iterator = iterator;
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
}
}
async #guard(turn, context) {
turn.controller.signal.throwIfAborted();
await abortable(this.#check(turn.chat, context), turn.controller.signal);
for (const reference of turn.references) {
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
}
turn.controller.signal.throwIfAborted();
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
}
#safe(error) {
// SDK/provider errors may contain headers, URLs or echoed request content.
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
}
async read(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
// Validate owner before mutating/aborting another caller's turn.
await this.#ownedChat(turn.chat, context);
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
turn.reading = true;
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
context.signal?.addEventListener("abort", abort, { once: true });
try {
if (context.signal?.aborted) abort();
await this.#guard(turn, context);
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
const next = await abortable(turn.iterator.next(), turn.controller.signal);
await this.#guard(turn, context);
if (next.done) {
this.#end(turn);
return { kind: "end", sequence: turn.sequence++ };
}
const event = next.value;
if (event?.kind === "text" && typeof event.text === "string") {
const bytes = Buffer.byteLength(event.text);
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
throw fail("resourceExhausted", "Model output byte budget exceeded");
}
turn.bytes += bytes;
return { kind: "text", sequence: turn.sequence++, text: event.text };
}
if (event?.kind === "usage") {
const usage = {};
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
if (event[key] !== undefined) {
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
usage[key] = event[key];
}
}
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
return { kind: "usage", sequence: turn.sequence++, ...usage };
}
// A model's tool request is data, never host authorization. This phase
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
} catch (error) {
this.#end(turn, error);
throw this.#safe(error);
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
}
async stop(turnHandle, context) {
const turn = this.turns.get(turnHandle);
if (!turn) return false;
await this.#ownedChat(turn.chat, context);
this.#end(turn, fail("cancelled", "Model turn cancelled"));
return true;
}
#end(turn, reason = fail("cancelled", "Model turn ended")) {
if (this.turns.get(turn.handle) !== turn) return;
this.turns.delete(turn.handle);
if (turn.chat.turn === turn) turn.chat.turn = null;
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
// Never wait for a provider which ignores cancellation. Its late next()
// result is observed by abortable() and cannot re-enter the output path.
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
turn.iterator = null;
}
#close(chat) {
if (chat.turn) this.#end(chat.turn);
this.chats.delete(chat.handle);
try { Promise.resolve(chat.model.dispose?.()).catch(() => {}); } catch {}
}
async close(chatHandle, context) {
const chat = this.chats.get(chatHandle);
if (!chat) return false;
await this.#ownedChat(chat, context); this.#close(chat); return true;
}
releaseRuntime(runtimeId) {
for (const opening of [...this.openings]) if (opening.runtimeId === runtimeId) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
this.contexts?.releaseRuntime(runtimeId);
}
releaseOwner(owner) {
for (const opening of [...this.openings]) if (opening.owner === owner) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
this.contexts?.releaseOwner(owner);
}
shutdown() { this.closed = true; for (const opening of this.openings) opening.active = false; this.openings.clear(); for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
}
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
@@ -0,0 +1,286 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const { RPC_ERRORS } = require("../rpcRouter.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
function fixture(options = {}) {
const owner = { budgetOwner: "synthetic-source-a" }; let currentOwner = owner; let current = true; let active = true; let now = 0;
const request = new AbortController();
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
const calls = [];
const model = { label: "Host selected model", isCurrent: () => current,
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
const resolveDataOwner = async () => currentOwner;
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: (binding) => binding.budgetOwner,
selectModel: async () => model, contexts,
clock: () => now, limits: options.limits });
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
}
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
assert.equal(f.calls.length, 0);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
assert.equal(f.calls[0].tools, undefined);
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
assert.equal(events[0].text, "Summary");
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
assert.equal(events[2].kind, "end");
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
});
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
const f = fixture(); t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
}
f.setOwner({});
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
f.setOwner(f.owner);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
});
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
const pending = deferred(); let signal;
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
t.after(() => f.broker.shutdown());
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
assert.equal(signal.aborted, true);
pending.resolve({ value: { kind: "text", text: "Late output" } });
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
});
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
for (const action of ["runtime", "owner", "abort"]) {
const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context);
await new Promise(setImmediate);
if (action === "runtime") f.broker.releaseRuntime("r1");
if (action === "owner") f.broker.releaseOwner(f.owner);
if (action === "abort") f.request.abort();
await assert.rejects(reading);
pending.resolve({ value: { kind: "text", text: "Late" } });
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
});
test("deadline also aborts providers that ignore AbortSignal", async () => {
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
});
test("model switch and chat expiration reject results", async () => {
for (const action of ["model", "expiry"]) {
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading, denied); f.broker.shutdown();
}
});
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
const f = fixture({ limits: { requestsPerMinute: 1 } });
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
await f.broker.stop(turn.turnHandle, f.context);
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
const again = await f.broker.open(f.context);
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
f.broker.shutdown();
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
}
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
const selected = await broken.broker.open(broken.context);
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
broken.broker.shutdown();
});
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
for (const action of ["revoke", "version"]) {
let current = true; const pending = deferred();
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
readText: () => "Synthetic", isCurrent: () => current }, f.context);
const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
if (action === "revoke") f.contexts.release(note.handle); else current = false;
pending.resolve({ value: { kind: "text", text: "Late" } });
await assert.rejects(reading); f.broker.shutdown();
}
});
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
const cases = [
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
];
for (const scenario of cases) {
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
if (scenario.start) await assert.rejects(start());
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
}
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
});
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
const f = fixture(); const chat = await f.broker.open(f.context);
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
const selected = await instance.broker.open(instance.context);
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
const controller = new AbortController();
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
});
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
})() } });
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
});
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
const entered = deferred(); const resume = deferred(); let checks = 0;
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
t.after(() => { resume.resolve(); f.broker.shutdown(); });
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
const chat = await f.broker.open(f.context);
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
const outcome = start.then(() => "accepted", (error) => error);
// On the old implementation, reach the reported post-publication model guard.
// On the fixed implementation, validation rejects before that await exists.
await Promise.race([entered.promise, outcome]);
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
assert.equal(f.contexts.handles.size, 0);
resume.resolve();
const error = await outcome;
assert.equal(error.code, RPC_ERRORS.invalidArgument);
assert.equal(f.broker.turns.size, 0);
assert.equal(f.calls.length, 0);
f.broker.shutdown();
});
test("pending model selections reserve capacity and cannot publish after revocation", async () => {
const f = fixture({ limits: { perRuntime: 1 } }); const pending = deferred(); let disposed = 0;
f.broker.selectModel = () => pending.promise;
const opening = f.broker.open(f.context); const rejected = assert.rejects(opening);
await new Promise(setImmediate);
await assert.rejects(f.broker.open(f.context), (error) => error.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
});
test("same-source runtime replacement preserves minute budgets while distinct sources stay isolated", async (t) => {
for (const limits of [{ requestsPerMinute: 1 }, { reservedTokensPerMinute: 3_074 }]) {
await t.test(Object.keys(limits)[0], async (t) => {
let now = 1_000; let sourceId = "source-a"; let runtimeId = "runtime-1"; let invocations = 0;
const pluginId = "com.example.assistant";
const context = () => ({ pluginId, pluginVersion: "1", runtimeId, assertActive: async () => {} });
const source = () => ({ id: sourceId, kind: "third-party", origin: `https://${sourceId}.example` });
const binding = new PluginAISourceBinding({
database: {
getActivePlugin: () => ({ enabled: true, activeVersion: "1", runtime: { status: "running" }, source: source() }),
getRetainedPluginSource: source,
privateOwner: () => `opaque:${sourceId}:assistant`,
publicPluginId: (owner) => owner === `opaque:${sourceId}:assistant` ? pluginId : null,
},
getRuntimeIdentity: () => ({ ...context(), runtimeKind: "browser" }),
});
const broker = new ModelInvocationBroker({
resolveDataOwner: (caller) => binding.resolve(caller),
resolveBudgetOwner: (owner) => JSON.stringify([owner.sourceKey, owner.privateOwner]),
selectModel: () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
invocations++; return (async function* () {})();
} }), clock: () => now, limits,
});
t.after(() => { broker.shutdown(); binding.shutdown(); });
const start = (chat) => broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, context());
const replace = (nextSource) => {
broker.releaseRuntime(runtimeId); binding.releaseRuntime(runtimeId);
sourceId = nextSource; runtimeId = `${runtimeId}-next`;
};
const firstOwner = await binding.resolve(context());
const first = await broker.open(context()); const firstTurn = await start(first);
await broker.stop(firstTurn.turnHandle, context());
replace("source-a");
assert.notEqual(await binding.resolve(context()), firstOwner);
const restarted = await broker.open(context());
await assert.rejects(start(restarted), (error) => error.code === RPC_ERRORS.resourceExhausted);
assert.equal(invocations, 1);
assert.equal(await broker.close(first.chatHandle, context()), false);
replace("source-b");
const other = await broker.open(context()); const otherTurn = await start(other);
await broker.stop(otherTurn.turnHandle, context()); assert.equal(invocations, 2);
replace("source-a");
const returned = await broker.open(context());
now = 60_999;
await assert.rejects(start(returned), (error) => error.code === RPC_ERRORS.resourceExhausted);
now = 61_000;
const afterWindow = await start(returned); assert.equal(invocations, 3);
await broker.stop(afterWindow.turnHandle, context());
});
}
});
test("budget ownership requires an explicit host adapter and never falls back to the plugin ID", async (t) => {
assert.throws(() => new ModelInvocationBroker({ resolveDataOwner: () => ({}), selectModel: () => ({}) }), /Host adapters required/);
for (const budgetOwner of [undefined, "", {}, "x".repeat(8_193)]) {
const f = fixture(); t.after(() => f.broker.shutdown());
f.broker.resolveBudgetOwner = () => budgetOwner;
await assert.rejects(f.broker.open(f.context), denied);
assert.equal(f.calls.length, 0); assert.equal(f.broker.chats.size, 0);
}
});
+61
View File
@@ -0,0 +1,61 @@
"use strict";
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
// Consume the market task's host-owned API. No source or owner is read from RPC
// parameters, and a missing API/retained source fails closed.
class PluginAISourceBinding {
constructor({ database, getRuntimeIdentity }) {
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
}
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
this.database = database;
this.getRuntimeIdentity = getRuntimeIdentity;
this.bindings = new Map();
}
#snapshot(context) {
const identity = this.getRuntimeIdentity(context.pluginId);
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
const plugin = this.database.getActivePlugin(identity.pluginId);
const source = plugin?.source;
const retained = this.database.getRetainedPluginSource(identity.pluginId);
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|| !["starting", "running"].includes(plugin.runtime?.status)
|| typeof source.id !== "string" || typeof source.kind !== "string"
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
const privateOwner = this.database.privateOwner(identity.pluginId);
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
}
async resolve(context) {
// Authenticate first. Even the database source lookup follows this guard.
await context.assertActive();
context.signal?.throwIfAborted();
const snapshot = this.#snapshot(context);
const previous = this.bindings.get(snapshot.runtimeId);
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
await context.assertActive();
context.signal?.throwIfAborted();
const current = this.#snapshot(context);
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
if (previous) return previous;
const binding = Object.freeze(snapshot);
this.bindings.set(binding.runtimeId, binding);
return binding;
}
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
releasePlugin(pluginId) {
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
}
shutdown() { this.bindings.clear(); }
}
module.exports = { PluginAISourceBinding };
@@ -0,0 +1,41 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
function fixture() {
let source = { id: "source-a", kind: "third-party", origin: "https://registry.example" };
let owner = "source:opaque-a:com.example.assistant"; let active = true; let runtimeId = "r1"; let reads = 0;
const context = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", assertActive: async () => { if (!active) throw Error("Inactive"); } };
const database = {
getActivePlugin: () => { reads++; return { source, enabled: true, activeVersion: "1", runtime: { status: "running" } }; },
getRetainedPluginSource: () => source,
privateOwner: () => owner,
publicPluginId: (value) => value === owner ? context.pluginId : null,
};
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => ({ ...context, runtimeId, runtimeKind: "browser" }) });
return { binding, context, database, reads: () => reads, activeTo: (v) => { active = v; }, runtimeTo: (v) => { runtimeId = v; },
sourceTo: (v) => { source = v; }, ownerTo: (v) => { owner = v; } };
}
test("host authentication precedes database access; forged runtime and missing provenance fail closed", async () => {
const f = fixture(); f.activeTo(false); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
f.activeTo(true); f.runtimeTo("different"); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
f.runtimeTo("r1"); f.database.getRetainedPluginSource = () => null; await assert.rejects(f.binding.resolve(f.context));
assert.throws(() => new PluginAISourceBinding({ database: {}, getRuntimeIdentity: () => null }));
});
test("opaque host owner remains stable and source changes cannot resume the old runtime", async () => {
const f = fixture(); const owner = await f.binding.resolve({ ...f.context, source: "attacker", privateOwner: "attacker" });
assert.equal(owner.privateOwner, "source:opaque-a:com.example.assistant");
assert.equal(await f.binding.resolve(f.context), owner);
f.sourceTo({ id: "source-b", kind: "third-party", origin: "https://other.example" });
f.ownerTo("source:opaque-b:com.example.assistant"); await assert.rejects(f.binding.resolve(f.context));
// A fresh authenticated runtime may acquire its own distinct binding.
f.binding.releaseRuntime("r1"); const next = await f.binding.resolve(f.context); assert.notEqual(next, owner);
});
test("source/owner changes during asynchronous authentication cannot commit a binding", async () => {
const f = fixture(); let checks = 0;
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
});
+216
View File
@@ -0,0 +1,216 @@
"use strict";
const { randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", ready: "netcatty:plugins:ai-host-ready", response: "netcatty:plugins:ai-host-response" });
// Trusted main-window link, never a plugin IPC surface. Canonical envelopes are
// supplied by the shared contract task; no provider config or key crosses it.
class PluginAITrustedHostAdapter {
constructor(options) {
this.getWindow = options.getWindow;
this.isLocked = options.isLocked ?? (() => true);
this.dialog = options.dialog;
this.cancelProviderStreams = options.cancelProviderStreams ?? ((sender, runtimeId) => require("../../bridges/aiBridge.cjs").cancelPluginAIStreamsForSender(sender, runtimeId));
this.pending = new Map(); this.listeners = new Set(); this.reviewControllers = new Map();
this.owner = null; this.ownerCleanup = null; this.generation = 0; this.closed = false;
this.timeoutMs = Math.min(options.timeoutMs ?? 60_000, 60_000);
this.validateRequest = options.validateRequest ?? null;
this.validateResponse = options.validateResponse ?? null;
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.invalidate(); });
}
registerIpc(ipcMain) {
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
return this.ipcRegistration;
}
#owner() {
const window = this.getWindow?.();
if (this.closed || this.isLocked() || !this.owner || window?.webContents !== this.owner || window.isDestroyed?.()
|| this.owner.isDestroyed?.() || window.isVisible?.() !== true) throw fail("unavailable", "Trusted AI host is unavailable");
return this.owner;
}
async assertActive(context) {
await context.assertActive(); context.signal?.throwIfAborted(); this.#owner();
}
setReady(sender, ready) {
if (typeof ready !== "boolean" || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) {
throw fail("permissionDenied", "AI host sender is not the main window");
}
if (!ready) { if (this.owner === sender) this.invalidate(true); return true; }
if (this.closed) throw fail("unavailable", "AI host is closed");
if (this.owner === sender) return true;
this.invalidate(true); this.owner = sender;
const window = this.getWindow();
const release = () => this.invalidate(true);
const hide = () => this.invalidate();
const navigate = (details, _url, inPlace, mainFrame) => {
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
};
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate); window.on?.("hide", hide);
this.ownerCleanup = () => {
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release);
sender.removeListener?.("did-start-navigation", navigate); window.removeListener?.("hide", hide);
};
return true;
}
#envelope(kind, payload, runtimeId) {
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
assertPluginJsonValue(request, { maxBytes: 24_576 });
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
return request;
}
#send(kind, payload, runtimeId) {
const request = this.#envelope(kind, payload, runtimeId);
return { request, sender: this.#owner(), generation: this.generation };
}
#cleanup(kind, payload, runtimeId, sender = this.owner) {
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
}
async #request(kind, payload, context) {
await this.assertActive(context);
if (this.pending.size >= 64) throw fail("resourceExhausted", "Trusted AI host request limit reached");
const { request, sender, generation } = this.#send(kind, payload, context.runtimeId);
return new Promise((resolve, reject) => {
const entry = { request, sender, generation, context, resolve, reject };
entry.finish = (result, error) => {
if (!this.pending.delete(request.requestId)) return;
clearTimeout(entry.timer); context.signal?.removeEventListener("abort", entry.abort);
if (error) {
this.#cleanup("request.cancel", { requestId: request.requestId }, context.runtimeId, sender);
reject(error);
} else resolve(result);
};
entry.abort = () => entry.finish(null, fail("cancelled", "Trusted AI host request cancelled"));
entry.timer = setTimeout(() => entry.finish(null, fail("deadlineExceeded", "Trusted AI host request timed out")), this.timeoutMs);
this.pending.set(request.requestId, entry);
context.signal?.addEventListener("abort", entry.abort, { once: true });
if (context.signal?.aborted) { entry.abort(); return; }
try { sender.send(CHANNELS.request, request); }
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
});
}
async respond(sender, response) {
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
assertPluginJsonValue(response, { maxBytes: 24_576 });
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
const expected = Buffer.from(entry.request.actionToken); const actual = Buffer.from(response.actionToken);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
// Consume the nonce before awaiting authentication to prevent replay races.
entry.request.actionToken = "";
try {
await this.assertActive(entry.context);
if (entry.generation !== this.generation || this.pending.get(response.requestId) !== entry) return false;
if (response.error === true) entry.finish(null, fail("unavailable", "Trusted AI host operation failed"));
else entry.finish(response.result);
return true;
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
}
async selectModel(context) {
const selected = await this.#request("model.select", {}, context);
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
const generation = this.generation;
return {
label: selected.label,
dispose: async () => { try { await this.#request("model.release", { hostHandle: selected.hostHandle }, { ...context, signal: undefined }); } catch {} },
isCurrent: async () => generation === this.generation && (await this.#request("model.current", { hostHandle: selected.hostHandle }, context)).current === true,
invoke: async (input) => {
const activeContext = { ...context, signal: input.signal };
const turn = await this.#request("model.start", { hostHandle: selected.hostHandle, prompt: input.prompt,
contexts: input.contexts, maxOutputTokens: input.maxOutputTokens, maxChunkBytes: input.maxChunkBytes }, activeContext);
if (typeof turn?.hostHandle !== "string") throw fail("internal", "Model turn is invalid");
return {
next: async () => {
const result = await this.#request("model.next", { hostHandle: turn.hostHandle }, activeContext);
if (result.done === true) return { done: true };
if (result.done !== false || !result.event) throw fail("internal", "Model stream event is invalid");
return { done: false, value: result.event };
},
return: async () => {
// Cleanup must remain possible after the turn signal is aborted.
try { await this.#request("model.stop", { hostHandle: turn.hostHandle }, { ...context, signal: undefined }); } catch {}
return { done: true };
},
};
},
};
}
async listSelected(context) {
const result = await this.#request("context.select", {}, context);
if (!Array.isArray(result?.items) || result.items.length > 5) throw fail("internal", "Selected context list is invalid");
const generation = this.generation;
return result.items.map((item) => ({ key: item.hostHandle, scope: item.scope, title: item.title, version: item.version,
isCurrent: async () => generation === this.generation && (await this.#request("context.current", { hostHandle: item.hostHandle, version: item.version }, context)).current === true,
readText: async ({ signal } = {}) => {
const read = await this.#request("context.read", { hostHandle: item.hostHandle, version: item.version }, { ...context, signal: signal ?? context.signal });
return read.text;
},
}));
}
async reviewText(selected, context) {
await this.assertActive(context);
const window = this.getWindow(); const generation = this.generation;
if (typeof this.dialog?.showMessageBox !== "function") return false;
// A native permission dialog can resolve just before its parent regains
// focus. Wait briefly for this exact owner, without focusing it ourselves.
const focusDeadline = Date.now() + 1_000;
while (window.isFocused?.() !== true) {
await this.assertActive(context);
if (generation !== this.generation || Date.now() >= focusDeadline) return false;
await new Promise((resolve) => setTimeout(resolve, 20));
}
const controller = new AbortController();
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
this.reviewControllers.set(controller, context.runtimeId);
try {
const result = await this.dialog.showMessageBox(window, {
type: "question", title: "Share selected context", message: `Share "${selected.title}" with ${selected.pluginId}?`,
detail: `Scope: ${selected.scope}\nVersion: ${selected.version}\n\n${selected.text}`,
buttons: ["Share this text", "Cancel"], defaultId: 1, cancelId: 1, noLink: true, signal,
});
await this.assertActive(context);
return !signal.aborted && generation === this.generation && result.response === 0;
} finally { this.reviewControllers.delete(controller); }
}
onDidInvalidate(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
releaseRuntime(runtimeId) {
try { if (this.owner) this.cancelProviderStreams(this.owner, runtimeId); } catch {}
for (const entry of [...this.pending.values()]) if (entry.context.runtimeId === runtimeId) entry.abort();
for (const [controller, owner] of this.reviewControllers) if (owner === runtimeId) controller.abort();
this.#cleanup("runtime.release", {}, runtimeId);
}
invalidate(releaseOwner = false) {
this.generation++;
try { if (this.owner) this.cancelProviderStreams(this.owner); } catch {}
for (const entry of [...this.pending.values()]) entry.finish(null, fail("cancelled", "Trusted AI host lifecycle changed"));
for (const controller of this.reviewControllers.keys()) controller.abort();
for (const listener of [...this.listeners]) { try { listener(); } catch {} }
if (releaseOwner) { this.ownerCleanup?.(); this.ownerCleanup = null; this.owner = null; }
}
shutdown() { this.ipcRegistration?.dispose(); this.ipcRegistration = null; this.closed = true; this.invalidate(true); this.unsubscribeLock?.(); this.listeners.clear(); }
}
module.exports = { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS };
@@ -0,0 +1,96 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS } = require("./trustedHostAdapter.cjs");
function fixture(options = {}) {
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false });
const messages = [];
sender.send = (channel, payload) => messages.push({ channel, payload: structuredClone(payload) });
const window = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
let currentWindow = window; let locked = false;
const adapter = new PluginAITrustedHostAdapter({ getWindow: () => currentWindow, isLocked: () => locked,
// Domain tests isolate nonce/owner lifecycle. Canonical schema validation is
// exercised separately when the shared contract is integrated.
cancelProviderStreams: () => {},
validateRequest: () => true, validateResponse: () => true, timeoutMs: 1_000,
dialog: { showMessageBox: async () => ({ response: 0 }) }, ...options });
const context = { runtimeId: "r", pluginId: "plugin", pluginVersion: "1", assertActive: async () => {} };
adapter.setReady(sender, true);
return { adapter, sender, window, context, messages, lockedTo: (v) => { locked = v; }, ownerTo: (v) => { currentWindow = v; } };
}
const tick = () => new Promise(setImmediate);
test("trusted renderer replies require the exact owner and one-use nonce", async (t) => {
const f = fixture(); t.after(() => f.adapter.shutdown());
const selecting = f.adapter.selectModel(f.context); await tick();
const request = f.messages.at(-1).payload;
const response = { requestId: request.requestId, actionToken: request.actionToken, result: { hostHandle: "model", label: "Synthetic model" } };
await assert.rejects(f.adapter.respond({ id: 2 }, response));
assert.equal(await f.adapter.respond(f.sender, { ...response, actionToken: "wrong" }), false);
assert.equal(await f.adapter.respond(f.sender, response), true);
assert.equal(await f.adapter.respond(f.sender, response), false);
assert.equal((await selecting).label, "Synthetic model");
});
test("owner navigation/hide, runtime release, cancellation and timeout discard pending replies", async () => {
for (const action of ["navigate", "hide", "runtime", "abort", "timeout"]) {
const f = fixture(action === "timeout" ? { timeoutMs: 20 } : {}); const controller = new AbortController();
const selecting = f.adapter.selectModel({ ...f.context, signal: controller.signal });
const rejected = assert.rejects(selecting); await tick();
const request = f.messages.at(-1).payload;
if (action === "navigate") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
if (action === "hide") f.window.emit("hide");
if (action === "runtime") f.adapter.releaseRuntime("r");
if (action === "abort") controller.abort();
await rejected; assert.equal(f.adapter.pending.size, 0);
if (action !== "navigate") assert.equal(await f.adapter.respond(f.sender, { ...request, result: {} }), false);
f.adapter.shutdown();
}
});
test("main window replacement and lock cannot reuse a previous renderer owner", async () => {
const f = fixture(); f.ownerTo({ ...f.window, webContents: { id: 3 } });
await assert.rejects(f.adapter.selectModel(f.context));
assert.throws(() => f.adapter.setReady(f.sender, true)); f.adapter.shutdown();
const next = fixture(); next.lockedTo(true); await assert.rejects(next.adapter.selectModel(next.context)); next.adapter.shutdown();
});
test("selected text review is Cancel by default and cannot approve after cancellation", async () => {
let resolve; let options;
const f = fixture({ dialog: { showMessageBox: (_window, value) => { options = value; return new Promise((r) => { resolve = r; }); } } });
const controller = new AbortController();
const review = f.adapter.reviewText({ scope: "note", title: "Synthetic", version: "v1", pluginId: "plugin", text: "Synthetic text" }, { ...f.context, signal: controller.signal });
await tick(); assert.equal(options.defaultId, options.cancelId); assert.equal(options.cancelId, 1);
controller.abort(); resolve({ response: 0 }); await assert.rejects(review); f.adapter.shutdown();
});
test("text review tolerates native permission focus restoration without focusing a window", async () => {
const f = fixture(); let focused = false; let focusCalls = 0;
f.window.isFocused = () => focused; f.window.focus = () => { focusCalls++; };
const timer = setTimeout(() => { focused = true; }, 25);
try {
assert.equal(await f.adapter.reviewText({ title: "Synthetic", scope: "note", version: "1", text: "Synthetic", pluginId: "plugin" }, f.context), true);
assert.equal(focusCalls, 0);
} finally { clearTimeout(timer); f.adapter.shutdown(); }
});
test("canonical trusted envelopes reject unknown requests, keys and provider diagnostics", async () => {
const { createDefinitionValidator } = require("../contractValidator.cjs");
const requestValid = createDefinitionValidator("PluginAIHostRequest");
const responseValid = createDefinitionValidator("PluginAIHostResponse");
const identity = { requestId: "00000000-0000-4000-8000-000000000001", actionToken: "A".repeat(32), runtimeId: "runtime" };
assert.equal(requestValid({ ...identity, kind: "model.select", payload: {} }), true);
assert.equal(requestValid({ ...identity, kind: "model.select", payload: { providerId: "arbitrary" } }), false);
assert.equal(requestValid({ ...identity, kind: "terminal.execute", payload: {} }), false);
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken,
result: { hostHandle: identity.requestId, label: "Synthetic", apiKey: "forbidden" } }), false);
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken, error: "raw diagnostics" }), false);
const handlers = new Map(); const f = fixture({ validateRequest: requestValid, validateResponse: responseValid });
f.adapter.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
const pending = f.adapter.selectModel(f.context); await tick(); const request = f.messages.at(-1).payload;
assert.equal(await f.adapter.respond(f.sender, { requestId: request.requestId, actionToken: request.actionToken,
result: { hostHandle: identity.requestId, label: "Synthetic" } }), true);
assert.equal((await pending).label, "Synthetic"); f.adapter.shutdown(); assert.equal(handlers.size, 0);
});
+2 -2
View File
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
this.router.notify(method, params);
}
openStream(streamId, windowBytes) {
openStream(streamId, windowBytes, options) {
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
return this.router.streams.openOutgoing(streamId, windowBytes);
return this.router.streams.openOutgoing(streamId, windowBytes, options);
}
getProcessId() {
+297
View File
@@ -0,0 +1,297 @@
"use strict";
// Host-only transport core. No protocol, credential lookup, or plugin-supplied
// Node runs here. The registered service authenticates source-bound runtimes.
const net = require("node:net");
const tls = require("node:tls");
const dns = require("node:dns/promises");
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
const LIMITS = Object.freeze(require("./generated/plugin-contract.schema.json").$defs.ByteStreamLimits.const);
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamEndpoint.cjs");
function failure(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
class PluginByteStreamBroker {
constructor(options) {
this.permissionEngine = options.permissionEngine;
this.quotaManager = options.quotaManager;
this.audit = options.audit ?? (() => {});
this.lookup = options.lookup ?? dns.lookup;
this.connectSocket = options.connectSocket ?? ((params) => net.createConnection(params));
this.connectTls = options.connectTls ?? ((params) => tls.connect(params));
// Only a host test harness can opt a specific loopback endpoint in. The
// production default excludes all loopback/link-local control surfaces.
this.testLocalEndpoints = new Set(options.testLocalEndpoints ?? []);
this.isControlEndpoint = options.isControlEndpoint ?? (() => false);
this.connections = new Map();
this.closeListeners = new Set();
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => {
for (const state of this.connections.values()) {
if (state.pluginId === event.pluginId
&& (event.permission === undefined || event.permission === "network.stream")
&& (event.resource === undefined || event.resource === state.endpoint)) {
this.finish(state, failure("permissionDenied", "Byte stream permission was revoked"));
}
}
});
}
record(state, operation) {
// Audit only host-observed generic operations, never bytes or peer messages.
try { this.audit(state.pluginId, `network.stream.${operation}`, {
runtimeId: state.runtimeId, endpoint: state.endpoint,
handleId: state.id, bytesRead: state.bytesRead, bytesWritten: state.bytesWritten,
}, state); } catch { /* An audit sink cannot keep a revoked socket alive. */ }
}
finish(state, reason) {
if (state.closed) return;
state.closed = true;
clearTimeout(state.connectTimer);
clearTimeout(state.idleTimer);
clearTimeout(state.lifetimeTimer);
state.error = reason;
this.connections.delete(state.id);
state.controller.abort(reason ?? failure("cancelled", "Byte stream closed"));
state.socket?.destroy();
this.record(state, "close");
for (const listener of this.closeListeners) {
try { listener(state, reason); } catch {}
}
}
onDidClose(listener) {
this.closeListeners.add(listener);
return { dispose: () => this.closeListeners.delete(listener) };
}
touch(state) {
clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream idle timeout")), LIMITS.idleMs);
state.idleTimer.unref?.();
}
async active(state, context) {
this.assertCommit(state, context);
await context.assertActive();
this.assertCommit(state, context);
}
assertCommit(state, context) {
if (this.closed || state.closed || this.connections.get(state.id) !== state) {
throw state.error ?? failure("unavailable", "Byte stream is closed");
}
state.controller.signal.throwIfAborted();
context.signal?.throwIfAborted();
context.assertCommit?.();
}
async connect(endpoint, context) {
let target;
try { target = canonicalizeStreamEndpoint(endpoint); }
catch { throw failure("invalidArgument", "Byte stream endpoint is invalid"); }
if (this.closed) throw failure("unavailable", "Byte stream broker stopped");
if (!context.runtimeId || !context.pluginId || typeof context.assertActive !== "function") {
throw failure("unauthenticated", "Byte streams require a host runtime identity");
}
const count = [...this.connections.values()].filter((state) => state.runtimeId === context.runtimeId).length;
if (count >= LIMITS.connectionsPerRuntime || this.connections.size >= LIMITS.connectionsTotal) {
throw failure("resourceExhausted", "Byte stream connection limit exceeded");
}
const state = {
...target, id: randomUUID(), pluginId: context.pluginId, runtimeId: context.runtimeId,
controller: new AbortController(), bytesRead: 0, bytesWritten: 0,
sourceOwner: context.sourceOwner, sourceId: context.sourceId,
closed: false, reading: false, writing: false, socket: null,
};
this.connections.set(state.id, state);
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream connection cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
this.assertCommit(state, context);
await raceWithAbort(Promise.resolve(this.permissionEngine.authorize({ ...context, signal: state.controller.signal }, {
permission: "network.stream", resources: [target.endpoint],
reason: `Open byte stream to ${target.endpoint}`,
operationId: `network.stream:${target.endpoint}`,
validateBeforeGrant: () => this.assertCommit(state, context),
})), state.controller.signal);
await this.active(state, context);
state.connectTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream connect timeout")), LIMITS.connectMs);
const addresses = net.isIP(target.host)
? [{ address: target.host, family: net.isIP(target.host) }]
: await raceWithAbort(Promise.resolve(this.lookup(target.host, { all: true, verbatim: true })), state.controller.signal);
await this.active(state, context);
if (!Array.isArray(addresses) || addresses.length === 0 || addresses.length > 64) {
throw failure("unavailable", "Byte stream hostname could not be resolved");
}
// Validate the whole DNS answer and pin one literal address. No second
// lookup, redirect, proxy discovery, or reconnect can change the peer.
for (const peer of addresses) {
if (![4, 6].includes(peer.family) || net.isIP(peer.address) !== peer.family) {
throw failure("unavailable", "Byte stream resolver returned an invalid address");
}
const local = isLocalControlAddress(peer.address);
const allowedTestPeer = this.testLocalEndpoints.has(target.endpoint)
&& !this.isControlEndpoint(peer.address, target.port);
if ((local && !allowedTestPeer) || (!local && this.isControlEndpoint(peer.address, target.port))) {
throw failure("permissionDenied", "Byte streams cannot reach local or application control endpoints");
}
}
const peer = addresses[0];
const params = {
host: peer.address, port: target.port, family: peer.family,
highWaterMark: LIMITS.bufferBytes,
};
if (target.transport === "tls") {
Object.assign(params, {
rejectUnauthorized: true, minVersion: "TLSv1.2",
...(net.isIP(target.host) ? {} : { servername: target.host }),
checkServerIdentity: (_name, certificate) => tls.checkServerIdentity(target.host, certificate),
});
}
this.assertCommit(state, context);
const socket = target.transport === "tls" ? this.connectTls(params) : this.connectSocket(params);
state.socket = socket;
if (state.closed) { socket.destroy(); throw state.error ?? failure("cancelled", "Byte stream connection cancelled"); }
socket.on("error", () => this.finish(state, failure("unavailable", "Byte stream transport failed")));
// Preserve one EOF observation for a delayed consumer. This reservation
// still counts toward quotas and expires on the existing idle/life timer.
socket.on("close", () => {
if (!state.eof) this.finish(state, failure("unavailable", "Byte stream transport closed"));
});
socket.on("end", () => { state.eof = true; });
socket.pause();
await raceWithAbort(new Promise((resolve) => socket.once(
target.transport === "tls" ? "secureConnect" : "connect", resolve,
)), state.controller.signal);
await this.active(state, context);
if (target.transport === "tls" && !socket.authorized) {
throw failure("permissionDenied", "Byte stream TLS validation failed");
}
clearTimeout(state.connectTimer);
state.lifetimeTimer = setTimeout(() => this.finish(state,
failure("deadlineExceeded", "Byte stream lifetime exceeded")), LIMITS.lifetimeMs);
state.lifetimeTimer.unref?.();
this.touch(state);
this.assertCommit(state, context);
this.record(state, "open");
return Object.freeze({ handleId: state.id, endpoint: state.endpoint });
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
}
}
get(id, context) {
const state = this.connections.get(id);
if (!state || state.pluginId !== context.pluginId || state.runtimeId !== context.runtimeId
|| state.sourceOwner !== context.sourceOwner || state.sourceId !== context.sourceId) {
throw failure("permissionDenied", "Byte stream handle does not belong to this runtime");
}
return state;
}
charge(state, direction, size) {
if (state.bytesRead + state.bytesWritten + size > LIMITS.connectionBytes) {
throw failure("resourceExhausted", "Byte stream byte limit exceeded");
}
this.quotaManager?.chargeBytes(state.runtimeId, "network.stream", size);
state[direction] += size;
this.touch(state);
}
async read(id, context) {
const state = this.get(id, context);
if (state.reading) throw failure("failedPrecondition", "Byte stream has a pending read");
state.reading = true;
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream read cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
while (true) {
this.assertCommit(state, context);
const available = state.socket.readableLength;
const bytes = state.socket.read(Math.min(available || LIMITS.chunkBytes, LIMITS.chunkBytes));
if (bytes !== null) {
this.charge(state, "bytesRead", bytes.byteLength);
await this.active(state, context);
this.assertCommit(state, context);
return new Uint8Array(bytes);
}
if (state.eof || state.socket.readableEnded) { this.finish(state); return null; }
await raceWithAbort(new Promise((resolve) => {
const readable = () => {
state.socket.off("end", readable);
state.socket.off("readable", readable);
state.controller.signal.removeEventListener("abort", readable);
resolve();
};
state.socket.once("readable", readable);
state.socket.once("end", readable);
// Remove listeners even when cancellation wins the race.
state.controller.signal.addEventListener("abort", readable, { once: true });
}), state.controller.signal);
await this.active(state, context);
}
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
state.reading = false;
}
}
async write(id, bytes, context) {
const state = this.get(id, context);
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > LIMITS.chunkBytes) {
throw failure("invalidArgument", "Byte stream chunk size is invalid");
}
if (state.writing) throw failure("resourceExhausted", "Byte stream has a pending write");
state.writing = true;
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream write cancelled"));
context.signal?.addEventListener("abort", onAbort, { once: true });
try {
await this.active(state, context);
this.assertCommit(state, context);
this.charge(state, "bytesWritten", bytes.byteLength);
const owned = Buffer.from(bytes);
await raceWithAbort(new Promise((resolve, reject) => {
state.socket.write(owned, (error) => error ? reject(error) : resolve());
}), state.controller.signal);
await this.active(state, context);
} catch (error) {
this.finish(state, error);
throw error;
} finally {
context.signal?.removeEventListener("abort", onAbort);
state.writing = false;
}
}
close(id, context) { this.finish(this.get(id, context)); }
releaseRuntime(runtimeId) {
for (const state of this.connections.values()) {
if (state.runtimeId === runtimeId) this.finish(state,
failure("unavailable", "Byte stream runtime stopped"));
}
}
shutdown() {
this.closed = true;
for (const state of this.connections.values()) this.finish(state,
failure("unavailable", "Byte stream broker stopped"));
this.revocation.dispose();
}
}
module.exports = { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress };
+330
View File
@@ -0,0 +1,330 @@
"use strict";
const assert = require("node:assert/strict");
const { test } = require("node:test");
const net = require("node:net");
const tls = require("node:tls");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { once } = require("node:events");
const { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamBroker.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
function context(runtimeId = "runtime-a", controller = new AbortController()) {
return { pluginId: "test.stream", runtimeId, signal: controller.signal,
assertActive: async () => controller.signal.throwIfAborted() };
}
async function server(t, listener, options) {
const sockets = new Set();
const instance = options ? tls.createServer(options, listener) : net.createServer(listener);
instance.on("connection", (socket) => {
sockets.add(socket);
socket.on("error", () => {});
socket.on("close", () => sockets.delete(socket));
});
instance.on("tlsClientError", () => {});
instance.listen(0, "127.0.0.1");
await once(instance, "listening");
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => instance.close(resolve));
});
return instance;
}
function broker(t, endpoint, options = {}) {
const audit = [];
const requests = [];
let revoke;
const instance = new PluginByteStreamBroker({
permissionEngine: {
authorize: async (_ctx, request) => requests.push(request),
onDidRevoke(listener) { revoke = listener; return { dispose() {} }; },
},
audit: (...args) => audit.push(args),
testLocalEndpoints: endpoint ? [endpoint] : [],
...options,
});
t.after(() => instance.shutdown());
return { instance, requests, audit, revoke: (event) => revoke(event) };
}
test("stream endpoint canonicalization requires one exact host, transport and port", () => {
assert.equal(canonicalizeStreamEndpoint("tls://EXAMPLE.test:443").endpoint, "tls://example.test:443");
assert.equal(canonicalizeStreamEndpoint("tcp://[0:0:0:0:0:0:0:1]:3306").endpoint, "tcp://[::1]:3306");
for (const value of ["*", "tcp://*:3306", "tcp://x:0", "tcp://x:01", "tcp://x:65536", "udp://x:22",
"tcp://x", "tcp://x:22/", "tcp://user@x:22", "tcp://127.1:22", "tcp://x:22?x", "tcp://x.:22",
"tcp://[::1%25lo]:22", "tcp://x:22#x", "tcp://-x:22"]) {
assert.throws(() => canonicalizeStreamEndpoint(value), TypeError, value);
}
});
test("local/control address classification handles mapped IPv6 and alternate literals", () => {
for (const address of ["127.0.0.1", "127.9.8.7", "0.0.0.0", "169.254.169.254", "224.0.0.1",
"::1", "::", "::ffff:127.0.0.1", "::ffff:7f00:1", "fe80::1", "ff02::1", "2002:7f00:1::"]) {
assert.equal(isLocalControlAddress(address), true, address);
}
for (const address of ["192.168.1.2", "10.0.0.8", "203.0.113.1", "2001:db8::1", "fd00::1"]) {
assert.equal(isLocalControlAddress(address), false, address);
}
});
test("real TCP transport exchanges binary bytes and records no payloads", async (t) => {
const peer = await server(t, (socket) => socket.on("data", (bytes) => socket.write(bytes)));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, requests, audit } = broker(t, endpoint);
const ctx = context();
const handle = await instance.connect(endpoint, ctx);
const bytes = Uint8Array.from([0, 255, 1, 254, 19]);
await instance.write(handle.handleId, bytes, ctx);
assert.deepEqual(await instance.read(handle.handleId, ctx), bytes);
instance.close(handle.handleId, ctx);
assert.equal(instance.connections.size, 0);
assert.deepEqual(requests[0].resources, [endpoint]);
assert.equal(requests[0].permission, "network.stream");
assert.deepEqual(audit.map((entry) => entry[1]), ["network.stream.open", "network.stream.close"]);
assert.equal(audit[1][2].bytesRead, bytes.byteLength);
assert.equal(audit[1][2].bytesWritten, bytes.byteLength);
assert.equal(JSON.stringify(audit).includes("data"), false);
});
test("remote EOF returns the final bytes and releases the connection", async (t) => {
const peer = await server(t, (socket) => socket.end("synthetic final bytes"));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const chunks = [];
while (true) {
const chunk = await instance.read(handleId, ctx);
if (chunk === null) break;
chunks.push(Buffer.from(chunk));
}
assert.equal(Buffer.concat(chunks).toString(), "synthetic final bytes");
assert.equal(instance.connections.size, 0);
});
test("a delayed consumer can observe EOF after the real socket has closed", async (t) => {
const peer = await server(t, (socket) => socket.end("final"));
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, revoke } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const socket = instance.connections.get(handleId).socket;
const closed = once(socket, "close");
assert.equal(Buffer.from(await instance.read(handleId, ctx)).toString(), "final");
await closed;
await assert.rejects(instance.read(handleId, context("other-runtime")), { code: RPC_ERRORS.permissionDenied });
assert.equal(await instance.read(handleId, ctx), null);
assert.equal(instance.connections.size, 0);
const second = await instance.connect(endpoint, ctx);
const secondClosed = once(instance.connections.get(second.handleId).socket, "close");
await instance.read(second.handleId, ctx);
await secondClosed;
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
await assert.rejects(instance.read(second.handleId, ctx), { code: RPC_ERRORS.permissionDenied });
});
test("denial happens before DNS or a socket is created", async (t) => {
let lookedUp = false;
const { instance } = broker(t, null, {
permissionEngine: { authorize: async () => { throw new Error("denied"); }, onDidRevoke: () => ({ dispose() {} }) },
lookup: () => { lookedUp = true; },
});
await assert.rejects(instance.connect("tcp://example.test:22", context()), /denied/u);
assert.equal(lookedUp, false);
assert.equal(instance.connections.size, 0);
});
test("production rejects loopback, mixed DNS answers, and app control peers", async (t) => {
const endpoint = "tcp://example.test:22";
const { instance } = broker(t, null, {
lookup: async () => [{ address: "203.0.113.1", family: 4 }, { address: "127.0.0.1", family: 4 }],
});
await assert.rejects(instance.connect(endpoint, context()), { code: RPC_ERRORS.permissionDenied });
await assert.rejects(instance.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
const protectedBroker = broker(t, "tcp://127.0.0.1:22", { isControlEndpoint: () => true }).instance;
await assert.rejects(protectedBroker.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
});
test("runtime handles cannot cross runtime or plugin boundaries", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
for (const other of [context("runtime-b"), { ...ctx, pluginId: "test.other" }]) {
await assert.rejects(instance.read(handleId, other), { code: RPC_ERRORS.permissionDenied });
assert.throws(() => instance.close(handleId, other), { code: RPC_ERRORS.permissionDenied });
}
instance.close(handleId, ctx);
await assert.rejects(instance.write(handleId, Uint8Array.of(1), ctx), { code: RPC_ERRORS.permissionDenied });
});
test("revocation and runtime release cancel pending real reads", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance, revoke } = broker(t, endpoint);
const ctx = context();
let handle = await instance.connect(endpoint, ctx);
const reading = instance.read(handle.handleId, ctx);
await new Promise((resolve) => setImmediate(resolve));
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
await assert.rejects(reading, { code: RPC_ERRORS.permissionDenied });
assert.equal(instance.connections.size, 0);
handle = await instance.connect(endpoint, ctx);
const secondRead = instance.read(handle.handleId, ctx);
instance.releaseRuntime(ctx.runtimeId);
await assert.rejects(secondRead, { code: RPC_ERRORS.unavailable });
assert.equal(instance.connections.size, 0);
});
test("cancellation during DNS rejects promptly and ignores late resolution", async (t) => {
let resolveDns;
let dialed = false;
const { instance } = broker(t, null, {
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
connectSocket: () => { dialed = true; },
});
const controller = new AbortController();
const pending = instance.connect("tcp://example.test:22", context("runtime-a", controller));
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
resolveDns([{ address: "203.0.113.1", family: 4 }]);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(dialed, false);
assert.equal(instance.connections.size, 0);
});
test("cancellation after the last async guard cannot create a late real socket", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
let dialed = false;
const { instance } = broker(t, endpoint, { connectSocket(options) {
dialed = true;
const socket = net.createConnection(options);
t.after(() => socket.destroy());
return socket;
} });
const controller = new AbortController();
let guards = 0;
const ctx = context("runtime-a", controller);
ctx.assertActive = async () => {
controller.signal.throwIfAborted();
if (++guards === 3) queueMicrotask(() => queueMicrotask(() => controller.abort()));
};
await assert.rejects(instance.connect(endpoint, ctx), { code: RPC_ERRORS.cancelled });
assert.equal(dialed, false);
assert.equal(instance.connections.size, 0);
});
test("connect and idle deadlines close resources and reject pending operations", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
t.mock.timers.enable({ apis: ["setTimeout"] });
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const reading = instance.read(handleId, ctx);
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(LIMITS.idleMs);
await assert.rejects(reading, { code: RPC_ERRORS.deadlineExceeded });
assert.equal(instance.connections.size, 0);
let resolveDns;
let dialed = false;
const slow = broker(t, null, {
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
connectSocket: () => { dialed = true; },
}).instance;
const pending = slow.connect("tcp://example.test:22", ctx);
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(LIMITS.connectMs);
await assert.rejects(pending, { code: RPC_ERRORS.deadlineExceeded });
resolveDns([{ address: "203.0.113.1", family: 4 }]);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(dialed, false);
assert.equal(slow.connections.size, 0);
});
test("concurrent operations and oversized writes cannot grow an unbounded queue", async (t) => {
const peer = await server(t, () => {});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint);
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
const pending = instance.read(handleId, ctx);
await assert.rejects(instance.read(handleId, ctx), { code: RPC_ERRORS.failedPrecondition });
await assert.rejects(instance.write(handleId, new Uint8Array(LIMITS.chunkBytes + 1), ctx), { code: RPC_ERRORS.invalidArgument });
const writing = instance.write(handleId, Uint8Array.of(1), ctx);
await assert.rejects(instance.write(handleId, Uint8Array.of(2), ctx), { code: RPC_ERRORS.resourceExhausted });
await writing;
instance.close(handleId, ctx);
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
});
test("connection reservations bound concurrent DNS operations", async (t) => {
const { instance } = broker(t, null, { lookup: () => new Promise(() => {}) });
const ctx = context();
const pending = Array.from({ length: LIMITS.connectionsPerRuntime }, () => instance.connect("tcp://example.test:22", ctx));
const results = Promise.allSettled(pending);
await assert.rejects(instance.connect("tcp://example.test:22", ctx), { code: RPC_ERRORS.resourceExhausted });
instance.releaseRuntime(ctx.runtimeId);
assert.ok((await results).every((result) => result.status === "rejected"));
assert.equal(instance.connections.size, 0);
});
test("real unread TCP output stays bounded and byte quota closes the socket", async (t) => {
const peer = await server(t, (socket) => {
const flood = () => { while (!socket.destroyed && socket.write(Buffer.alloc(LIMITS.chunkBytes))) {} };
socket.on("drain", flood);
flood();
});
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
const { instance } = broker(t, endpoint, { quotaManager: {
chargeBytes() { throw new Error("quota reached"); },
} });
const ctx = context();
const { handleId } = await instance.connect(endpoint, ctx);
await new Promise((resolve) => setTimeout(resolve, 30));
const state = instance.connections.get(handleId);
assert.ok(state.socket.readableLength <= LIMITS.bufferBytes + LIMITS.chunkBytes);
await assert.rejects(instance.read(handleId, ctx), /quota reached/u);
assert.equal(instance.connections.size, 0);
});
test("real TLS validates trust and the original hostname against a pinned IP", async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-stream-tls-test-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const keyPath = path.join(directory, "test.key");
const certPath = path.join(directory, "test.crt");
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath,
"-out", certPath, "-days", "1", "-subj", "/CN=stream-fixture.test", "-addext", "subjectAltName=DNS:stream-fixture.test"],
{ stdio: "ignore" });
const cert = fs.readFileSync(certPath);
const peer = await server(t, (socket) => socket.on("data", (data) => socket.write(data)), {
key: fs.readFileSync(keyPath), cert,
});
const endpoint = `tls://stream-fixture.test:${peer.address().port}`;
const lookup = async () => [{ address: "127.0.0.1", family: 4 }];
const untrusted = broker(t, endpoint, { lookup }).instance;
await assert.rejects(untrusted.connect(endpoint, context()), { code: RPC_ERRORS.unavailable });
let dialOptions;
const trusted = broker(t, endpoint, { lookup,
connectTls: (options) => { dialOptions = options; return tls.connect({ ...options, ca: cert }); },
}).instance;
const ctx = context();
const { handleId } = await trusted.connect(endpoint, ctx);
assert.equal(dialOptions.host, "127.0.0.1");
assert.equal(dialOptions.servername, "stream-fixture.test");
assert.equal(dialOptions.rejectUnauthorized, true);
await trusted.write(handleId, Uint8Array.of(42), ctx);
assert.deepEqual(await trusted.read(handleId, ctx), Uint8Array.of(42));
trusted.close(handleId, ctx);
const wrongEndpoint = `tls://wrong-fixture.test:${peer.address().port}`;
const wrong = broker(t, wrongEndpoint, { lookup, connectTls: (options) => tls.connect({ ...options, ca: cert }) }).instance;
await assert.rejects(wrong.connect(wrongEndpoint, context()), { code: RPC_ERRORS.unavailable });
});
+53
View File
@@ -0,0 +1,53 @@
"use strict";
const net = require("node:net");
function failure(_code, message) { return new TypeError(message); }
function canonicalizeStreamEndpoint(value) {
if (typeof value !== "string" || value.length > 512) {
throw failure("invalidArgument", "Byte stream endpoint is invalid");
}
// URL's permissive non-special scheme parser accepts paths, userinfo, and
// alternate IPv4 spellings. Require a complete unambiguous authority instead.
const match = /^(tcp|tls):\/\/(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$/u.exec(value);
if (!match) throw failure("invalidArgument", "Byte streams require an exact TCP or TLS host and port");
let host = match[2].toLowerCase();
const port = Number(match[3]);
if (port < 1 || port > 65535 || String(port) !== match[3]) {
throw failure("invalidArgument", "Byte stream port is invalid");
}
if (host.startsWith("[")) {
host = host.slice(1, -1);
if (net.isIP(host) !== 6) throw failure("invalidArgument", "Byte stream IPv6 address is invalid");
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
} else if (!net.isIP(host)) {
if (host.length > 253 || /^[0-9.]+$/u.test(host) || host.split(".").some((label) => (
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)
))) throw failure("invalidArgument", "Byte stream hostname is invalid");
}
return Object.freeze({
endpoint: `${match[1]}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`,
transport: match[1], host, port,
});
}
function isLocalControlAddress(address) {
if (net.isIP(address) === 4) {
const [a, b] = address.split(".").map(Number);
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
}
if (net.isIP(address) !== 6) return true;
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
if (canonical.startsWith("::ffff:")) {
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
}
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
return canonical.startsWith("::") || canonical.startsWith("ff")
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|| canonical.startsWith("2001:0:");
}
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
@@ -0,0 +1,132 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { once } = require("node:events");
const { createPluginHostService } = require("./hostService.cjs");
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
test("installed browser entrypoint uses registered SDK/RPC streams, denial and revoke", { timeout: 15_000 }, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-byte-entry-"));
let service;
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {});
socket.on("data", (bytes) => socket.write(bytes));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(async () => {
await service?.manager.shutdown();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
const packageRoot = path.join(root, "source");
await fs.mkdir(packageRoot);
const id = "com.netcatty.byte-entry-test";
const manifest = {
manifestVersion: 1, id, name: "byte-entry-test", version: "0.1.0", publisher: "netcatty",
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal.1 <0.2.0" },
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
activationEvents: [`onCommand:${id}.run`],
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
contributes: { commands: ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
};
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(packageRoot, "browser.js"), `
export default { activate(context) {
let connection;
for (const name of ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
return context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
}
if (name === "ai") {
const chat = await context.ai.openChat({});
try {
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic prompt" });
const events = [];
while (true) { const event = await context.ai.readTurn({ turnHandle: turn.turnHandle }); events.push(event); if (event.kind === "end") return events; }
} finally { await context.ai.closeChat({ chatHandle: chat.chatHandle }); }
}
if (name === "cancel") { await connection?.close(); return true; }
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
if (name === "pending") return { connected: true };
if (name === "cancelRead") {
const reading = connection.readable.read();
connection.readable.cancel();
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
}
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
} finally { await connection.close(); await connection.close(); }
}));
} };`);
let allow = false;
const decisions = [];
service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
electron: {}, env: {},
pluginAIHostAdapter: {
assertActive: (context) => context.assertActive(),
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: async () => true,
invoke: async () => (async function* () { yield { kind: "text", text: "synthetic model result" }; yield { kind: "usage", inputTokens: 2, outputTokens: 3, totalTokens: 5 }; })(),
}),
listSelected: async () => [{ key: "fixture", scope: "tab", title: "Synthetic tab", version: "v1",
readText: async () => "synthetic selected text", isCurrent: async () => true }],
reviewText: async () => true,
},
requestPermissionDecision: async (request) => {
decisions.push(request);
return request.permission === "network.stream" && !allow
? { requestId: request.requestId, decision: "deny" }
: { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
},
});
// Explicit test-only loopback fixture injection. The production constructor
// exposes no option that forwards these overrides to the broker.
service.byteStreamService.broker.testLocalEndpoints.add(endpoint);
service.byteStreamService.broker.isControlEndpoint = () => false;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "fixture.ncpkg");
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
await assert.rejects(run("run"), /denied/u);
assert.equal(service.byteStreamService.broker.connections.size, 0);
allow = true;
assert.deepEqual(await run("run"), [0, 255, 42]);
assert.equal(await run("cancelRead"), true);
assert.equal(service.byteStreamService.records.size, 0);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
await assert.rejects(run("spoof"), /contract/u);
assert.deepEqual(await run("ai"), [
{ kind: "text", sequence: 0, text: "synthetic model result" },
{ kind: "usage", sequence: 1, inputTokens: 2, outputTokens: 3, totalTokens: 5 },
{ kind: "end", sequence: 2 },
]);
assert.equal((await run("selected")).text, "synthetic selected text");
await assert.rejects(run("aiSpoof"), /invalid/u);
assert.deepEqual(await run("pending"), { connected: true });
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
await run("cancel");
await run("pending");
await service.manager.setEnabled(id, false);
assert.equal(service.byteStreamService.broker.connections.size, 0);
assert.equal(service.byteStreamService.closedHandles.size, 0);
});
+207
View File
@@ -0,0 +1,207 @@
"use strict";
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const FEATURE = require("./generated/plugin-contract.schema.json").$defs.ByteStreamFeature.const;
function denied() { return new PluginRpcError(RPC_ERRORS.permissionDenied, "Byte stream runtime or source binding is invalid"); }
function validator(name) {
const validate = createDefinitionValidator(name);
return (params) => {
if (!validate(params)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Byte stream parameters violate the contract");
return params;
};
}
class PluginByteStreamService {
constructor(options) {
this.database = options.database;
this.runtimeSupervisor = options.runtimeSupervisor;
this.records = new Map();
this.closedHandles = new Map();
this.closed = false;
this.broker = new PluginByteStreamBroker({
permissionEngine: options.permissionEngine,
quotaManager: options.quotaManager,
// The public host service never forwards transport fixture overrides.
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
audit: (pluginId, operation, fields, state) => {
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
},
});
this.brokerClose = this.broker.onDidClose((state, reason) => {
if (reason) this.closeRecord(this.records.get(state.id), reason);
});
}
sourceMatches(identity) {
const active = this.database.getActivePlugin(identity.pluginId);
const retained = this.database.getRetainedPluginSource(identity.pluginId);
return Boolean(active?.enabled && identity.sourceOwner && identity.sourceId
&& active.source?.id === identity.sourceId && retained?.id === identity.sourceId
&& this.database.privateOwner(identity.pluginId) === identity.sourceOwner
&& this.database.publicPluginId(identity.sourceOwner) === identity.pluginId);
}
async authenticate(context) {
await context.assertActive();
const assertCommit = () => {
context.signal?.throwIfAborted();
const current = this.runtimeSupervisor.getRuntimeIdentity(context.pluginId);
if (this.closed || !current || current.runtimeId !== context.runtimeId
|| current.pluginVersion !== context.pluginVersion
|| current.securityPrincipal !== context.securityPrincipal
|| current.sourceOwner !== context.sourceOwner || current.sourceId !== context.sourceId
|| !this.sourceMatches(context)) throw denied();
};
assertCommit();
if (!context.manifest?.features?.required?.includes(FEATURE)) {
throw new PluginRpcError(RPC_ERRORS.unsupported, "Byte stream feature was not negotiated");
}
return Object.freeze({ ...context, assertCommit,
assertActive: async () => { await context.assertActive(); assertCommit(); },
});
}
register(registry) {
// These routes own authorization: connect authenticates before the broker
// obtains its exact endpoint grant; attach/close prove handle ownership.
// The registry public flag skips only the generic permission middleware.
// Runtime authentication and quota middleware still apply to every call.
registry.registerRequest("network.stream.connect", async ({ endpoint }, caller) => {
const context = await this.authenticate(caller);
const handle = await this.broker.connect(endpoint, context);
try {
context.assertCommit();
this.broker.get(handle.handleId, context);
const record = {
...handle, context,
inputStreamId: `byte-in-${randomUUID()}`, outputStreamId: `byte-out-${randomUUID()}`,
attached: false, closing: false, input: null, output: null,
};
this.records.set(handle.handleId, record);
record.abort = () => this.closeRecord(record);
context.signal?.addEventListener("abort", record.abort, { once: true });
return { ...handle, inputStreamId: record.inputStreamId,
outputStreamId: record.outputStreamId, windowBytes: LIMITS.bufferBytes };
} catch (error) {
const state = this.broker.connections.get(handle.handleId);
if (state) this.broker.finish(state, error);
throw error;
}
}, { validateParams: validator("ByteStreamConnectRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
registry.registerRequest("network.stream.attach", async ({ handleId }, caller) => {
const context = await this.authenticate(caller);
const record = this.get(handleId, context);
if (record.attached) throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Byte stream is already attached");
record.attached = true;
try {
record.output = await this.runtimeSupervisor.openStream(context.pluginId, record.outputStreamId, LIMITS.bufferBytes, {
expectedIdentity: context,
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
});
context.assertCommit();
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
context.signal?.addEventListener("abort", record.abort, { once: true });
// A cancelled attachment cannot leave a producer running after its RPC.
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
return { attached: true };
} catch (error) {
const alreadyClosing = record.closing;
this.closeRecord(record, error);
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
throw error;
}
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
registry.registerRequest("network.stream.close", async ({ handleId }, caller) => {
const context = await this.authenticate(caller);
if (!this.wasClosed(handleId, context)) this.closeRecord(this.get(handleId, context));
return null;
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
this.streamRegistration = registry.registerIncomingStream(async (stream, caller) => {
const record = [...this.records.values()].find((item) => item.inputStreamId === stream.streamId);
if (!record) return false;
const context = await this.authenticate(caller);
this.get(record.handleId, context);
if (record.input || stream.windowBytes !== LIMITS.bufferBytes) throw denied();
record.input = stream;
stream.bind({
onChunk: async (chunk, release) => {
try {
if (chunk.encoding !== "binary" || !(chunk.bytes instanceof Uint8Array)) throw denied();
await this.broker.write(record.handleId, chunk.bytes, record.context);
record.context.assertCommit();
release();
} catch (error) { this.closeRecord(record, error); }
},
onClose: () => this.closeRecord(record),
});
return true;
});
}
get(handleId, context) {
const record = this.records.get(handleId);
if (!record || record.closing || record.context.runtimeId !== context.runtimeId
|| record.context.pluginId !== context.pluginId || record.context.sourceOwner !== context.sourceOwner
|| record.context.sourceId !== context.sourceId) throw denied();
return record;
}
wasClosed(handleId, context) {
const owner = this.closedHandles.get(handleId);
return owner && owner.runtimeId === context.runtimeId && owner.pluginId === context.pluginId
&& owner.sourceOwner === context.sourceOwner && owner.sourceId === context.sourceId;
}
async pump(record) {
try {
while (!record.closing) {
const bytes = await this.broker.read(record.handleId, record.context);
if (record.closing) return;
record.context.assertCommit();
if (bytes === null) { record.output.end(); break; }
await record.output.write(bytes);
}
this.closeRecord(record);
} catch (error) { this.closeRecord(record, error); }
}
closeRecord(record, reason) {
if (!record || record.closing) return;
record.closing = true;
record.context.signal?.removeEventListener("abort", record.abort);
this.records.delete(record.handleId);
const { pluginId, runtimeId, sourceOwner, sourceId } = record.context;
this.closedHandles.set(record.handleId, { pluginId, runtimeId, sourceOwner, sourceId });
while (this.closedHandles.size > 256) this.closedHandles.delete(this.closedHandles.keys().next().value);
const state = this.broker.connections.get(record.handleId);
if (state) this.broker.finish(state, reason);
try { record.output?.cancel(); } catch {}
try { void Promise.resolve(record.input?.cancel()).catch(() => {}); } catch {}
}
releaseRuntime(runtimeId) {
for (const record of this.records.values()) {
if (record.context.runtimeId === runtimeId) this.closeRecord(record);
}
this.broker.releaseRuntime(runtimeId);
for (const [id, owner] of this.closedHandles) {
if (owner.runtimeId === runtimeId) this.closedHandles.delete(id);
}
}
shutdown() {
this.closed = true;
for (const record of this.records.values()) this.closeRecord(record);
this.broker.shutdown();
this.brokerClose.dispose();
this.streamRegistration?.dispose();
this.closedHandles.clear();
}
}
module.exports = { PluginByteStreamService, FEATURE };
@@ -0,0 +1,97 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const { once } = require("node:events");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
async function fixture(t, options = {}) {
let peer;
const sockets = new Set();
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
server.listen(0, "127.0.0.1");
await once(server, "listening");
let service;
t.after(async () => {
service?.shutdown();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
let source = "first";
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
manifest: { features: { required: ["network.byte-streams"] } },
};
const audit = [];
const writes = [];
const registry = new PluginHostRpcRegistry();
service = new PluginByteStreamService({
database: {
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
getRetainedPluginSource: () => ({ id: source }),
privateOwner: () => `opaque-${source}`,
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
recordSecurityAudit: (...args) => audit.push(args),
},
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
})) },
});
service.broker.testLocalEndpoints.add(endpoint);
service.broker.isControlEndpoint = () => false;
service.register(registry);
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
}
test("source changes before grant publication cannot create a socket or grant", async (t) => {
let approve;
let grants = 0;
const f = await fixture(t, { authorize: async (_context, descriptor) => {
await new Promise((resolve) => { approve = resolve; });
descriptor.validateBeforeGrant();
grants++;
} });
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
while (!approve) await new Promise((resolve) => setImmediate(resolve));
f.swap(); approve();
await assert.rejects(pending, /source binding/u);
assert.equal(grants, 0);
assert.equal(f.peer(), undefined);
assert.equal(f.service.broker.connections.size, 0);
assert.equal(f.audit.length, 0);
});
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
const f = await fixture(t);
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
await f.call("network.stream.attach", { handleId: descriptor.handleId });
f.swap();
f.peer().write("synthetic old-source bytes");
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.writes.length, 0);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
});
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
let opened;
let cancelled = 0;
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
while (!opened) await new Promise((resolve) => setImmediate(resolve));
f.service.releaseRuntime("runtime-one");
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
await assert.rejects(attaching, /cancelled/u);
assert.equal(cancelled, 1);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
});
@@ -0,0 +1,165 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const SCOPES = new Set(["workspace", "tab", "session", "host", "note"]);
const LIMITS = Object.freeze({ handles: 128, perRuntime: 8, contentBytes: 16_384, ttlMs: 300_000, readMs: 5_000 });
// Internal host authority. Only trusted selection/review UI may call share().
// A menu id, plugin supplied entity id or global inventory is never accepted.
class SelectedContextAuthority {
constructor({ resolveDataOwner, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function") throw new TypeError("Host data-owner resolver required");
this.resolveDataOwner = resolveDataOwner;
this.clock = clock;
this.limits = Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
const value = limits[key] ?? max;
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
return [key, value];
})));
this.handles = new Map();
this.closed = false;
this.listeners = new Set();
}
async binding(context) {
context.signal?.throwIfAborted();
await context.assertActive();
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) {
throw fail("permissionDenied", "Host data owner is unavailable");
}
context.signal?.throwIfAborted();
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #check(entry, context) {
const binding = await this.binding(context);
if (this.closed || this.handles.get(entry.handle) !== entry || entry.expiresAt <= this.clock()
|| binding.owner !== entry.owner || binding.runtimeId !== entry.runtimeId
|| binding.pluginVersion !== entry.pluginVersion) throw fail("permissionDenied", "Selected context is unavailable");
context.signal?.throwIfAborted();
// The trusted reader checks the current window/resource generation and owner.
// Recheck our map after awaiting it: close/revoke/version changes can race.
const current = await entry.isCurrent();
const currentOwner = await this.resolveDataOwner(context);
await context.assertActive();
context.signal?.throwIfAborted();
if (!current || currentOwner !== entry.owner || this.handles.get(entry.handle) !== entry || this.closed || entry.expiresAt <= this.clock()) {
throw fail("permissionDenied", "Selected context changed");
}
}
#prune() {
for (const [handle, entry] of this.handles) if (entry.expiresAt <= this.clock()) this.release(handle);
}
async share(selection, context) {
if (this.closed) throw fail("unavailable", "Selected context is closed");
assertPluginJsonValue({ scope: selection.scope, title: selection.title, version: selection.version }, { maxBytes: 1024 });
if (!SCOPES.has(selection.scope) || typeof selection.title !== "string" || selection.title.length > 160
|| typeof selection.version !== "string" || !selection.version || selection.version.length > 128
|| typeof selection.readText !== "function" || typeof selection.isCurrent !== "function") {
throw fail("invalidArgument", "Invalid selected context");
}
// This API accepts a curated text reader, never a Host/Vault DTO. Passwords,
// private keys and arbitrary entity fields have no place in the descriptor.
if (Object.keys(selection).some((key) => !["scope", "title", "version", "readText", "isCurrent"].includes(key))) {
throw fail("invalidArgument", "Selected context contains unsupported fields");
}
const binding = await this.binding(context);
if (!await selection.isCurrent()) throw fail("permissionDenied", "Selection is no longer current");
await context.assertActive();
this.#prune();
if (this.closed || this.handles.size >= this.limits.handles
|| [...this.handles.values()].filter((entry) => entry.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Selected context handle limit reached");
}
const entry = { ...binding, ...selection, handle: randomUUID(), expiresAt: this.clock() + this.limits.ttlMs };
this.handles.set(entry.handle, entry);
return this.#summary(entry);
}
#summary(entry) {
return { handle: entry.handle, scope: entry.scope, title: entry.title, version: entry.version, expiresAt: entry.expiresAt };
}
async summaries(context) {
const binding = await this.binding(context);
this.#prune();
const summaries = [];
for (const entry of [...this.handles.values()]) {
if (entry.owner !== binding.owner || entry.runtimeId !== binding.runtimeId || entry.pluginVersion !== binding.pluginVersion) continue;
try { await this.#check(entry, context); summaries.push(this.#summary(entry)); }
catch (error) { if (context.signal?.aborted) throw error; this.release(entry.handle); }
}
await context.assertActive();
return summaries;
}
async #read({ handle, version }, context) {
const entry = this.handles.get(handle);
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
await this.#check(entry, context);
let text;
try { text = await entry.readText({ signal: context.signal, maxBytes: this.limits.contentBytes }); }
catch { throw fail("unavailable", "Selected context reader failed"); }
await this.#check(entry, context);
if (typeof text !== "string" || text.includes("\0") || Buffer.byteLength(text) > this.limits.contentBytes) {
throw fail("resourceExhausted", "Selected context exceeds its text limit");
}
// Do not disclose private key material even if a curated text reader errs.
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text)) {
throw fail("permissionDenied", "Private key material cannot be shared");
}
return { ...this.#summary(entry), text };
}
async read(reference, context) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(fail("deadlineExceeded", "Selected context read timed out")), this.limits.readMs);
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
let abort;
try {
signal.throwIfAborted();
return await new Promise((resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
this.#read(reference, { ...context, signal }).then(resolve, reject);
});
} finally {
clearTimeout(timeout);
if (abort) signal.removeEventListener("abort", abort);
}
}
async assertCurrent({ handle, version }, context) {
const entry = this.handles.get(handle);
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
await this.#check(entry, context);
}
onDidInvalidate(listener) {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
release(handle) {
if (!this.handles.delete(handle)) return;
for (const listener of [...this.listeners]) {
// Revocation must finish even when an observer fails. Never log selected
// text or let an observer exception retain another runtime handle.
try { listener(handle); } catch {}
}
}
releaseRuntime(runtimeId) {
for (const [handle, entry] of this.handles) if (entry.runtimeId === runtimeId) this.release(handle);
}
releaseOwner(owner) {
for (const [handle, entry] of this.handles) if (entry.owner === owner) this.release(handle);
}
shutdown() { this.closed = true; for (const handle of [...this.handles.keys()]) this.release(handle); this.listeners.clear(); }
}
module.exports = { SelectedContextAuthority, SELECTED_CONTEXT_LIMITS: LIMITS };
@@ -0,0 +1,87 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { SelectedContextAuthority } = require("./selectedContextAuthority.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
function fixture() {
const owner = {}; let currentOwner = owner; let current = true; let version = "1"; let reads = 0; let now = 0;
const context = { pluginId: "same", pluginVersion: "1", runtimeId: "r1", assertActive: async () => {} };
const authority = new SelectedContextAuthority({ resolveDataOwner: async () => currentOwner, clock: () => now });
const selection = { scope: "note", title: "Synthetic Note", version: "1", isCurrent: () => current && version === "1",
readText: () => { reads++; return "synthetic content"; } };
return { owner, context, authority, selection, reads: () => reads, ownerTo: (v) => { currentOwner = v; },
versionTo: (v) => { version = v; }, currentTo: (v) => { current = v; }, timeTo: (v) => { now = v; } };
}
test("summaries enumerate only explicitly shared selection and never read content", async () => {
const f = fixture(); assert.deepEqual(await f.authority.summaries(f.context), []);
const note = await f.authority.share(f.selection, f.context);
assert.deepEqual(await f.authority.summaries(f.context), [note]); assert.equal(f.reads(), 0);
assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
assert.equal(f.reads(), 1);
assert.deepEqual(await f.authority.summaries({ ...f.context, runtimeId: "r2" }), []);
f.ownerTo({}); assert.deepEqual(await f.authority.summaries(f.context), []);
await assert.rejects(f.authority.read(note, f.context));
f.ownerTo(f.owner); assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
});
test("scope, owner, expected version and resource lifetime are enforced before read", async () => {
for (const action of ["version", "owner", "closed", "expiry", "release", "runtime"]) {
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
if (action === "version") f.versionTo("2");
if (action === "owner") f.currentTo(false);
if (action === "closed") f.authority.shutdown();
if (action === "expiry") f.timeTo(300_001);
if (action === "release") f.authority.release(note.handle);
if (action === "runtime") f.authority.releaseRuntime("r1");
await assert.rejects(f.authority.read(note, f.context)); assert.equal(f.reads(), 0);
}
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
await assert.rejects(f.authority.read({ ...note, version: "2" }, f.context));
await assert.rejects(f.authority.read({ handle: "menu-id", version: "1" }, f.context));
await assert.rejects(f.authority.share({ ...f.selection, scope: "all" }, f.context));
});
test("revocation/version/owner replacement while reader awaits suppresses late content", async () => {
for (const action of ["release", "version", "owner", "source"]) {
const f = fixture(); const pending = deferred(); let started;
const entered = new Promise((r) => { started = r; });
const note = await f.authority.share({ ...f.selection, readText: () => { started(); return pending.promise; } }, f.context);
const reading = f.authority.read(note, f.context); await entered;
if (action === "release") f.authority.release(note.handle);
if (action === "version") f.versionTo("2");
if (action === "owner") f.currentTo(false);
if (action === "source") f.ownerTo({});
pending.resolve("late secret text"); await assert.rejects(reading);
}
});
test("unknown Host fields, private keys and oversized content are excluded", async () => {
const f = fixture();
await assert.rejects(f.authority.share({ ...f.selection, password: "forbidden" }, f.context));
await assert.rejects(f.authority.share({ ...f.selection, privateKey: "forbidden" }, f.context));
for (const text of ["-----BEGIN OPENSSH PRIVATE KEY-----\nforbidden", "X".repeat(16_385)]) {
const note = await f.authority.share({ ...f.selection, readText: () => text }, f.context);
await assert.rejects(f.authority.read(note, f.context));
}
});
test("content reader has a deadline and respects request cancellation", async () => {
const owner = {}; const context = { runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
const authority = new SelectedContextAuthority({ resolveDataOwner: () => owner, limits: { readMs: 20 } });
const note = await authority.share({ scope: "note", title: "Synthetic", version: "1", isCurrent: () => true,
readText: () => new Promise(() => {}) }, context);
await assert.rejects(authority.read(note, context), /timed out/);
const controller = new AbortController(); const reading = authority.read(note, { ...context, signal: controller.signal });
controller.abort(); await assert.rejects(reading); authority.shutdown();
});
test("one failing invalidation listener cannot interrupt other listeners or handle cleanup", async () => {
const f = fixture(); const first = await f.authority.share(f.selection, f.context); const second = await f.authority.share(f.selection, f.context);
const notified = [];
f.authority.onDidInvalidate(() => { throw Error("listener failed"); });
f.authority.onDidInvalidate((handle) => notified.push(handle));
assert.doesNotThrow(() => f.authority.releaseRuntime("r1"));
assert.equal(f.authority.handles.size, 0);
assert.deepEqual(notified, [first.handle, second.handle]);
});
-26
View File
@@ -187,8 +187,6 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
@@ -263,33 +261,10 @@ class PluginFilesystemBroker {
}
}
async #assertWritable(canonical) {
const variants = async (entry) => {
const lexical = path.resolve(entry);
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
};
for (const file of this.protectedFiles) {
if ((await variants(file)).includes(canonical)) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
for (const directory of this.protectedDirectories) {
for (const parent of await variants(directory)) {
const relative = path.relative(parent, canonical);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
assertAuthorizedPath(context, "filesystem.write", value.path);
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
await this.#assertWritable(canonical);
assertAuthorizedPath(context, "filesystem.write", canonical);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
await context.assertActive();
@@ -303,7 +278,6 @@ class PluginFilesystemBroker {
]);
assertSameOpenedFile(openedStats, stats);
assertSameOpenedFile(openedStats, pathStats);
await this.#assertWritable(canonical);
await context.assertActive();
if (value.overwrite) await handle.truncate(0);
await handle.writeFile(value.bytes);
@@ -390,61 +390,3 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
(error) => error.code === RPC_ERRORS.resourceExhausted,
);
});
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
const root = createRoot(t);
const directory = path.join(root, "host-logs");
await fsp.mkdir(directory);
const audit = path.join(directory, "runtime.jsonl");
const preferences = path.join(root, "local-logging.json");
await fsp.writeFile(audit, "host audit");
await fsp.writeFile(preferences, "host preferences");
const alias = path.join(root, "alias");
await fsp.symlink(audit, alias);
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
for (const target of [audit, preferences, alias]) {
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
}
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
});
test("host audit protection resolves a symlinked userData directory", async (t) => {
const root = createRoot(t);
const actual = path.join(root, "actual-profile");
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
const profileAlias = path.join(root, "profile-alias");
await fsp.symlink(actual, profileAlias);
const target = path.join(actual, "logs", "runtime.jsonl");
await fsp.writeFile(target, "host audit");
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
});
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
const root = createRoot(t);
const logs = path.join(root, "host-logs");
const target = path.join(root, "synthetic.txt");
await fsp.mkdir(logs);
await fsp.writeFile(target, "prior");
let checks = 0;
let active = true;
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
...fsp, async realpath(requested) {
const canonical = await fsp.realpath(requested);
if (requested === logs && ++checks === 3) active = false;
return canonical;
},
} });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
const context = { ...runtimeContext(authorization), async assertActive() {
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
} };
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "prior");
});
@@ -0,0 +1,45 @@
"use strict";
// Test transport harness only. This is Node, not an OS/browser sandbox test.
// Production continues to use the sandboxed BrowserPluginRuntime unchanged.
const { MessageChannel } = require("node:worker_threads");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
const { PluginRpcRouter } = require("../rpcRouter.cjs");
function inProcessBrowserRuntime(options) {
let host;
let peer;
let port;
return {
async start(config) {
const { startPluginRuntime } = await import("../runtime/runtimePeer.mjs");
const { port1, port2 } = new MessageChannel();
port = port1;
host = new PluginRpcRouter({ pluginId: config.pluginId,
send: (message, transfer) => port1.postMessage(message, transfer),
requestHandlers: options.requestHandlers, notificationHandlers: options.notificationHandlers,
onIncomingStream: options.onIncomingStream, onBeforeMessage: options.onBeforeMessage,
onProtocolError: options.onProtocolError,
});
port1.on("message", (message) => { void host.accept(message); });
peer = await startPluginRuntime({ port: port2, config,
loadPlugin: () => import(pathToFileURL(path.join(options.packageRoot, options.plugin.manifest.main.browser)).href),
});
const initialized = await host.request("plugin.initialize", {
netcattyVersion: config.netcattyVersion, apiVersion: config.apiVersion, supportedFeatures: config.supportedFeatures,
});
await host.request("plugin.activate", {});
return initialized;
},
request: (...args) => host.request(...args),
notify: (...args) => host.notify(...args),
openStream: (...args) => host.streams.openOutgoing(...args),
async stop() {
try { if (host) await host.request("plugin.deactivate", {}); }
finally { await peer?.dispose(); host?.close(); port?.close(); }
},
};
}
module.exports = { inProcessBrowserRuntime };
@@ -1,38 +0,0 @@
import { definePlugin } from "@netcatty/plugin-sdk";
export default definePlugin({
activate(context) {
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.example.local-logging-fixture.${suffix}`, handler,
));
register("emit", () => {
context.logger.info("Synthetic diagnostic for local logging QA", {
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
prompt: "SYNTHETIC-PROMPT-BODY",
Notes: "SYNTHETIC-NOTE-BODY",
SQL: "SYNTHETIC-SQL-BODY",
results: "SYNTHETIC-RESULT-BODY",
terminalContent: "SYNTHETIC-TERMINAL-BODY",
pluginId: "synthetic-spoofed-plugin",
sourceId: "official",
runtimeId: "synthetic-spoofed-runtime",
});
return { ok: true, synthetic: true };
});
register("noise", () => {
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
return { ok: true, synthetic: true, emitted: 400 };
});
register("denied", async () => {
// No network permission is declared. The host must reject before any
// network I/O or approval prompt; no body or credential is ever supplied.
try {
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
throw new Error("Synthetic undeclared request unexpectedly succeeded");
} catch (error) {
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
return { denied: true, synthetic: true };
}
});
},
});
@@ -1,29 +0,0 @@
{
"manifestVersion": 1,
"id": "com.example.local-logging-fixture",
"name": "local-logging-fixture",
"displayName": "Synthetic local logging fixture",
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
"version": "0.1.0",
"publisher": "example",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": [
"onCommand:com.example.local-logging-fixture.emit",
"onCommand:com.example.local-logging-fixture.noise",
"onCommand:com.example.local-logging-fixture.denied"
],
"permissions": { "required": ["commands", "menus"], "optional": [] },
"contributes": {
"commands": [
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
],
"menus": [
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
]
}
}
File diff suppressed because it is too large. Load diff
+39 -66
View File
@@ -79,6 +79,7 @@ function normalizeRuntimeIdentity(identity) {
runtimeKind: identity.runtimeKind,
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
...(identity.sourceOwner === undefined ? {} : { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId }),
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
...(identity.logger === undefined ? {} : { logger: identity.logger }),
});
@@ -175,73 +176,45 @@ class PluginHostRpcRegistry {
await assertCurrent?.();
transportContext.signal?.throwIfAborted();
};
const startedAt = Date.now();
let authorizationOutcome = "not-reached";
let auditResources;
let auditResult;
let auditError;
try {
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
recordAuthorization: (outcome) => {
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
},
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
auditResources = authorization?.resources;
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
auditResult = result;
return result;
} catch (error) {
auditError = error;
throw error;
} finally {
// Never record params, handler results, error messages or plugin text.
const dataBytes = (value) => {
if (typeof value === "string") return Buffer.byteLength(value);
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
return null;
};
const field = (value, key) => value && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
return result;
};
const requestHandlers = Object.freeze(Object.fromEntries(
[...this.requests].map(([method, registration]) => [
+63 -62
View File
@@ -1,8 +1,6 @@
"use strict";
const path = require("node:path");
const { PluginLogService } = require("./pluginLogService.cjs");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
@@ -29,6 +27,9 @@ const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
@@ -39,7 +40,6 @@ const {
resolveDefaultRuntimeKind,
} = require("./runtimeSupervisor.cjs");
const { PluginQuotaManager } = require("./quotaManager.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
const { PluginSecretStore } = require("./secretStore.cjs");
const { SecretLeaseStore } = require("./secretLease.cjs");
@@ -93,9 +93,12 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
let byteStreamService;
let aiContextService;
let aiHostAdapter;
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -103,7 +106,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures,
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -143,8 +146,6 @@ function createPluginHostService(options) {
const filesystemBroker = new PluginFilesystemBroker({
quotaManager,
openDirectoryHandle: options.openPluginDirectoryHandle,
protectedDirectories: [paths.logs],
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
});
const networkBroker = new PluginNetworkBroker({
fetch: options.fetch ?? (options.electron.net?.fetch
@@ -211,36 +212,25 @@ function createPluginHostService(options) {
|| !context.availableKinds.includes(kind)
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
try {
await permissionEngine.authorizeRequired(context.plugin, {
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
securityPrincipal: context.securityPrincipal,
signal: context.signal,
skipPermissions: ["runtime.advanced"],
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
});
}
} catch (error) {
const identity = logService.identity({
pluginId: context.plugin.id, pluginVersion: context.plugin.activeVersion,
runtimeId: null, runtimeKind: kind, securityPrincipal: context.securityPrincipal,
}, context.plugin);
logService.audit(identity, { operation: "runtime.authorization", result: "error",
authorization: error?.code === RPC_ERRORS.permissionDenied ? "denied" : "unknown",
errorCode: error?.code });
throw error;
}
return kind;
};
@@ -250,14 +240,13 @@ function createPluginHostService(options) {
};
runtimeSupervisor = new RuntimeSupervisor({
electron: options.electron,
logService,
database,
packageStore,
protocol,
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures,
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -267,23 +256,34 @@ function createPluginHostService(options) {
getInitialEnvironment: () => contributionService.getEnvironment(),
runtimeResourceMonitor: quotaManager,
runtimeCleanup: async (identity) => {
byteStreamService?.releaseRuntime(identity.runtimeId);
aiContextService?.releaseRuntime(identity.runtimeId);
interactionService?.releaseRuntime(identity.runtimeId);
leaseStore.revokeRuntime(identity.runtimeId);
await companionSupervisor.releaseRuntime(identity.runtimeId);
},
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
});
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
permissionEngine.onDidRevoke((event) => {
const plugin = database.getActivePlugin(event.pluginId);
const identity = runtimeSupervisor.getRuntimeIdentity(event.pluginId) ?? {
pluginId: event.pluginId, pluginVersion: plugin?.activeVersion,
};
logService.audit(logService.identity(identity, plugin), {
operation: "permission.revoked", authorization: "not-required", result: "ok",
resources: event.resource ? [event.resource] : [],
});
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
byteStreamService.register(rpcRegistry);
aiHostAdapter = options.pluginAIHostAdapter ?? new PluginAITrustedHostAdapter({
getWindow: options.getInteractionWindow ?? (() => null),
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
dialog: options.electron.dialog,
});
if (options.electron.ipcMain) aiHostAdapter.registerIpc?.(options.electron.ipcMain);
aiContextService = new PluginAIContextService({ database, permissionEngine,
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
hostAdapter: aiHostAdapter,
});
aiContextService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
contributionService.onPluginDisabled = (pluginId) => {
aiContextService.releasePlugin(pluginId);
return originalOnPluginDisabled(pluginId);
};
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
const terminalProviderService = new PluginTerminalProviderService({
contributionService,
permissionEngine,
@@ -408,24 +408,21 @@ function createPluginHostService(options) {
runtimeSupervisor,
contributionService,
beforeClose: async () => {
try {
themeService.shutdown();
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
} finally {
await logService.close();
}
themeService.shutdown();
interactionService.shutdown();
byteStreamService.shutdown();
aiContextService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
},
});
return {
logService,
themeService,
interactionService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
@@ -445,6 +442,8 @@ function createPluginHostService(options) {
paths,
protocol,
networkBroker,
byteStreamService,
aiContextService,
permissionEngine,
quotaManager,
rpcRegistry,
@@ -456,7 +455,9 @@ function createPluginHostService(options) {
};
} catch (error) {
try { interactionService?.shutdown(); } catch {}
void logService.close();
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
database.close();
throw error;
}
-80
View File
@@ -401,83 +401,3 @@ test("normal release blocks a custom utility placement before permission prompts
}), /requires developer mode/);
assert.equal(prompts, 0);
});
test("host service logs revocation with injected provenance and drains through manager shutdown", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-host-"));
let seen;
const service = createPluginHostService({ ...createOptions(root), resolvePluginProvenance(input) {
seen = input;
return { sourceId: "synthetic-host-source", namespace: "test", sourceKind: "local" };
} });
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-audit", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" } };
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "b".repeat(64), packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await service.logService.ready;
assert.equal(service.logService.status().saveToDisk, false);
await service.logService.configure({ saveToDisk: true, verbose: false });
service.permissionEngine.revokeAll(manifest.id);
await service.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(service.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "permission.revoked");
assert.equal(record.sourceId, "synthetic-host-source");
assert.equal(record.archiveSha256, "b".repeat(64));
assert.equal(record.pluginVersion, "1.0.0");
assert.equal(record.runtimeId, null);
assert.equal(seen.pluginId, manifest.id);
assert.equal(seen.archiveSha256, "b".repeat(64));
await service.manager.shutdown();
assert.equal(service.logService.sink.closed, true);
});
test("logging always drains before database close when another shutdown stage rejects", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-shutdown-failure-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
let resume;
const gate = new Promise((resolve) => { resume = resolve; });
host.logService.sink.queue = host.logService.sink.queue.then(() => gate);
const logger = host.logService.createLogger({ pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "host-shutdown", runtimeKind: "browser" }, {});
logger.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
let settled = false;
const shutdown = host.manager.shutdown().finally(() => { settled = true; });
// Attach the rejection assertion before yielding so the expected failure is handled.
const rejected = assert.rejects(shutdown, /synthetic companion shutdown failure/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.doesNotThrow(() => host.database.db.prepare("SELECT 1").get());
resume();
await rejected;
assert.equal(host.logService.sink.closed, true);
assert.equal(host.logService.retentionTimer._destroyed, true);
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "synthetic.before-shutdown");
});
test("activation permission denial is audited before a runtime logger exists", async (t) => {
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
const archiveSha256 = "a".repeat(64);
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
}), { code: RPC_ERRORS.permissionDenied });
await host.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "runtime.authorization");
assert.equal(record.authorization, "denied");
assert.equal(record.runtimeId, null);
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
assert.equal(record.archiveSha256, archiveSha256);
assert.equal(Object.hasOwn(record, "message"), false);
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
});
@@ -1,34 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
test("native logging fixture packs reproducibly and exposes only synthetic commands", async (t) => {
const root = path.join(__dirname, "fixtures", "local-logging-plugin");
const output = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-logging-package-"));
t.after(() => fs.rm(output, { recursive: true, force: true }));
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
await buildPluginPackage(root, path.join(output, "one.ncpkg"));
await buildPluginPackage(root, path.join(output, "two.ncpkg"));
assert.deepEqual(await fs.readFile(path.join(output, "one.ncpkg")), await fs.readFile(path.join(output, "two.ncpkg")));
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
assert.deepEqual(manifest.permissions, { required: ["commands", "menus"], optional: [] });
const plugin = (await import(pathToFileURL(path.join(root, "index.js")).href)).default;
const commands = new Map();
const logs = [];
let deniedRequests = 0;
plugin.activate({
subscriptions: { add() {} },
commands: { registerCommand(id, handler) { commands.set(id, handler); } },
logger: { info(...args) { logs.push(args); } },
network: { async request(request) { assert.equal(request.method, "GET"); assert.equal(request.body, undefined); deniedRequests++; throw Object.assign(new Error("synthetic permission denial"), { code: "permission_denied" }); } },
});
assert.deepEqual(await commands.get(`${manifest.id}.emit`)(), { ok: true, synthetic: true });
assert.match(logs[0][0], /Synthetic/);
assert.equal(logs[0][1].sourceId, "official", "spoofed field is deliberately nested plugin text, never host provenance");
assert.deepEqual(await commands.get(`${manifest.id}.denied`)(), { denied: true, synthetic: true });
assert.equal(deniedRequests, 1);
});
@@ -0,0 +1,8 @@
"use strict";
// Include the isolated domain suites in npm run test:plugin-runtime.
require("./ai/modelInvocationBroker.test.cjs");
require("./context/selectedContextAuthority.test.cjs");
require("./ai/sourceBinding.test.cjs");
require("./ai/trustedHostAdapter.test.cjs");
require("./ai/aiContextService.test.cjs");
require("./ai/aiContextEntrypoint.test.cjs");
+2 -12
View File
@@ -639,21 +639,11 @@ class PluginPermissionEngine {
createMiddleware() {
return async (context, next) => {
if (context.metadata.public === true) {
context.recordAuthorization?.("not-required");
return next();
}
if (context.metadata.public === true) return next();
if (!context.authorization) {
context.recordAuthorization?.("denied");
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
}
try {
await this.authorize(context, context.authorization);
} catch (error) {
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
throw error;
}
context.recordAuthorization?.("allowed");
await this.authorize(context, context.authorization);
return next();
};
}
+4 -1
View File
@@ -2,6 +2,7 @@
const { createHash } = require("node:crypto");
const path = require("node:path");
const { canonicalizeStreamEndpoint } = require("./byteStreamEndpoint.cjs");
const contractSchema = require("./generated/plugin-contract.schema.json");
@@ -62,6 +63,7 @@ function canonicalizeFilesystemResource(value) {
function canonicalizePermissionResource(permission, resource) {
assertPluginPermission(permission);
if (permission === "network.stream") return canonicalizeStreamEndpoint(resource).endpoint;
if (resource === "*") return resource;
if (permission === "network") return canonicalizeNetworkOrigin(resource);
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
@@ -86,6 +88,7 @@ function permissionResourceCovers(
resourceKind = "exact",
requestedResourceKind = "exact",
) {
if (permission === "network.stream") return grantResource !== "*" && grantResource === requestedResource;
if (grantResource === "*") return true;
if (permission === "filesystem.read" || permission === "filesystem.write") {
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
@@ -112,7 +115,7 @@ function normalizePermissionDeclarations(manifest) {
: [...new Set((value.resources ?? []).map((resource) => (
canonicalizePermissionResource(permission, resource)
)))].sort();
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
if ((required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) || permission === "network.stream") {
if (resources.length === 0) {
throw new TypeError(`Required permission ${permission} must declare resources`);
}
-13
View File
@@ -20,11 +20,6 @@ const CHANNELS = Object.freeze({
themeApply: "netcatty:plugins:theme-apply",
themesChanged: "netcatty:plugins:themes-changed",
status: "netcatty:plugins:status",
logStatus: "netcatty:plugins:log-status",
logConfigure: "netcatty:plugins:log-configure",
logOpen: "netcatty:plugins:log-open",
logExport: "netcatty:plugins:log-export",
logClear: "netcatty:plugins:log-clear",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
@@ -701,14 +696,6 @@ function registerPluginBridge(ipcMain, options) {
}
return id;
};
for (const [channel, action] of [
[CHANNELS.logStatus, "status"], [CHANNELS.logConfigure, "configure"],
[CHANNELS.logOpen, "openFolder"], [CHANNELS.logExport, "exportLogs"], [CHANNELS.logClear, "clear"],
]) handle(channel, async (_manager, payload) => {
if (!options.logService) throw new Error("Local plugin logging is unavailable");
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
-14
View File
@@ -1721,17 +1721,3 @@ test('theme catalog and selection remain trusted app management channels',async(
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
});
test("local log management rejects plugin/untrusted senders before touching the sink", async () => {
const ipcMain = createIpcMain();
const actions = [];
const logService = { ready: Promise.resolve() };
for (const action of ["status", "configure", "openFolder", "exportLogs", "clear"]) logService[action] = async () => { actions.push(action); return { saveToDisk: false }; };
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, logService, isTrustedSender: (event) => event?.trusted === true });
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) {
await assert.rejects(ipcMain.handlers.get(channel)({}, {}), /Untrusted/);
assert.equal(actions.length, 0);
}
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
});
-36
View File
@@ -1,36 +0,0 @@
"use strict";
/**
* Host-only adapter. Call with the host-assigned activation identity after the
* caller guard; never pass plugin request params, manifest source claims or an
* owner supplied by a plugin. Missing source APIs mean unknown provenance.
*/
function createPluginLogProvenanceResolver(database) {
const methods = ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"];
const resolve = (identity) => {
if (!methods.every((method) => typeof database?.[method] === "function")) return null;
const plugin = database.getActivePlugin(identity.pluginId);
if (!plugin || plugin.id !== identity.pluginId
|| plugin.activeVersion !== identity.pluginVersion
|| plugin.archiveSha256 !== identity.archiveSha256) return null;
const source = plugin.source;
const retained = database.getRetainedPluginSource(identity.pluginId);
if (!source || !retained || source.id !== retained.id || source.kind !== retained.kind) return null;
const owner = database.privateOwner(identity.pluginId);
if (typeof owner !== "string" || !owner || owner === identity.pluginId || database.publicPluginId(owner) !== identity.pluginId) return null;
return { sourceId: source.id, sourceKind: source.kind, namespace: owner };
};
return (identity) => {
const initial = resolve(identity);
if (!initial) return null;
return Object.freeze({ ...initial, isCurrent: () => {
try {
const current = resolve(identity);
return current !== null && current.sourceId === initial.sourceId
&& current.sourceKind === initial.sourceKind && current.namespace === initial.namespace;
} catch { return false; }
} });
};
}
module.exports = { createPluginLogProvenanceResolver };
@@ -1,70 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { createPluginHostService } = require("./hostService.cjs");
test("real source database replacement never reattributes old runtime log records", {
skip: typeof PluginDatabase.prototype.getRetainedPluginSource !== "function" && "requires the source API from PR #3981; run on the documented integration worktree",
}, async (t) => {
const { thirdPartySource, OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-integration-"));
const host = createPluginHostService({
app: { getPath: () => root, getAppPath: () => process.cwd(), getVersion: () => "0.0.0" },
electron: {}, env: {},
});
const database = host.database;
const pluginId = "com.example.source-log-fixture";
// The current source contract encodes every namespace, including local.
const { LOCAL_SOURCE } = require("./sourceIdentity.cjs");
database.installVersion({ pluginId: "com.example.local-source-fixture", version: "1.0.0", manifest: {}, archiveSha256: "b".repeat(64), packageRelativePath: "com.example.local-source-fixture/1.0.0", source: LOCAL_SOURCE });
const localOwner = database.privateOwner("com.example.local-source-fixture");
assert.notEqual(localOwner, "com.example.local-source-fixture");
assert.equal(database.publicPluginId(localOwner), "com.example.local-source-fixture");
const first = thirdPartySource("Synthetic first source", "https://synthetic-first.example.com");
const install = (source) => database.installVersion({ pluginId, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
install(first);
const service = host.logService;
t.after(async () => { await host.manager.shutdown().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const activation = { pluginId, pluginVersion: "1.0.0", runtimeId: "source-fixture-A", runtimeKind: "browser" };
const firstLogger = service.createLogger(activation, database.getActivePlugin(pluginId));
firstLogger.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const oldOwner = database.privateOwner(pluginId);
let resume;
const queueGate = new Promise((resolve) => { resume = resolve; });
service.sink.queue = service.sink.queue.then(() => queueGate);
await firstLogger.write("info", "synthetic queued before replacement");
database.removePlugin(pluginId);
assert.equal(database.getRetainedPluginSource(pluginId).id, first.id);
database.clearUninstalledSourceData(pluginId, first.id);
install(OFFICIAL_SOURCE);
assert.equal(database.publicPluginId(oldOwner), null);
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
const replacement = service.createLogger({ ...activation, runtimeId: "source-fixture-B" }, database.getActivePlugin(pluginId));
replacement.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.equal(records[0].sourceId, first.id);
assert.equal(records[0].namespace, oldOwner);
assert.equal(records[1].sourceId, "official");
assert.equal(records[1].namespace, database.privateOwner(pluginId));
assert.equal(Object.hasOwn(records[1], "officiallyMaintained"), false);
assert.doesNotMatch(JSON.stringify(records), /queued before replacement|synthetic-first.example.com/);
replacement.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
await assert.rejects(host.manager.shutdown(), /synthetic companion shutdown failure/);
assert.equal(service.sink.closed, true);
const drained = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(drained.at(-1).operation, "synthetic.before-shutdown");
assert.equal(drained.at(-1).sourceId, "official");
});
@@ -1,149 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const { PluginLogService } = require("./pluginLogService.cjs");
function sourceFixture(pluginId = "com.example.synthetic-source-logging") {
const identity = { pluginId, pluginVersion: "1.0.0", archiveSha256: "a".repeat(64), runtimeId: "host-runtime-A", runtimeKind: "browser" };
let source = { id: `third-party:${"b".repeat(64)}`, kind: "third-party", name: "Synthetic source", origin: "https://synthetic.example.com" };
let active = { id: pluginId, activeVersion: identity.pluginVersion, archiveSha256: identity.archiveSha256, source };
let retained = source;
const owner = () => source ? `source:${source.id === "official" ? "c".repeat(64) : "d".repeat(64)}:${pluginId}` : null;
const database = {
getActivePlugin(id) { assert.equal(id, pluginId); return active; },
getRetainedPluginSource(id) { assert.equal(id, pluginId); return retained; },
privateOwner(id) { assert.equal(id, pluginId); return owner(); },
publicPluginId(encoded) { return encoded === owner() ? pluginId : null; },
};
return { identity, database, plugin: () => active, setActive(value) { active = value; },
setRetained(value) { retained = value; },
replace() { source = { id: "official", kind: "official", name: "Not a maintenance claim", origin: "https://plugins.netcatty.app" }; active = { ...active, source }; retained = source; },
uninstall() { active = null; },
};
}
test("host provenance uses the bound owner and ignores plugin-supplied source metadata", () => {
const f = sourceFixture();
const resolver = createPluginLogProvenanceResolver(f.database);
const result = resolver({ ...f.identity, sourceId: "official", source: { id: "official" }, owner: "spoof" });
assert.equal(result.sourceId, f.plugin().source.id);
assert.equal(result.sourceKind, "third-party");
assert.equal(result.namespace, f.database.privateOwner(f.identity.pluginId));
assert.equal(result.isCurrent(), true);
assert.equal(Object.hasOwn(result, "officiallyMaintained"), false);
assert.equal(Object.hasOwn(result, "origin"), false);
f.replace();
assert.equal(result.isCurrent(), false);
assert.equal(resolver(f.identity).sourceId, "official");
});
test("missing source APIs, retained mismatch, orphaned owner and changed package remain unknown", () => {
const f = sourceFixture();
const resolve = createPluginLogProvenanceResolver(f.database);
assert.equal(createPluginLogProvenanceResolver({ getActivePlugin: f.database.getActivePlugin })(f.identity), null);
assert.equal(resolve({ ...f.identity, pluginVersion: "2.0.0" }), null);
assert.equal(resolve({ ...f.identity, archiveSha256: "c".repeat(64) }), null);
f.database.privateOwner = () => null;
assert.equal(resolve(f.identity), null, "no bare pluginId fallback");
f.database.privateOwner = () => f.identity.pluginId;
f.database.publicPluginId = (owner) => owner;
assert.equal(resolve(f.identity), null, "a bare owner is not an authenticated namespace");
f.database.privateOwner = () => "source:orphan";
assert.equal(resolve(f.identity), null);
const g = sourceFixture();
g.setRetained({ id: "different", kind: "third-party" });
assert.equal(createPluginLogProvenanceResolver(g.database)(g.identity), null);
const h = sourceFixture();
const snapshot = createPluginLogProvenanceResolver(h.database)(h.identity);
h.uninstall();
assert.equal(snapshot.isCurrent(), false);
assert.equal(createPluginLogProvenanceResolver(h.database)(h.identity), null, "retained source alone cannot authenticate an installed activation");
});
test("queued diagnostics and host facts are revalidated after async I/O before commit", async (t) => {
const f = sourceFixture(`com.${"a".repeat(120)}`);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-log-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
assert.equal(service.status().saveToDisk, false);
await service.configure({ saveToDisk: true, verbose: true });
const loggerA = service.createLogger(f.identity, f.plugin());
loggerA.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
let release;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
loggerA.audit({ operation: "synthetic.pending-source-A", authorization: "allowed", result: "ok" });
await loggerA.write("info", "synthetic pending diagnostic A");
await paused;
f.replace();
release();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
const loggerB = service.createLogger({ ...f.identity, runtimeId: "host-runtime-B" }, f.plugin());
loggerB.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.match(records[0].sourceId, /^third-party:/u);
assert.equal(records[1].sourceId, "official");
assert.notEqual(records[0].namespace, records[1].namespace);
assert.equal(records[0].runtimeId, "host-runtime-A");
assert.equal(records[1].runtimeId, "host-runtime-B");
assert.doesNotMatch(JSON.stringify(records), /synthetic.pending-source-A|pending diagnostic|example.com|maintenance/i);
f.uninstall();
await loggerB.write("info", "synthetic stale callback");
loggerB.audit({ operation: "synthetic.after-uninstall", result: "ok" });
assert.equal(service.status().droppedRecords, 4);
});
test("a thrown source revalidation fails closed without breaking plugin execution", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-failure-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const logger = service.createLogger(f.identity, f.plugin());
f.database.getActivePlugin = () => { throw new Error("synthetic unavailable source database"); };
await logger.write("info", "synthetic ignored");
logger.audit({ operation: "storage.get", result: "ok" });
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
assert.equal(service.status().writeErrors, 0);
});
test("a stale queued source cannot rotate away existing audit evidence", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-rotation-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, limits: { maxFiles: 1 }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: false });
const logger = service.createLogger(f.identity, f.plugin());
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const before = await fs.readFile(service.sink.file(0), "utf8");
service.sink.maxBytes = Buffer.byteLength(before) + 16;
let resume;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { resume = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await paused;
f.replace();
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
assert.equal(await fs.readFile(service.sink.file(0), "utf8"), before);
});
-150
View File
@@ -1,150 +0,0 @@
"use strict";
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const { createHmac, randomBytes } = require("node:crypto");
const { PluginLogger, PluginLogSink } = require("./pluginLogger.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const bounded = (value, length = 128) => typeof value === "string"
? value.slice(0, length).replace(/[^a-zA-Z0-9._:@/-]/gu, "_") : null;
const sourceKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,128}$/u.test(value) ? value : null;
const ownerKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,256}$/u.test(value) ? value : null;
const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
class PluginLogService {
constructor(options) {
this.directory = path.join(options.paths.logs, "host-v1");
this.preferencesPath = path.join(options.paths.root, "local-logging.json");
this.resolveProvenance = options.resolveProvenance ?? (() => null);
this.electron = options.electron;
this.provenanceChecks = new WeakMap();
this.salt = randomBytes(32);
this.sink = new PluginLogSink({ directory: this.directory, ...options.limits });
this.settings = { saveToDisk: false, verbose: false };
this.management = Promise.resolve();
try {
if (fs.statSync(this.preferencesPath).size <= 1024) {
const saved = JSON.parse(fs.readFileSync(this.preferencesPath, "utf8"));
this.settings.saveToDisk = saved.saveToDisk === true;
// Free-form diagnostics always require fresh consent after app restart.
}
} catch {}
this.ready = this.sink.setEnabled(this.settings.saveToDisk).then(async () => {
if (!this.sink.enabled && fs.existsSync(this.directory)) await this.sink.prune();
}).catch(() => {
this.sink.writeErrors++;
this.settings.saveToDisk = false;
this.sink.enabled = false;
});
this.retentionTimer = setInterval(() => {
if (!fs.existsSync(this.directory) || this.sink.closed) return;
this.sink.queue = this.sink.queue.then(() => this.sink.prune()).catch(() => { this.sink.writeErrors++; });
}, 60 * 60 * 1000);
this.retentionTimer.unref?.();
}
status() {
return { ...this.settings, ...this.sink.status(), maxBytes: this.sink.maxBytes * this.sink.maxFiles,
retentionDays: this.sink.retentionMs / 86400000 };
}
async configure(request) {
if (!request || typeof request.saveToDisk !== "boolean" || typeof request.verbose !== "boolean") throw new TypeError("Invalid plugin log settings");
return this.#manage(async () => {
if (this.sink.closed) throw new Error("Plugin logging is closed");
const settings = { saveToDisk: request.saveToDisk, verbose: request.saveToDisk && request.verbose };
// Stop ingress before changing preferences. Failure leaves persistence off.
this.settings = { saveToDisk: false, verbose: false };
await this.sink.setEnabled(false);
await fsp.mkdir(path.dirname(this.preferencesPath), { recursive: true, mode: 0o700 });
const temporary = `${this.preferencesPath}.tmp`;
await fsp.writeFile(temporary, JSON.stringify({ saveToDisk: settings.saveToDisk }), { mode: 0o600 });
await fsp.rename(temporary, this.preferencesPath);
await this.sink.setEnabled(settings.saveToDisk);
this.settings = settings;
return this.status();
});
}
#manage(action) {
const result = this.management.then(() => this.ready).then(action);
this.management = result.catch(() => {});
return result;
}
identity(identity, plugin) {
// Only the injected HOST resolver may assign source and namespace identity.
let source = null;
try { source = this.resolveProvenance(Object.freeze({ pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
archiveSha256: plugin?.archiveSha256 ?? null, securityPrincipal: identity.securityPrincipal })); } catch {}
const metadata = Object.freeze({ pluginId: bounded(identity.pluginId), pluginVersion: bounded(identity.pluginVersion),
runtimeId: bounded(identity.runtimeId), runtimeKind: bounded(identity.runtimeKind),
archiveSha256: /^[a-f0-9]{64}$/u.test(plugin?.archiveSha256 ?? "") ? plugin.archiveSha256 : null,
sourceId: sourceKey(source?.sourceId), sourceKind: sourceKey(source?.sourceKind), namespace: ownerKey(source?.namespace) });
if (source?.isCurrent && typeof source.isCurrent === "function") {
this.provenanceChecks.set(metadata, () => source.isCurrent() === true);
}
return metadata;
}
createLogger(identity, plugin) {
const provenance = this.identity(identity, plugin);
return new PluginLogger({ sink: this.sink, identity: provenance, verbose: () => this.settings.verbose, isCurrent: this.provenanceChecks.get(provenance), audit: (event) => this.audit(provenance, event) });
}
audit(identity, event) {
if (!this.sink.enabled) return;
// An allowlist, never spread request/response/error objects into an audit.
const resources = Array.isArray(event.resources) ? event.resources.slice(0, 16) : [];
this.sink.enqueue({ timestamp: new Date().toISOString(), recordType: "host-audit", trust: "host-observed",
...identity, operation: bounded(event.operation, 256),
targetIds: resources.filter((item) => typeof item === "string").map((item) =>
createHmac("sha256", this.salt).update(item.slice(0, 8192)).digest("hex")),
authorization: ["allowed", "denied", "not-required", "not-reached", "unknown"].includes(event.authorization) ? event.authorization : "unknown",
result: event.result === "ok" ? "ok" : "error",
errorCode: Object.values(RPC_ERRORS).includes(event.errorCode) ? event.errorCode : event.result === "ok" ? null : RPC_ERRORS.internal,
durationMs: count(event.durationMs), inputBytes: count(event.inputBytes), outputBytes: count(event.outputBytes) }, this.provenanceChecks.get(identity));
}
async openFolder() {
return this.#manage(async () => {
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
const error = await this.electron.shell.openPath(this.directory);
if (error) throw new Error("Could not open plugin log folder");
return this.status();
});
}
async exportLogs() {
return this.#manage(async () => {
const result = await this.electron.dialog.showSaveDialog({ title: "Export local plugin logs", defaultPath: "netcatty-plugin-logs.jsonl" });
if (result.canceled || !result.filePath) return this.status();
if (path.resolve(result.filePath).startsWith(path.resolve(this.directory) + path.sep)) throw new Error("Choose a destination outside the log folder");
const task = this.sink.queue.then(async () => {
const chunks = [];
for (let index = this.sink.maxFiles - 1; index >= 0; index--) {
try {
const stats = await fsp.lstat(this.sink.file(index));
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > this.sink.maxBytes) throw new Error("Unsafe plugin log file");
if (await this.sink.retained(this.sink.file(index))) chunks.push(await fsp.readFile(this.sink.file(index)));
} catch (error) { if (error.code !== "ENOENT") throw error; }
}
await fsp.writeFile(result.filePath, Buffer.concat(chunks), { mode: 0o600 });
});
this.sink.queue = task.catch(() => {});
await task;
return this.status();
});
}
async clear() {
return this.#manage(async () => {
const result = await this.electron.dialog.showMessageBox({ type: "warning", title: "Clear local plugin logs?",
message: "Delete the saved local audit and diagnostic files? The bounded security history in plugin permissions is kept.",
buttons: ["Cancel", "Clear logs"], defaultId: 0, cancelId: 0, noLink: true });
if (result.response === 1) await this.sink.clear();
return this.status();
});
}
async close() {
clearInterval(this.retentionTimer);
await this.management;
await this.ready;
await this.sink.close();
}
}
module.exports = { PluginLogService };
+72 -162
View File
@@ -1,186 +1,96 @@
"use strict";
const fs = require("node:fs/promises");
const { constants } = require("node:fs");
const path = require("node:path");
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
const { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
const { assertPluginStorageSegment } = require("./paths.cjs");
const SENSITIVE_KEY = /(?:password|passwd|token|secret|api.?key|private.?key|credential|authorization|cookie|totp|otpauth|prompt|notes?|sql|results?|terminal|content|body|payload)/iu;
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
// Bounds apply to the entire traversal, including hostile, cyclic or wide input.
// This is damage limitation for opt-in diagnostics, not a guarantee of secrecy.
function redactLogValue(value, key = "", depth = 0, budget = { remaining: 4096 }) {
function redactLogValue(value, key = "", depth = 0) {
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
if (depth >= 6 || budget.remaining <= 0) return "[TRUNCATED]";
budget.remaining -= 16;
if (value == null || typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const text = value.slice(0, Math.min(2048, Math.max(0, budget.remaining))).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
budget.remaining -= text.length;
return text;
}
if (typeof value !== "object") return "[UNSUPPORTED]";
try {
const output = Array.isArray(value) ? [] : Object.create(null);
// Stop enumeration as soon as the budget is exhausted.
let count = 0;
for (const entryKey in value) {
if (budget.remaining <= 0 || count++ >= 32) break;
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
if (!descriptor?.enumerable) continue;
const safeKey = entryKey.slice(0, 64).replace(/[\u0000-\u001f\u007f]/gu, " ");
budget.remaining -= safeKey.length;
const item = "value" in descriptor ? redactLogValue(descriptor.value, entryKey, depth + 1, budget) : "[UNAVAILABLE]";
if (Array.isArray(output)) output.push(item);
else output[safeKey] = item;
if (depth >= 8) return "[TRUNCATED]";
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
if (typeof value === "string") return value.slice(0, 2_048);
if (Array.isArray(value)) {
const output = [];
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
output.push(descriptor && "value" in descriptor
? redactLogValue(descriptor.value, "", depth + 1)
: "[UNAVAILABLE]");
}
return output;
} catch { return "[UNAVAILABLE]"; }
}
/** One app-owned sink gives every plugin a shared disk and queue budget. */
class PluginLogSink {
constructor(options) {
this.directory = options.directory;
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? 256 * 1024;
this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
for (const value of [this.maxBytes, this.maxFiles, this.maxPendingBytes, this.retentionMs]) {
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError("Invalid plugin log limit");
}
this.clock = options.clock ?? Date.now;
this.enabled = false;
this.closed = false;
this.pendingBytes = 0;
this.diagnosticPendingBytes = 0;
this.dropped = 0;
this.writeErrors = 0;
this.queue = Promise.resolve();
this.generation = 0;
}
enqueue(record, isCurrent = () => true) {
if (!this.enabled || this.closed) return;
if (this.clearing) { this.dropped++; return; }
const current = () => { try { return isCurrent() === true; } catch { return false; } };
if (!current()) { this.dropped++; return; }
let line;
try { line = `${JSON.stringify({ ...record, timestamp: new Date(this.clock()).toISOString(), droppedRecords: this.dropped, writeErrors: this.writeErrors })}\n`; }
catch { this.dropped++; return; }
const bytes = Buffer.byteLength(line);
const diagnostic = record.recordType === "plugin-diagnostic";
if ((diagnostic && this.diagnosticPendingBytes + bytes > this.maxPendingBytes / 4) || bytes > Math.min(this.maxBytes, 16 * 1024) || this.pendingBytes + bytes > this.maxPendingBytes) {
this.dropped++; return;
}
const generation = this.generation;
this.pendingBytes += bytes;
if (diagnostic) this.diagnosticPendingBytes += bytes;
this.queue = this.queue.then(async () => {
if (generation !== this.generation || !current()) { this.dropped++; return; }
await this.prune();
const active = this.file(0);
let size = 0;
try { size = (await fs.stat(active)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
if (!current()) { this.dropped++; return; }
if (size + bytes > this.maxBytes) {
await fs.rm(this.file(this.maxFiles - 1), { force: true });
for (let index = this.maxFiles - 2; index >= 0; index--) {
if (!current()) { this.dropped++; return; }
try { await fs.rename(this.file(index), this.file(index + 1)); }
catch (error) { if (error.code !== "ENOENT") throw error; }
}
if (typeof value === "object") {
const output = {};
for (const entryKey of Object.keys(value).slice(0, 64)) {
if (SENSITIVE_KEY.test(entryKey)) {
output[entryKey.slice(0, 128)] = "[REDACTED]";
continue;
}
if (!current()) { this.dropped++; return; }
const handle = await fs.open(active, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
try {
if (!(await handle.stat()).isFile()) throw new Error("Unsafe plugin log file");
if (!current()) { this.dropped++; return; }
await handle.writeFile(line);
} finally { await handle.close(); }
}).catch(() => { this.dropped++; this.writeErrors++; }).finally(() => {
this.pendingBytes -= bytes;
if (diagnostic) this.diagnosticPendingBytes -= bytes;
});
}
file(index) { return path.join(this.directory, index === 0 ? "runtime.jsonl" : `runtime.${index}.jsonl`); }
async retained(file) {
// Every host record is capped at 16 KiB. Read only the first line: append
// updates mtime, so mtime cannot enforce record retention for active files.
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const buffer = Buffer.alloc(16 * 1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const newline = buffer.indexOf(10, 0);
if (newline < 0 || newline >= bytesRead) return false;
let first;
try { first = JSON.parse(buffer.subarray(0, newline).toString("utf8")); } catch { return false; }
const start = typeof first.timestamp === "string" ? Date.parse(first.timestamp) : NaN;
return Number.isFinite(start) && this.clock() - start < this.retentionMs;
} finally { await handle.close(); }
}
async prune() {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
for (let index = 0; index < this.maxFiles; index++) {
try {
const file = this.file(index);
const stats = await fs.lstat(file);
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe plugin log file");
if (stats.size > this.maxBytes || !await this.retained(file)) await fs.rm(file);
} catch (error) { if (error.code !== "ENOENT") throw error; }
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
? redactLogValue(descriptor.value, entryKey, depth + 1)
: "[UNAVAILABLE]";
}
return output;
}
flush() { return this.queue; }
async setEnabled(enabled) {
this.enabled = false;
this.generation++;
await this.flush();
if (enabled === true && !this.closed) {
await this.prune();
this.enabled = true;
}
}
async clear() {
this.clearing = true;
this.generation++;
try {
await this.flush();
for (let index = 0; index < this.maxFiles; index++) await fs.rm(this.file(index), { force: true });
} finally { this.clearing = false; }
}
async close() { this.closed = true; this.enabled = false; await this.flush(); }
status() { return { droppedRecords: this.dropped, writeErrors: this.writeErrors, pendingBytes: this.pendingBytes }; }
return "[UNSUPPORTED]";
}
class PluginLogger {
constructor(options = {}) {
this.sink = options.sink;
this.identity = options.identity;
this.verbose = options.verbose ?? (() => false);
this.isCurrent = options.isCurrent;
this.closed = false;
this.audit = options.audit ?? (() => {});
this.bucket = new TokenBucket(DEFAULT_QUOTAS.logWritesPerMinute, DEFAULT_QUOTAS.logWritesPerMinute / 60000, options.clock ?? Date.now);
constructor(options) {
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
this.directory = path.join(options.logsDirectory, this.pluginId);
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
this.pendingBytes = 0;
this.queue = Promise.resolve();
}
write(level, message, fields) {
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
this.sink.enqueue({
const record = JSON.stringify({
timestamp: new Date().toISOString(),
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
...this.identity,
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
diagnostic: {
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
fields: redactLogValue(fields),
},
}, this.isCurrent);
return Promise.resolve();
message: String(message ?? "").slice(0, 2_048),
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
});
const line = `${record}\n`;
const bytes = Buffer.byteLength(line);
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
this.pendingBytes += bytes;
this.queue = this.queue
.then(() => this.#append(line))
.catch(() => {})
.finally(() => { this.pendingBytes -= bytes; });
return this.queue;
}
async #append(line) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
const activePath = path.join(this.directory, "runtime.log");
let size = 0;
try { size = (await fs.stat(activePath)).size; } catch {}
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
}
async #rotate() {
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
try {
await fs.rename(
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
path.join(this.directory, `runtime.${index}.log`),
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
}
close() { this.closed = true; }
}
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
module.exports = { PluginLogger, redactLogValue };
+30 -245
View File
@@ -1,258 +1,43 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginLogger, PluginLogSink, redactLogValue } = require("./pluginLogger.cjs");
const { PluginLogService } = require("./pluginLogService.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
async function fixture(t, limits = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-plugin-logs-"));
const paths = { root, logs: path.join(root, "logs") };
let response = 0;
const service = new PluginLogService({ paths, limits, resolveProvenance: () => ({ sourceId: "synthetic-source", sourceKind: "local", namespace: "test" }),
electron: { shell: { openPath: async () => "" }, dialog: { showMessageBox: async () => ({ response }), showSaveDialog: async () => ({ filePath: path.join(root, "export.jsonl") }) } } });
await service.ready;
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
const identity = { pluginId: "com.example.synthetic", pluginVersion: "1.2.3", runtimeId: "host-runtime-1", runtimeKind: "browser" };
const logger = service.createLogger(identity, { archiveSha256: "a".repeat(64) });
const records = async () => {
await service.sink.flush();
const files = await fs.readdir(service.directory).catch(() => []);
const lines = (await Promise.all(files.map((name) => fs.readFile(path.join(service.directory, name), "utf8")))).join("");
return lines.trim() ? lines.trim().split("\n").map(JSON.parse) : [];
};
return { service, logger, identity, root, paths, records, confirm: () => { response = 1; } };
}
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
test("persistence defaults off and disabled logger does not coerce or inspect text", async (t) => {
const f = await fixture(t);
await f.logger.write("error", { toString() { throw Error("must not execute"); } });
f.logger.audit({ operation: "storage.get", result: "ok" });
assert.deepEqual(await f.records(), []);
await assert.rejects(fs.stat(f.service.directory), { code: "ENOENT" });
assert.equal(f.service.status().saveToDisk, false);
});
test("plugin logs redact structured secrets and remain bounded on disk", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-logs-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const logger = new PluginLogger({
pluginId: "com.example.logger",
logsDirectory: root,
maxBytes: 240,
maxFiles: 2,
});
test("enabled host audit excludes bodies and binds host source, version, hash and runtime", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
await f.logger.write("info", "SYNTHETIC_SECRET_PROMPT");
f.logger.audit({ operation: "network.request", authorization: "allowed", result: "ok", resources: ["https://user:password@example.test/private?api_key=SYNTHETIC_SECRET"],
body: "SYNTHETIC_SECRET_BODY", error: "SYNTHETIC_SECRET_ERROR", inputBytes: 24, outputBytes: 64, durationMs: 3, pluginId: "spoof" });
const records = await f.records();
assert.equal(records.length, 1);
assert.equal(records[0].pluginId, f.identity.pluginId);
assert.equal(records[0].sourceId, "synthetic-source");
assert.equal(records[0].archiveSha256, "a".repeat(64));
assert.equal(records[0].runtimeId, "host-runtime-1");
assert.equal(records[0].authorization, "allowed");
assert.equal(records[0].inputBytes, 24);
assert.match(records[0].targetIds[0], /^[a-f0-9]{64}$/u);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|example.test|password|spoof/);
});
test("verbose diagnostics are opt-in, bounded and cannot impersonate host records", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await f.logger.write("bad", "message\u001b\n", { sourceId: "spoof", recordType: "host-audit", apiKey: "SYNTHETIC_SECRET", prompt: "SYNTHETIC_SECRET", Notes: "SYNTHETIC_SECRET", SQL: "SYNTHETIC_SECRET", results: "SYNTHETIC_SECRET", terminalContent: "SYNTHETIC_SECRET", credentialPayload: "SYNTHETIC_SECRET" });
const records = await f.records();
assert.equal(records[0].sourceId, "synthetic-source");
assert.equal(records[0].recordType, "plugin-diagnostic");
assert.equal(records[0].trust, "plugin-authored-unverified");
assert.equal(records[0].diagnostic.fields.sourceId, "spoof");
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|\\u001b/);
const cyclic = {}; cyclic.self = cyclic;
await f.logger.write("error", { toString() { throw Error("coercion"); } }, cyclic);
await f.logger.write("info", "proxy", new Proxy({}, { ownKeys() { throw Error("proxy"); } }));
const getter = {};
Object.defineProperty(getter, "value", { enumerable: true, get() { throw Error("accessor"); } });
assert.equal(redactLogValue(getter).value, "[UNAVAILABLE]");
assert.equal((await f.records()).length, 3);
const restored = new PluginLogService({ paths: f.paths });
await restored.ready;
assert.equal(restored.status().saveToDisk, true);
assert.equal(restored.status().verbose, false);
await restored.close();
});
test("shared disk cap, queue cap, retention and drop accounting", async (t) => {
const f = await fixture(t, { maxBytes: 800, maxFiles: 2, maxPendingBytes: 1600 });
await f.service.configure({ saveToDisk: true, verbose: true });
for (let i = 0; i < 10; i++) {
f.logger.audit({ operation: "storage.get", result: "ok" });
await f.service.sink.flush();
for (let index = 0; index < 8; index += 1) {
await logger.write("info", `record-${index}`, {
nested: { apiToken: "must-not-appear", visible: index },
});
}
let files = await fs.readdir(f.service.directory);
const files = fs.readdirSync(path.join(root, "com.example.logger"));
assert.ok(files.length <= 2);
for (const name of files) assert.ok((await fs.stat(path.join(f.service.directory, name))).size <= 800);
for (let i = 0; i < 1000; i++) f.logger.audit({ operation: "storage.get", result: "ok" });
assert.ok(f.service.sink.pendingBytes <= 1600);
assert.ok(f.service.status().droppedRecords > 0);
await f.service.sink.flush();
f.logger.audit({ operation: "storage.get", result: "ok" });
assert.ok((await f.records()).some((record) => record.droppedRecords > 0));
files = await fs.readdir(f.service.directory);
const future = Date.now() + f.service.sink.retentionMs;
f.service.sink.clock = () => future;
await f.service.sink.prune();
assert.deepEqual(await fs.readdir(f.service.directory), []);
const contents = files.map((file) => fs.readFileSync(
path.join(root, "com.example.logger", file),
"utf8",
)).join("\n");
assert.doesNotMatch(contents, /must-not-appear/);
assert.match(contents, /\[REDACTED\]/);
});
test("disable, revoke, clear confirmation and shutdown stop future diagnostic writes", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await f.logger.write("info", "before revoke");
await f.service.sink.flush();
f.logger.close();
await f.logger.write("info", "after revoke");
assert.equal((await f.records()).length, 1);
await f.service.clear();
assert.equal((await f.records()).length, 1);
f.confirm(); await f.service.clear();
assert.equal((await f.records()).length, 0);
const active = f.service.createLogger(f.identity, {});
await f.service.configure({ saveToDisk: false, verbose: false });
await active.write("info", "disabled");
assert.equal((await f.records()).length, 0);
await f.service.configure({ saveToDisk: true, verbose: true });
await active.write("info", "before shutdown");
await f.service.close();
await active.write("info", "after shutdown");
assert.equal((await f.records()).length, 1);
});
test("export is local and does not alter retained files", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
f.logger.audit({ operation: "filesystem.stat", result: "ok", authorization: "allowed" });
await f.service.exportLogs();
const exported = await fs.readFile(path.join(f.root, "export.jsonl"), "utf8");
assert.equal(JSON.parse(exported).operation, "filesystem.stat");
assert.equal((await f.records()).length, 1);
});
test("RPC audit includes denied, malformed and public operations without error messages or bodies", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
const registry = new PluginHostRpcRegistry();
const engine = new PluginPermissionEngine({ database: { listPermissionGrants: () => [] } });
t.after(() => engine.shutdown());
registry.use(engine.createMiddleware());
registry.registerRequest("synthetic.public", (params) => ({ data: params.data }), { metadata: { public: true } });
registry.registerRequest("synthetic.denied", () => { throw Error("not reached"); });
registry.registerRequest("synthetic.invalid", () => null, { validateParams() { throw new Error("SYNTHETIC_SECRET_ERROR"); } });
const routes = registry.createRoutes({ ...f.identity, logger: f.logger });
await routes.requestHandlers["synthetic.public"]({ data: "SYNTHETIC_SECRET_BODY" }, {});
await assert.rejects(routes.requestHandlers["synthetic.denied"]({}, {}), { code: RPC_ERRORS.permissionDenied });
await assert.rejects(routes.requestHandlers["synthetic.invalid"]({}, {}));
const records = await f.records();
assert.deepEqual(records.map((r) => r.authorization), ["not-required", "denied", "not-reached"]);
assert.equal(records[0].inputBytes, 21);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
});
test("I/O failure is counted and never fails plugin execution", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await fs.writeFile(f.service.sink.file(0), "");
await fs.rm(f.service.sink.file(0));
await fs.symlink(path.join(f.root, "outside"), f.service.sink.file(0));
await f.logger.write("info", "synthetic");
await f.service.sink.flush();
assert.equal(f.service.status().writeErrors, 1);
assert.equal(f.service.status().droppedRecords, 1);
});
test("invalid sink limits fail early", () => {
assert.throws(() => new PluginLogSink({ directory: "/unused", maxFiles: -1 }));
const logger = new PluginLogger();
assert.doesNotThrow(() => logger.write("info", "disabled"));
});
test("unknown provenance stays unknown and a disabled restart prunes expired files", async (t) => {
const f = await fixture(t);
f.service.resolveProvenance = () => null;
const logger = f.service.createLogger({ ...f.identity, sourceId: "manifest-official-claim" }, { sourceId: "manifest-official-claim", archiveSha256: "c".repeat(64) });
await f.service.configure({ saveToDisk: true, verbose: false });
logger.audit({ operation: "storage.get", result: "ok" });
const records = await f.records();
assert.equal(records[0].sourceId, null);
assert.equal(records[0].namespace, null);
assert.doesNotMatch(JSON.stringify(records), /manifest-official-claim/);
await f.service.configure({ saveToDisk: false, verbose: false });
const expired = await fs.readFile(f.service.sink.file(0), "utf8");
await fs.writeFile(f.service.sink.file(0), expired.replace(/"timestamp":"[^"]+"/u, '"timestamp":"2000-01-01T00:00:00.000Z"'));
const restored = new PluginLogService({ paths: f.paths });
await restored.ready;
assert.equal(restored.status().saveToDisk, false);
assert.deepEqual(await fs.readdir(restored.directory), []);
await restored.close();
});
test("noisy and malformed plugin diagnostics remain bounded with visible drops", async (t) => {
const f = await fixture(t, { maxPendingBytes: 4096 });
await f.service.configure({ saveToDisk: true, verbose: true });
const wide = {};
for (let index = 0; index < 100; index++) wide[`key-${index}`] = Array(100).fill("synthetic ".repeat(100));
await f.logger.write(Symbol("invalid"), { toString() { throw Error("must not coerce"); } }, wide);
for (let index = 0; index < 1000; index++) void f.logger.write("info", "synthetic noise", { pluginId: "spoof", index });
assert.ok(f.service.sink.pendingBytes <= 4096);
assert.ok(f.service.status().droppedRecords > 0);
for (const record of await f.records()) {
assert.equal(record.pluginId, f.identity.pluginId);
assert.ok(Buffer.byteLength(JSON.stringify(record)) < 16384);
}
});
test("source resolver URLs and userinfo are not persisted as source identity", async (t) => {
const f = await fixture(t);
f.service.resolveProvenance = () => ({ sourceId: "https://user:SYNTHETIC_SECRET@example.test/source?key=SYNTHETIC_SECRET", sourceKind: "local", namespace: "user@SYNTHETIC_SECRET" });
const logger = f.service.createLogger(f.identity, {});
await f.service.configure({ saveToDisk: true, verbose: false });
logger.audit({ operation: "storage.get", result: "ok" });
const records = await f.records();
assert.equal(records[0].sourceId, null);
assert.equal(records[0].namespace, null);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
});
test("31 days of low-volume appends never retain an active record for seven days", async (t) => {
let now = Date.UTC(2030, 0, 1);
const f = await fixture(t, { clock: () => now });
await f.service.configure({ saveToDisk: true, verbose: false });
for (let day = 0; day < 31; day++) {
now = Date.UTC(2030, 0, 1) + day * 86400000;
f.logger.audit({ operation: `synthetic.day-${day}`, authorization: "not-required", result: "ok" });
const records = await f.records();
assert.ok(records.length > 0);
for (const record of records) assert.ok(now - Date.parse(record.timestamp) < f.service.sink.retentionMs);
}
assert.doesNotMatch(JSON.stringify(await f.records()), /synthetic.day-0"/u);
});
test("export excludes an expired active segment even when its mtime is recent", async (t) => {
let now = Date.UTC(2030, 0, 1);
const f = await fixture(t, { clock: () => now });
await f.service.configure({ saveToDisk: true, verbose: false });
f.logger.audit({ operation: "synthetic.expired", result: "ok" });
await f.service.sink.flush();
now += 86400000;
f.logger.audit({ operation: "synthetic.day-one", result: "ok" });
await f.service.sink.flush();
// Appending refreshed mtime; export must use the first host record's age.
await fs.utimes(f.service.sink.file(0), new Date(now), new Date(now));
now += f.service.sink.retentionMs;
await f.service.exportLogs();
assert.equal(await fs.readFile(path.join(f.root, "export.jsonl"), "utf8"), "");
f.logger.audit({ operation: "synthetic.new-segment", result: "ok" });
await f.service.sink.flush();
await f.service.exportLogs();
const records = (await fs.readFile(path.join(f.root, "export.jsonl"), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 1);
assert.equal(records[0].operation, "synthetic.new-segment");
test("log redaction does not invoke accessors", () => {
const value = {};
Object.defineProperty(value, "secret", {
enumerable: true,
get() { throw new Error("accessor invoked"); },
});
assert.deepEqual(redactLogValue(value), { secret: "[REDACTED]" });
});
@@ -0,0 +1,54 @@
import { PluginError } from "@netcatty/plugin-sdk";
import { PLUGIN_BYTE_STREAM_FEATURE } from "@netcatty/plugin-contract";
export async function connectByteStream({ client, streams, subscriptions, enabledFeatures }, request, cancellationToken) {
if (!enabledFeatures.includes(PLUGIN_BYTE_STREAM_FEATURE)) throw new PluginError("unsupported", "Byte stream feature is unavailable");
const descriptor = await client.request("network.stream.connect", request, { cancellationToken });
let readable;
let writable;
let readablePromise;
let closing;
let cancellation;
let connection;
const close = () => {
closing ??= client.request("network.stream.close", { handleId: descriptor.handleId })
.finally(() => {
cancellation?.dispose();
streams.rejectReadable(descriptor.outputStreamId);
if (connection) subscriptions.delete(connection);
});
return closing;
};
const cancel = () => { void close().catch(() => {}); };
const checkCancelled = async () => {
if (!cancellationToken?.isCancellationRequested) return;
await close().catch(() => {});
throw new PluginError("cancelled", "Byte stream connection cancelled");
};
try {
await checkCancelled();
readablePromise = streams.acceptReadable(descriptor.outputStreamId);
void readablePromise.catch(() => {});
writable = await streams.openWritable(descriptor.inputStreamId, descriptor.windowBytes);
const attaching = client.request("network.stream.attach", { handleId: descriptor.handleId }, { cancellationToken });
[readable] = await Promise.all([readablePromise, attaching]);
await checkCancelled();
connection = Object.freeze({
endpoint: descriptor.endpoint,
readable: Object.freeze({
id: readable.id, read: () => readable.read(), cancel, dispose: cancel,
}),
writable: Object.freeze({
id: writable.id, write: (bytes) => writable.write(bytes), end: () => writable.end(),
fail: cancel, cancel, dispose: cancel,
}),
close, dispose: cancel,
});
cancellation = cancellationToken?.onCancellationRequested(cancel);
subscriptions.add(connection);
return connection;
} catch (error) {
await close().catch(() => {});
throw error;
}
}
+22 -2
View File
@@ -7,6 +7,7 @@ import {
} from "@netcatty/plugin-sdk";
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
import { connectByteStream } from "./byteStreamClient.mjs";
let terminalInterceptorTransportPromise;
@@ -164,6 +165,7 @@ function createHostClient(transport) {
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
if (!request) return false;
pending.delete(`${typeof message.id}:${String(message.id)}`);
request.cancellation?.dispose();
if (message.error) {
request.reject(new PluginError(
pluginErrorNameFromRpcError(message.error),
@@ -174,17 +176,24 @@ function createHostClient(transport) {
return true;
}
function request(method, params, options = {}) {
if (options.cancellationToken?.isCancellationRequested) return Promise.reject(new PluginError("cancelled", "Plugin request cancelled"));
const id = nextId;
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
return new Promise((resolve, reject) => {
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
const cancellationId = options.cancellationToken ? `browser-${id}` : undefined;
const pendingRequest = { resolve, reject };
pending.set(`${typeof id}:${String(id)}`, pendingRequest);
transport.post({
jsonrpc: "2.0",
id,
method,
params,
...(cancellationId === undefined ? {} : { cancellationId }),
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
});
pendingRequest.cancellation = options.cancellationToken?.onCancellationRequested(() => {
notify("$/cancelRequest", { cancellationId });
});
});
}
function notify(method, params) {
@@ -192,7 +201,7 @@ function createHostClient(transport) {
}
function close() {
const error = new PluginError("unavailable", "Plugin host disconnected");
for (const request of pending.values()) request.reject(error);
for (const request of pending.values()) { request.cancellation?.dispose(); request.reject(error); }
pending.clear();
}
return { accept, request, notify, close };
@@ -376,10 +385,19 @@ function createPluginContext(config, client, runtimeApi) {
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
};
const network = {
connect: (request, cancellationToken) => connectByteStream({ client, streams: runtimeApi.streams, subscriptions,
enabledFeatures: config.enabledFeatures }, request, cancellationToken),
request: (request) => client.request("network.request", request, {
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
}),
};
const ai = Object.fromEntries(["openChat", "startTurn", "readTurn", "stopTurn", "closeChat"].map((operation) => [
operation, (params) => client.request(`ai.${operation}`, params, operation === "readTurn" ? { deadlineMs: 65_000 } : {}),
]));
const selectedContext = {
list: (params) => client.request("context.selected.list", params),
read: (params) => client.request("context.selected.read", params),
};
const filesystem = {
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
path: filePath,
@@ -553,6 +571,8 @@ function createPluginContext(config, client, runtimeApi) {
secrets,
credentials,
network,
ai,
selectedContext,
ui,
clipboard,
filesystem,
+17 -11
View File
@@ -51,7 +51,6 @@ function resolveDefaultRuntimeKind({ plugin }) {
class RuntimeSupervisor {
constructor(options) {
this.electron = options.electron;
this.logService = options.logService;
this.database = options.database;
this.packageStore = options.packageStore;
this.protocol = options.protocol;
@@ -203,7 +202,6 @@ class RuntimeSupervisor {
quarantinedAt: details.quarantinedAt,
});
}
if (identity) this.logService?.audit(identity.logger.identity, { operation: `runtime.${status}`, authorization: "not-required", result: ["error", "quarantined"].includes(status) ? "error" : "ok" });
this.#emitRuntimeState(identity, status, details);
}
@@ -241,6 +239,10 @@ class RuntimeSupervisor {
signal.throwIfAborted();
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
// Capture the host's opaque source owner before async package/placement work.
// Test doubles without provenance APIs cannot use source-bound capabilities.
const sourceOwner = plugin.source && typeof this.database.privateOwner === "function"
? this.database.privateOwner(pluginId) : null;
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
if (plugin.manifest.main.data) throw new Error("Static theme packs have no executable runtime");
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
@@ -275,12 +277,14 @@ class RuntimeSupervisor {
const currentPlugin = this.database.getActivePlugin(pluginId);
if (
!currentPlugin?.enabled
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|| (plugin.source && currentPlugin.source?.id !== plugin.source.id)
|| currentPlugin.activeVersion !== plugin.activeVersion
|| currentPlugin.runtime.quarantinedAt != null
) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
}
let logger;
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
let runtime;
const identity = Object.freeze({
pluginId,
@@ -288,14 +292,17 @@ class RuntimeSupervisor {
runtimeId: randomUUID(),
runtimeKind: kind,
securityPrincipal,
...(sourceOwner === null ? {} : { sourceOwner, sourceId: plugin.source.id }),
manifest: freezeJson(structuredClone(plugin.manifest)),
packageRoot,
get logger() { return logger; },
logger,
assertCurrent: () => {
const active = this.database.getActivePlugin(pluginId);
if (
this.runtimes.get(pluginId) !== runtime
|| !active?.enabled
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|| (plugin.source && active.source?.id !== plugin.source.id)
|| active.activeVersion !== plugin.activeVersion
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
) {
@@ -303,13 +310,11 @@ class RuntimeSupervisor {
}
},
});
logger = this.logService?.createLogger(identity, plugin) ?? new PluginLogger();
const routes = this.rpcRegistry.createRoutes(identity);
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
const onProtocolError = (error) => {
logger.audit({ operation: "runtime.protocol", authorization: "not-reached", result: "error", errorCode: error?.code });
return logger.write("error", "Plugin protocol violation", { error: error?.message });
};
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
error: error?.message ?? String(error),
});
runtime = kind === "browser"
? this.runtimeFactories.browser({
electron: this.electron,
@@ -545,9 +550,9 @@ class RuntimeSupervisor {
}
async #cleanupRuntime(identity) {
identity?.logger?.close();
if (!identity || !this.runtimeCleanup) return;
await this.runtimeCleanup(Object.freeze({
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
pluginId: identity.pluginId,
pluginVersion: identity.pluginVersion,
runtimeId: identity.runtimeId,
@@ -580,6 +585,7 @@ class RuntimeSupervisor {
runtimeId: identity.runtimeId,
runtimeKind: identity.runtimeKind,
securityPrincipal: identity.securityPrincipal,
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
});
}
@@ -656,7 +662,7 @@ class RuntimeSupervisor {
if (typeof runtime.openStream !== "function") {
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
}
const stream = await runtime.openStream(streamId, windowBytes);
const stream = await runtime.openStream(streamId, windowBytes, { onClose: options?.onClose });
try {
const current = await this.#getRunningRuntime(pluginId);
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
@@ -1002,30 +1002,3 @@ test('data-only themes never create a runtime on startup or explicit start',asyn
await assert.rejects(fixture.supervisor.start(manifest.id),/no executable runtime/);assert.equal(fixture.runtimeOptions.length,0);
await fixture.supervisor.shutdown();
});
test("runtime cleanup closes diagnostic loggers before stale runtime callbacks can write", async (t) => {
const { PluginLogger } = require("./pluginLogger.cjs");
const loggers = [];
const events = [];
const fixture = createFixture(t, () => ({
async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
async stop() {},
}), { logService: {
createLogger(identity) {
const logger = new PluginLogger({ identity });
loggers.push(logger);
return logger;
},
audit(_identity, event) { events.push(event.operation); },
} });
t.after(() => fixture.supervisor.shutdown());
await fixture.supervisor.start(fixture.manifest.id);
assert.equal(loggers[0].closed, false);
await fixture.supervisor.stop(fixture.manifest.id);
assert.equal(loggers[0].closed, true);
assert.deepEqual(events.slice(0, 3), ["runtime.starting", "runtime.running", "runtime.stopped"]);
await fixture.runtimeOptions[0].logger.write("info", "synthetic stale callback");
await fixture.supervisor.start(fixture.manifest.id);
await fixture.supervisor.shutdown();
assert.equal(loggers[1].closed, true);
});
+14 -1
View File
@@ -158,6 +158,8 @@ class PluginStreamRouter {
this.outgoing.delete(frame.streamId);
for (const pending of outgoing.queue) pending.reject(new Error(`Plugin stream cancelled: ${frame.streamId}`));
outgoing.queue.length = 0;
outgoing.queuedBytes = 0;
this.#notifyOutgoingClosed(outgoing, "cancel");
return;
}
const state = this.incoming.get(frame.streamId);
@@ -233,7 +235,8 @@ class PluginStreamRouter {
state.onClose = handlers.onClose;
}
async openOutgoing(streamId, windowBytes) {
async openOutgoing(streamId, windowBytes, options = {}) {
if (options.onClose !== undefined && typeof options.onClose !== "function") throw new TypeError("Stream onClose must be a function");
if (
this.closed
|| this.outgoing.has(streamId)
@@ -268,6 +271,7 @@ class PluginStreamRouter {
queuedBytes: 0,
closed: false,
terminalKind: null,
onClose: options.onClose,
};
this.outgoing.set(streamId, state);
try {
@@ -347,6 +351,13 @@ class PluginStreamRouter {
for (const pending of state.queue) pending.reject(error);
state.queue.length = 0;
state.queuedBytes = 0;
this.#notifyOutgoingClosed(state, error);
}
#notifyOutgoingClosed(state, reason) {
if (state.closeNotified) return;
state.closeNotified = true;
try { state.onClose?.(reason); } catch {}
}
#finishOutgoing(state, kind) {
@@ -373,6 +384,7 @@ class PluginStreamRouter {
) {
this.outgoing.delete(state.streamId);
}
this.#notifyOutgoingClosed(state, kind);
}
}
@@ -413,6 +425,7 @@ class PluginStreamRouter {
for (const state of this.outgoing.values()) {
state.closed = true;
for (const pending of state.queue) pending.reject(error);
this.#notifyOutgoingClosed(state, error);
}
this.outgoing.clear();
for (const state of this.incoming.values()) {
+11
View File
@@ -5,6 +5,17 @@ const test = require("node:test");
const { PluginStreamRouter } = require("./streamRouter.cjs");
test("outgoing close hook observes idle peer cancellation exactly once", async () => {
const closed = [];
const router = new PluginStreamRouter({ send() {} });
const stream = await router.openOutgoing("idle-close-hook", 1024, { onClose: (reason) => closed.push(reason) });
await router.accept({ frame: { streamId: "idle-close-hook", sequence: 1, kind: "cancel" } });
stream.cancel();
router.close();
assert.deepEqual(closed, ["cancel"]);
assert.equal(router.outgoing.size, 0);
});
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
const sent = [];
const received = [];
+2 -2
View File
@@ -208,9 +208,9 @@ class UtilityPluginRuntime {
this.router.notify(method, params);
}
openStream(streamId, windowBytes) {
openStream(streamId, windowBytes, options) {
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
return this.router.streams.openOutgoing(streamId, windowBytes);
return this.router.streams.openOutgoing(streamId, windowBytes, options);
}
attachTerminalInterceptor(descriptor, port) {
+7 -5
View File
@@ -108,6 +108,13 @@ function createPreloadApi(ctx) {
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
respondPluginAIHost: (response) => ipcRenderer.invoke("netcatty:plugins:ai-host-response", response),
onPluginAIHostRequest: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:ai-host-request", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:ai-host-request", listener);
},
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
onPluginInteraction: (callback) => {
@@ -128,11 +135,6 @@ function createPreloadApi(ctx) {
}),
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
getPluginLogStatus: () => ipcRenderer.invoke("netcatty:plugins:log-status"),
configurePluginLogs: (settings) => ipcRenderer.invoke("netcatty:plugins:log-configure", settings),
openPluginLogFolder: () => ipcRenderer.invoke("netcatty:plugins:log-open"),
exportPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-export"),
clearPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-clear"),
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
onPluginThemesChanged: (callback) => {
@@ -0,0 +1,23 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const { createPreloadApi } = require("./api.cjs");
test("trusted AI preload uses dedicated typed channels and disposes subscriptions", async () => {
const calls = []; const ipcRenderer = new EventEmitter();
ipcRenderer.invoke = async (...args) => { calls.push(args); return true; };
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
await api.setPluginAIHostReady(true);
const reply = { requestId: "request", actionToken: "token", error: true };
await api.respondPluginAIHost(reply);
assert.deepEqual(calls, [["netcatty:plugins:ai-host-ready", true], ["netcatty:plugins:ai-host-response", reply]]);
const received = []; const off = api.onPluginAIHostRequest((request) => received.push(request));
const request = { kind: "model.select" };
ipcRenderer.emit("netcatty:plugins:ai-host-request", {}, request); off();
ipcRenderer.emit("netcatty:plugins:ai-host-request", {}, { kind: "context.select" });
assert.deepEqual(received, [request]);
assert.doesNotMatch(fs.readFileSync(path.join(__dirname, "../plugins/runtime/viewPreload.cjs"), "utf8"), /ai-host-|PluginAIHost/);
});
+2
View File
@@ -0,0 +1,2 @@
.package/
*.ncpkg
+38
View File
@@ -0,0 +1,38 @@
# Selected Note Assistant
A minimal browser plugin that uses Netcatty's selected configured model and one
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
It has no tools, terminal commands, direct network access, Node runtime, or storage.
The page follows light/dark mode and accent updates through the existing
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
Missing tokens fall back to the standard sample palettes. Its theme subscription
is disposed on page hide and restored on page show.
An expired chat is reopened before the next explicit Send. If the configured
model changes or a request fails, use **Refresh model** to reopen the session,
then click **Send** yourself. Refresh keeps your draft and never retries model
inference. If a request failed after reaching the provider, check its outcome
before choosing to send it again.
Selected references expire too. If a selected Note expires or changes, click
**Choose reference**, confirm the Note in Netcatty, and select its fresh reference
in the dropdown before clicking **Send**. Refresh model does not share a Note
again. Re-selection keeps your draft and does not read content or run inference;
the new reference still requires its own content permission and text review.
The host must support `ai.model-invocation` and `context.selected-read`, with the
trusted renderer adapter connected. AI invocation, selected-summary enumeration,
and selected-content reading have separate optional permissions. The host shows
the exact selected text for review before the plugin or model receives it.
A menu or command ID grants no authority over a resource.
Build the six-file staging directory with `node build.mjs`, then pack `.package`
with Netcatty's plugin CLI. Build scripts are not included in the installed package.
The plugin uses only the canonical `context.ai` and `context.selectedContext` SDK
methods through its own declared view commands. No chat history is persisted.
For isolated QA, use the deterministic loopback provider and synthetic Note recipe
in `docs/plugin-platform/assistant-native-qa.md` in the Netcatty repository. Fixture
QA performs no model inference or paid API call. Native acceptance and the remaining
independent review are separate requirements.
+12
View File
@@ -0,0 +1,12 @@
import { URL } from 'node:url';
import { mkdir, copyFile, rm } from 'node:fs/promises';
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
// Build a six-file staging directory. Build scripts and source tooling are never
// part of the installed browser package.
const staging = new URL('./.package/', import.meta.url);
await rm(staging, { recursive: true, force: true });
await mkdir(new URL('./dist/', staging), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md', 'dist/index.js']) {
await copyFile(new URL(file, import.meta.url), new URL(file, staging));
}
@@ -0,0 +1,29 @@
{
"manifestVersion": 1,
"id": "com.netcatty.assistant",
"name": "assistant-example",
"displayName": "Selected Note Assistant",
"description": "Minimal sandbox assistant using a host-selected model and one explicitly selected reference. No tools or commands are executed.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"main": { "browser": "dist/index.js" },
"activationEvents": [ "onView:com.netcatty.assistant.panel" ],
"permissions": {
"required": [ "commands", "views" ],
"optional": [ "ai.invoke", "context.selected.list", "context.selected.read" ]
},
"features": { "required": [ "ai.model-invocation", "context.selected-read" ] },
"contributes": {
"commands": [
{ "id": "com.netcatty.assistant.open", "title": "Assistant: Open Chat" },
{ "id": "com.netcatty.assistant.contexts", "title": "Assistant: Selected References" },
{ "id": "com.netcatty.assistant.send", "title": "Assistant: Send" },
{ "id": "com.netcatty.assistant.read", "title": "Assistant: Read Stream" },
{ "id": "com.netcatty.assistant.stop", "title": "Assistant: Stop" },
{ "id": "com.netcatty.assistant.close", "title": "Assistant: Close Chat" }
],
"views": [ { "id": "com.netcatty.assistant.panel", "title": "Selected Note Assistant", "location": "modal", "entry": "panel.html" } ]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@netcatty/example-assistant-plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs"
}
}
+29
View File
@@ -0,0 +1,29 @@
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
* { box-sizing:border-box; }
body { margin:0; }
main { max-width:760px; padding:36px; margin:auto; }
header { margin-bottom:26px; }
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
#model { font-weight:600; }
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
textarea { resize:vertical; min-height:84px; }
form { margin:22px 0; }
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
button:disabled { opacity:.42; cursor:default; }
#choose-reference { margin-top:10px; }
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
.response { min-height:160px; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
footer { padding-top:8px; }
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
<title>Assistant example</title>
<link rel="stylesheet" href="panel.css">
</head>
<body>
<main>
<header><span class="badge">PLUGIN EXAMPLE</span><h1>Summarize a selected Note</h1><p>Use the model and reference shared by Netcatty.</p></header>
<section class="card"><label>Model selected in Netcatty</label><div id="model">Connecting...</div></section>
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p><button id="choose-reference" type="button" disabled>Choose reference</button></section>
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><button id="refresh" type="button" disabled>Refresh model</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
<section class="card response"><label>Response</label><pre id="response" aria-live="polite">Your response appears here.</pre><p id="usage" class="hint"></p></section>
<footer id="qa"></footer>
</main>
<script src="panel.js" defer></script>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
/* global document, window */
'use strict';
const element = (id) => document.getElementById(id);
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
// Follow the same seven public semantic tokens as the other browser samples.
// Only this page's own DOM is updated; no host settings or DOM access is used.
(() => {
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
let subscription;
const subscribe = () => {
if (subscription) return;
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
const root = document.documentElement;
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
root.dataset.theme = theme;
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
root.dataset.highContrast = String(Boolean(environment.highContrast));
for (const name of names) {
const value = environment.themeTokens?.[name];
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
else root.style.removeProperty(name);
}
});
};
subscribe();
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
window.addEventListener('pageshow', subscribe);
})();
let chat;
let turn;
let generation = 0;
let closed = false;
const references = new Map();
function busy(value) {
element('send').disabled = value || !chat;
element('stop').disabled = !value;
element('refresh').disabled = value;
element('choose-reference').disabled = value;
element('context').disabled = value;
element('prompt').disabled = value;
}
async function replaceChat(run) {
const previous = chat; chat = undefined;
if (previous) await invoke('close', { chatHandle: previous.chatHandle }).catch(() => {});
if (closed || run !== generation) return false;
const opened = await invoke('open');
if (closed || run !== generation) {
await invoke('close', { chatHandle: opened.chatHandle }).catch(() => {});
return false;
}
chat = opened;
element('model').textContent = chat.model;
return true;
}
async function selectReferences(run) {
const summaries = await invoke('contexts');
if (closed || run !== generation) return false;
references.clear();
const placeholder = document.createElement('option');
placeholder.value = ''; placeholder.textContent = 'No reference';
element('context').replaceChildren(placeholder);
for (const reference of summaries) {
if (reference.scope !== 'note') continue;
references.set(reference.handle, { handle: reference.handle, version: reference.version, expiresAt: reference.expiresAt });
const option = document.createElement('option');
option.value = reference.handle; option.textContent = reference.title;
element('context').append(option);
}
return true;
}
async function initialize() {
const run = ++generation;
busy(true);
try {
if (!await replaceChat(run)) return;
if (!await selectReferences(run)) return;
element('status').textContent = 'Ready';
} catch {
if (run !== generation) return;
const failedChat = chat; chat = undefined;
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
} finally { if (run === generation) busy(false); }
}
element('refresh').addEventListener('click', async () => {
if (closed || element('refresh').disabled) return;
const run = ++generation;
busy(true); element('status').textContent = 'Refreshing model...';
try {
if (await replaceChat(run)) element('status').textContent = 'Ready';
} catch {
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
} finally { if (run === generation) busy(false); }
});
element('choose-reference').addEventListener('click', async () => {
if (closed || element('choose-reference').disabled) return;
const run = ++generation;
busy(true); element('status').textContent = 'Choosing reference...';
try {
if (await selectReferences(run)) element('status').textContent = 'References updated. Select a reference, then Send.';
} catch {
if (run === generation) element('status').textContent = 'Reference selection ended. Choose reference again when access is available.';
} finally { if (run === generation) busy(false); }
});
element('compose').addEventListener('submit', async (event) => {
event.preventDefault();
if (!chat || element('send').disabled) return;
const reference = references.get(element('context').value);
if (reference && reference.expiresAt <= Date.now()) {
element('status').textContent = 'Selected reference expired. Choose reference again, then Send.';
return;
}
const run = ++generation;
busy(true); element('status').textContent = 'Streaming...';
element('response').textContent = ''; element('usage').textContent = '';
try {
// No inference has been requested yet, so an expired session can be
// replaced before this explicit Send. Never retry a failed send/read.
if (chat.expiresAt <= Date.now() && !await replaceChat(run)) return;
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
contexts: reference ? [{ handle: reference.handle, version: reference.version }] : [] });
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
turn = started.turnHandle;
for (;;) {
const chunk = await invoke('read', { turnHandle: turn });
if (run !== generation) return;
if (chunk.kind === 'end') break;
if (chunk.kind === 'text') element('response').textContent += chunk.text;
if (chunk.kind === 'usage') element('usage').textContent = `${chunk.inputTokens ?? '?'} input tokens · ${chunk.outputTokens ?? '?'} output tokens`;
}
element('status').textContent = 'Complete';
} catch {
if (run === generation) element('status').textContent = 'Request ended. Check access, choose reference again, or refresh the model before sending again.';
} finally {
if (run === generation) { turn = undefined; busy(false); }
}
});
element('stop').addEventListener('click', async () => {
if (element('stop').disabled) return;
const run = ++generation;
const stoppedTurn = turn; turn = undefined;
element('status').textContent = 'Stopping...'; element('stop').disabled = true;
try {
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
else if (chat) {
const stoppedChat = chat; chat = undefined;
await invoke('close', { chatHandle: stoppedChat.chatHandle });
}
} catch { /* Closing a revoked session may already have stopped the turn. */ }
finally { if (run === generation) { element('status').textContent = 'Stopped'; busy(false); } }
});
// QA supplies this text through the regular view message surface.
window.netcattyView.onDidReceiveMessage((message) => {
if (message?.qaFixture === true) element('qa').textContent = 'Isolated QA · deterministic local provider · no model inference or paid API';
});
window.addEventListener('pagehide', () => {
closed = true; ++generation;
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
});
void initialize();
+14
View File
@@ -0,0 +1,14 @@
// Ordinary browser runtime. The host SDK supplies the only privileged surface.
export default {
activate(context) {
const register = (name, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.netcatty.assistant.${name}`, handler,
));
register('open', () => context.ai.openChat({}));
register('contexts', async () => (await context.selectedContext.list({})).items);
register('send', (params) => context.ai.startTurn(params));
register('read', (params) => context.ai.readTurn(params));
register('stop', (params) => context.ai.stopTurn(params));
register('close', (params) => context.ai.closeChat(params));
},
};
+111
View File
@@ -0,0 +1,111 @@
# Run the disposable database example
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
example does not use saved SSH or AI credentials.
## Prepare the fixture and package
1. Check out the exact transport PR head and run `npm ci` followed by
`npm run build:plugin-runtime-deps`.
2. Create a dedicated internal Docker network. Do not alter an existing
network if this subnet is already in use.
```sh
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
docker run --detach --rm --name netcatty-plugin-db-qa \
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
```
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
to succeed. The container publishes no host ports and has no external route.
The empty administrative password exists only in this disposable container.
4. Open its local client with
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
Create the fixture below. Replace the password placeholder with a new
synthetic session password. Do not put a real password in this example.
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<new-synthetic-session-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
5. Build the normal browser package for this exact endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs \
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
```
The tool prints the package hash, not credentials. Its temporary staging
directory is removed. Only the manifest, browser bundle, and three view
assets enter the five-file package. No Node preparation tool, server setup,
dependency tree, or credential file enters the package.
If a different private subnet is needed, prepare a new package for its exact
fixture IP. Record the new package hash and review its endpoint permission.
The example page does not accept arbitrary hosts or SQL.
## Run automated entrypoint acceptance
From the repository root, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
The test creates its own isolated server, random session password, and temporary
package. It exercises both the transport core and the installed example entry
through the registered SDK/RPC path. It removes the container, network, package
and profile afterward. The in-process runtime harness does not prove native
browser sandbox behavior.
## Record native acceptance
1. Start the exact build with the existing sandbox settings and isolated
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
is a local import, not a claim of official maintenance.
2. Enable the plugin and open **Disposable database example** from its view
action. Record the plugin detail screen, source, version, and exact endpoint
resource. The plugin has a browser entry only.
3. Enter `plugin_demo` and the synthetic session password. The password input
is masked and clears before dispatch. Click **Connect and read** and deny
the endpoint prompt. The page must show a bounded failure and remain usable.
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
Do not record the password or administrative client session.
5. Revoke the stream grant in the manager, retry, and deny. Check that no
connection remains. Test **Cancel**, close/reopen the view, and disable the
plugin during a pending request. Restart with the same QA profile: the
plugin must require the session password again.
6. Inspect generic connection audit records. Record endpoint, open/close and
byte counts; verify that neither the password, SQL nor result body appears.
Verify the account's server-side write denial in the disposable server.
Save screenshots of install/details, denial, endpoint approval and the result,
plus a short recording of denial/retry/query/revoke/disable. This recipe does
not claim that native QA has run. The automated Node entrypoint harness is not
a BrowserWindow sandbox test.
The example database connection is plaintext only inside the disposable
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
real-socket certificate and hostname validation tests. Do not use this example
with real data or an existing database.
## Remove the fixture
Disable and uninstall the example, then remove the temporary container and
network:
```sh
docker rm --force netcatty-plugin-db-qa
docker network rm netcatty-plugin-db-qa
```
The container, its synthetic credential hashes, and data disappear. Remove the
prepared package and isolated QA profile after retaining the required evidence.
@@ -0,0 +1,34 @@
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
import { queryExample } from "./protocol.mjs";
import { endpoint } from "./fixture-config.mjs";
export default {
activate(context) {
let pending;
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.netcatty.database-minimal.${suffix}`, handler,
));
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
register("cancel", cancel);
register("endpoint", () => ({ endpoint }));
context.subscriptions.add({ dispose: cancel });
register("query", async (args) => {
if (pending) throw new Error("An example connection is already active");
if (!args || Array.isArray(args) || Object.keys(args).some((key) => !["username", "password"].includes(key))
|| typeof args.username !== "string" || typeof args.password !== "string") throw new Error("Enter disposable fixture credentials");
const cancellation = new CancellationTokenSource();
pending = cancellation;
let connection;
try {
connection = await context.network.connect({ endpoint }, cancellation.token);
return await queryExample({ read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
close: () => connection.close() }, args.username, args.password);
} finally {
args.password = "";
await connection?.close().catch(() => {});
cancellation.dispose();
pending = undefined;
}
});
},
};
@@ -0,0 +1 @@
export const endpoint = "tcp://database-fixture.invalid:3306";
@@ -0,0 +1,26 @@
{
"manifestVersion": 1,
"id": "com.netcatty.database-minimal",
"name": "database-minimal",
"displayName": "Disposable database example",
"description": "Connect to one prepared synthetic fixture and read one row.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["network.byte-streams"] },
"main": { "browser": "browser.js" },
"activationEvents": ["onCommand:com.netcatty.database-minimal.query", "onView:com.netcatty.database-minimal.panel"],
"permissions": {
"required": ["commands", "views"],
"optional": [{ "permission": "network.stream", "resources": ["tcp://database-fixture.invalid:3306"], "reason": "Connect only to the prepared disposable fixture." }]
},
"contributes": {
"commands": [
{ "id": "com.netcatty.database-minimal.query", "title": "Read one synthetic database row" },
{ "id": "com.netcatty.database-minimal.cancel", "title": "Cancel example connection" },
{ "id": "com.netcatty.database-minimal.endpoint", "title": "Show example endpoint" }
],
"views": [{ "id": "com.netcatty.database-minimal.panel", "title": "Disposable database example", "location": "modal", "entry": "panel.html" }]
}
}
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more