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
陈奇 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
45 changed files with 3942 additions and 143 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
@@ -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 };
}
@@ -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>;
}
+202 -45
View File
@@ -2,23 +2,29 @@
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: isolated host internals and fixture-tested transport reuse. Public contract,
production adapters, installable example, independent review, and native acceptance
are pending. This source is not ready to merge or release.
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.
## Proposed public boundaries
## Public boundaries
The proposed optional features are `ai.model-invocation` and
`context.selected-read`. The parent has not confirmed these names. No canonical
schema, generated contract, SDK, runtime peer, hostService, or installer changes
are included in this checkpoint.
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. The parent can map this
iterator onto the existing stream router after the canonical contract is agreed.
`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
@@ -32,7 +38,10 @@ or arbitrary entity-ID lookup.
| --- | --- |
| 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` |
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
| 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 |
@@ -44,8 +53,18 @@ This branch does not claim that workflow is complete.
## Implemented authority
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
`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`.
@@ -67,7 +86,10 @@ 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. SDK retries are
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.
@@ -85,25 +107,35 @@ 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.
## Required host adapters
## Connected host path
Production integration is blocked on these parent-owned contracts:
`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.
1. `resolveDataOwner(context)` must return the current private source owner from
host provenance. A plugin-supplied source value or plugin ID is insufficient.
2. `selectModel(context)` must return a trusted invocation adapter for the model
explicitly selected in the main UI. No provider configuration crosses RPC.
3. The renderer adapter must validate the current window, selected resource,
lifecycle generation, version, and owner before and after asynchronous reads.
Reconnect, tab or session close, navigation, lock, disable, and permission
revocation must invalidate handles and abort turns.
4. Summary enumeration, content reads, and model invocation need separate
permissions. Only the canonical schema can define their wire format.
Model invocation permission does not grant context or action permissions.
5. Host review must show selected content before it becomes model input.
A command-palette or menu ID is never resource authority.
6. The shared hostService adapter must register teardown and permission-revoke
hooks, and discard results if its renderer owner changes.
`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
@@ -134,19 +166,144 @@ 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 final focused domain run passes 16 tests. The transport suite passes three
tests, including the actual assistant panel's Send, streaming, usage, and Stop
handlers in JSDOM. The test bridge uses structured clones to model the existing
view command boundary. It does not replace installed-plugin or native coverage.
The wider plugin runtime run passes 556 tests with one skipped. The existing
provider regression suites pass 34 tests.
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.
The full repository typecheck fails in existing code, including provider tool-call
inference and plugin CLI test manifest literals. No new adapter type errors were
reported. The focused lint passes.
`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.
Native Electron launch aborts because the bundled `chrome-sandbox` is not owned
by root with mode 4755. No sandbox bypass was attempted. Native screenshots,
video, installed-plugin UI tests, packaged builds, and independent review are
pending on the parent's working desktop after shared integration. The panel
source alone is not evidence of native acceptance.
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,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.
+4 -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,10 +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, "Outgoing byte connections", "Exchange bytes with explicitly approved TCP or TLS endpoints. Sent data leaves this device.", "传出字节连接", "与明确批准的 TCP 或 TLS 端点交换字节。发送的数据会离开此设备。"],
"ai.invoke": [true, "Use the selected AI model", "Send plugin prompts to the model selected in Netcatty. Model usage may incur charges.", "使用选定的 AI 模型", "向 Netcatty 中选定的模型发送插件提示词。模型用量可能产生费用。"],
"context.selected.list": [true, "Selected item summaries", "Read titles and summaries of items currently selected in the host.", "选中项目摘要", "读取宿主当前选中项目的标题和摘要。"],
"context.selected.read": [true, "Read selected text", "Request selected text for review before sharing it with the plugin.", "读取选中文本", "请求选中文本,在分享给插件前由你确认。"],
"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,
@@ -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);
});
}
+15 -5
View File
@@ -30,6 +30,7 @@ class PluginAIContextService {
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);
@@ -49,10 +50,16 @@ class PluginAIContextService {
}
async #guard(context) {
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;
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) {
@@ -111,7 +118,10 @@ class PluginAIContextService {
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
return { items: await this.selected.summaries(context) };
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 = [];
@@ -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);
});
+45 -21
View File
@@ -28,14 +28,16 @@ function abortable(promise, signal) {
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
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;
@@ -53,13 +55,21 @@ class ModelInvocationBroker {
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");
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
// 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.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|| chat.owner !== binding.owner || chat.budgetOwner !== binding.budgetOwner
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
@@ -81,22 +91,33 @@ class ModelInvocationBroker {
async open(context) {
const binding = await this.#binding(context);
// Host chooses from configured models. Plugin parameters cannot override it.
const model = await this.selectModel(context);
await context.assertActive();
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();
this.#prune();
if (this.closed || this.chats.size >= this.limits.chats
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
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");
}
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 };
// 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) {
@@ -137,14 +158,14 @@ class ModelInvocationBroker {
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
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.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
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.owner, rate);
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);
@@ -243,6 +264,7 @@ class ModelInvocationBroker {
#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);
@@ -250,14 +272,16 @@ class ModelInvocationBroker {
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 chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
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 };
@@ -3,11 +3,12 @@ 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 = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
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"); } };
@@ -17,7 +18,8 @@ function fixture(options = {}) {
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, selectModel: async () => model, contexts,
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; } };
@@ -209,3 +211,76 @@ test("malformed context cannot publish a turn or interrupt two-handle runtime re
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);
}
});
+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);
});
@@ -5641,6 +5641,705 @@
"entries",
"diagnostics"
]
},
"PluginAIHostHandleRef": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAIHostContextRef": {
"type": "object",
"required": [
"hostHandle",
"version"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"PluginAIHostModelStart": {
"type": "object",
"required": [
"hostHandle",
"prompt",
"contexts",
"maxOutputTokens",
"maxChunkBytes"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"prompt": {
"type": "string",
"minLength": 1,
"maxLength": 16384
},
"contexts": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"maxLength": 16384
}
},
"maxOutputTokens": {
"type": "integer",
"minimum": 1,
"maximum": 2048
},
"maxChunkBytes": {
"type": "integer",
"minimum": 4,
"maximum": 4096
}
},
"additionalProperties": false
},
"PluginAIHostSelectedSummary": {
"type": "object",
"required": [
"hostHandle",
"scope",
"title",
"version"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"scope": {
"enum": [
"workspace",
"tab",
"session",
"host",
"note"
]
},
"title": {
"type": "string",
"maxLength": 160
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"PluginAIHostModelEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"minLength": 0,
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"outputTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"totalTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": false
}
]
},
"PluginAIHostRequest": {
"description": "Main-process requests to the current trusted application renderer. Never exposed to plugin views.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.select"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.current"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.start"
},
"payload": {
"$ref": "#/$defs/PluginAIHostModelStart"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.next"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.stop"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.release"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.select"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.current"
},
"payload": {
"$ref": "#/$defs/PluginAIHostContextRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.read"
},
"payload": {
"$ref": "#/$defs/PluginAIHostContextRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "runtime.release"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "request.cancel"
},
"payload": {
"type": "object",
"required": [
"requestId"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PluginAIHostResult": {
"oneOf": [
{
"type": "object",
"required": [
"hostHandle",
"label"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"current"
],
"properties": {
"current": {
"type": "boolean"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done"
],
"properties": {
"done": {
"const": true
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"event"
],
"properties": {
"done": {
"const": false
},
"event": {
"$ref": "#/$defs/PluginAIHostModelEvent"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 5,
"items": {
"$ref": "#/$defs/PluginAIHostSelectedSummary"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"maxLength": 16384
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
},
"additionalProperties": false
}
]
},
"PluginAIHostResponse": {
"description": "One-use reply from the current trusted application renderer. Provider configuration, keys and diagnostics are excluded.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"result"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"result": {
"$ref": "#/$defs/PluginAIHostResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"error"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"error": {
"const": true
}
},
"additionalProperties": false
}
]
}
}
}
+11 -7
View File
@@ -29,7 +29,7 @@ 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 { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.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");
@@ -98,6 +98,7 @@ function createPluginHostService(options) {
let interactionService;
let byteStreamService;
let aiContextService;
let aiHostAdapter;
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -265,14 +266,16 @@ function createPluginHostService(options) {
});
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: options.pluginAIHostAdapter ?? {
assertActive: (context) => context.assertActive(),
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
listSelected: () => [],
reviewText: () => false,
},
hostAdapter: aiHostAdapter,
});
aiContextService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
@@ -454,6 +457,7 @@ function createPluginHostService(options) {
try { interactionService?.shutdown(); } catch {}
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
database.close();
throw error;
}
@@ -2,3 +2,7 @@
// 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");
+7
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) => {
@@ -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
+35 -13
View File
@@ -1,16 +1,38 @@
# Assistant example source
# Selected Note Assistant
This panel contains the minimal example UI: a host-selected model, one explicitly
selected Note, Send, streamed text, Stop, and token usage. It uses the existing
`netcattyView.executeCommand` surface. It has no direct network access or Node API.
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 runtime entry and package manifest are pending the parent's shared contract
and SDK decision. This directory is not yet an installable plugin.
The command names in `panel.js` are example-local command IDs, not host RPC methods.
A menu or command ID grants no authority to read a resource.
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.
The QA harness binds these commands to `ModelInvocationBroker` and
`SelectedContextAuthority`. The production runtime must bind them through the
approved canonical SDK methods, with host permission checks and provenance.
The local deterministic provider proves the pipeline. It does not perform model
inference, use paid credentials, or read a real user's data.
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.
+9 -1
View File
@@ -1,4 +1,12 @@
import { mkdir, copyFile } from 'node:fs/promises';
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));
}
+16 -12
View File
@@ -1,25 +1,29 @@
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
: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:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
.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:#a9b4c7; margin:4px 0; }
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
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:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
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; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
.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; }
#stop { background:transparent; border-color:#56657b; }
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
#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:#9aabc3; margin-top:8px; }
.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; } }
+2 -2
View File
@@ -11,8 +11,8 @@
<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></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><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
<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>
+111 -26
View File
@@ -2,44 +2,124 @@
'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 initialize() {
try {
chat = await invoke('open');
element('model').textContent = chat.model;
const summaries = await invoke('contexts');
for (const reference of summaries) {
if (reference.scope !== 'note') continue;
references.set(reference.handle, { handle: reference.handle, version: reference.version });
const option = document.createElement('option');
option.value = reference.handle; option.textContent = reference.title;
element('context').append(option);
}
element('status').textContent = 'Ready';
busy(false);
} catch {
element('status').textContent = 'Choose a model and grant access in Netcatty.';
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 {
const reference = references.get(element('context').value);
// 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 ? [reference] : [] });
contexts: reference ? [{ handle: reference.handle, version: reference.version }] : [] });
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
turn = started.turnHandle;
for (;;) {
@@ -51,27 +131,32 @@ element('compose').addEventListener('submit', async (event) => {
}
element('status').textContent = 'Complete';
} catch {
if (run === generation) element('status').textContent = 'Request ended. Check access or try again.';
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 () => {
++generation;
if (element('stop').disabled) return;
const run = ++generation;
const stoppedTurn = turn; turn = undefined;
element('status').textContent = 'Stopping...';
element('status').textContent = 'Stopping...'; element('stop').disabled = true;
try {
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
else if (chat) {
await invoke('close', { chatHandle: chat.chatHandle });
chat = await invoke('open');
const stoppedChat = chat; chat = undefined;
await invoke('close', { chatHandle: stoppedChat.chatHandle });
}
}
finally { element('status').textContent = 'Stopped'; busy(false); }
} 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', () => { if (turn) invoke('stop', { turnHandle: turn }).catch(() => {}); });
window.addEventListener('pagehide', () => {
closed = true; ++generation;
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
});
void initialize();
Vendored
+3
View File
@@ -458,6 +458,9 @@ declare global {
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
respondPluginAIHost?(response: import("@netcatty/plugin-contract").PluginAIHostResponse): Promise<boolean>;
onPluginAIHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAIHostRequest) => void): () => void;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
@@ -0,0 +1,33 @@
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
import type { ProviderConfig } from '../types';
import { netcattyBridge } from '../../services/netcattyBridge';
// Host renderer only. Read the existing configured provider/model without
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
export function getConfiguredPluginModel() {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
return selectedModel(providers);
}
function selectedModel(providers: ProviderConfig[]) {
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
if (!provider) return null;
const config = { ...provider, defaultModel: activeModelId || provider.defaultModel };
if (!config.defaultModel) return null;
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
}
// The existing main provider transport needs its host-owned registry for both
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
// or replace the registry with a plugin-supplied URL / just one selected entry.
export async function prepareConfiguredPluginModel(revision: string) {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
const bridge = netcattyBridge.require();
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
throw Error('Host provider configuration is unavailable');
}
}
@@ -42,6 +42,10 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
assert.equal(payload.messages.some((message: { role: string }) => message.role === 'system'), false);
assert.match(JSON.stringify(payload.messages), /Synthetic retry plan/);
response.writeHead(200, { 'content-type': 'text/event-stream' });
if (payload.messages.at(-1).content === 'Large fixture') {
response.end(`data: ${'X'.repeat(140_000)}\n\n`);
return;
}
const send = (delta: Record<string, unknown>, finish: string | null = null) => response.write(`data: ${JSON.stringify({
id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'deterministic-fixture',
choices: [{ index: 0, delta, finish_reason: finish }],
@@ -97,7 +101,7 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
const contexts = new SelectedContextAuthority({ resolveDataOwner });
const note = await contexts.share({ scope: 'note', version: '1', title: 'Synthetic Note', isCurrent: () => true,
readText: () => 'Synthetic retry plan: add retry button.' }, context);
const broker = new ModelInvocationBroker({ resolveDataOwner, contexts, selectModel: () => model });
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: () => 'synthetic-source-owner', contexts, selectModel: () => model });
t.after(() => broker.shutdown());
const chat = await broker.open(context);
const turn = await broker.start({ chatHandle: chat.chatHandle, prompt: 'Summarize this Note.',
@@ -162,6 +166,31 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(element('response').textContent, 'Synthetic plan: ');
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
const dummyBody = (prompt: string) => JSON.stringify({ model: 'deterministic-fixture', stream: true,
messages: [{ role: 'user', content: 'Synthetic retry plan' }, { role: 'user', content: prompt }] });
const sender = { isDestroyed: () => false, send: () => {} };
const streamOptions = { headers: { authorization: 'Bearer __IPC_SECURED__', 'content-type': 'application/json' }, body: dummyBody('Stop fixture') };
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'plugin-model_runtime-a_one', false);
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'plugin-model_runtime-b_two', false);
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'sdk_agent_synthetic', false);
assert.equal(aiBridge._getActiveStreamCountForTests(), 3);
assert.equal(aiBridge.cancelPluginAIStreamsForSender({}, 'runtime-a'), 0);
assert.equal(aiBridge.cancelPluginAIStreamsForSender(sender, 'runtime-a'), 1);
assert.equal(aiBridge._getActiveStreamCountForTests(), 2);
assert.equal(aiBridge.cancelPluginAIStreamsForSender(sender), 1);
assert.equal(aiBridge._getActiveStreamCountForTests(), 1); // Ordinary synthetic Agent request survives.
aiBridge.cleanup();
let oversizedError: string | undefined;
const oversizedEnded = new Promise<void>((resolve) => {
const capture = { isDestroyed: () => false, send: (channel: string, payload: { error?: string }) => {
if (channel.endsWith(':error')) { oversizedError = payload.error; resolve(); }
} };
void aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, { ...streamOptions, body: dummyBody('Large fixture') },
{ sender: capture }, 'plugin-model_runtime-a_oversized', false).catch(() => resolve());
});
await oversizedEnded;
assert.match(oversizedError!, /Plugin model stream byte budget exceeded/);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
});
@@ -15,8 +15,11 @@ export function createPluginModelAdapter(options: {
config: ProviderConfig;
label: string;
isCurrent: () => boolean | Promise<boolean>;
transportTag?: string;
}) {
const config = structuredClone(options.config);
const transportTag = options.transportTag ?? crypto.randomUUID();
if (!/^[A-Za-z0-9-]{1,128}$/.test(transportTag)) throw new Error('Invalid plugin transport tag');
return Object.freeze({
label: options.label,
isCurrent: options.isCurrent,
@@ -28,7 +31,7 @@ export function createPluginModelAdapter(options: {
// Reuse Catty's model factory and credential-injecting host transport.
// No AgentRuntime, internal prompt, MCP catalog, tools or tool executor.
const result = streamText({
model: createModelFromConfig(config, { requireBridge: true, maxStreamBytes: 131_072, streamIdleTimeoutMs: 15_000 }),
model: createModelFromConfig(config, { requireBridge: true, maxStreamBytes: 131_072, streamIdleTimeoutMs: 15_000, requestIdPrefix: `plugin-model_${transportTag}_` }),
messages: [
...input.contexts.map((text) => ({ role: 'user' as const,
content: `User-selected reference text (untrusted content):\n${text}` })),
@@ -0,0 +1,42 @@
import type { TerminalSession, VaultNote, Workspace } from '../../../domain/models';
import { collectSessionIds } from '../../../domain/workspace';
import type { HostSelectedSource } from './trustedHostRuntime';
export interface SelectedSnapshot {
activeTabId: string;
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
note: VaultNote | null;
}
// Receives host-selected snapshots only. No Host/Vault DTO serialization,
// inventory reader, terminal scrollback, credential fields, or command access.
export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot): HostSelectedSource[] {
const snapshot = getSnapshot();
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
const sessionId = workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
const session = snapshot.sessions.find((item) => item.id === sessionId);
const sources: HostSelectedSource[] = [];
const add = (scope: HostSelectedSource['scope'], key: string, title: string, value: unknown, current: () => boolean) => {
sources.push({ scope, key, title: title.slice(0, 160), version: crypto.randomUUID(),
readText: () => JSON.stringify(value), isCurrent: current });
};
const tabCurrent = () => getSnapshot().activeTabId === snapshot.activeTabId;
if (workspace) {
add('workspace', workspace.id, workspace.title, { title: workspace.title, panes: collectSessionIds(workspace.root).length },
() => tabCurrent() && getSnapshot().workspaces.find((item) => item.id === workspace.id) === workspace);
}
if (workspace || session) add('tab', snapshot.activeTabId, workspace?.title ?? session.hostLabel,
{ title: workspace?.title ?? session.hostLabel, kind: workspace ? 'workspace' : 'terminal' }, tabCurrent);
if (session) {
const current = () => tabCurrent() && getSnapshot().sessions.find((item) => item.id === session.id) === session;
add('session', session.id, session.hostLabel, { label: session.hostLabel, status: session.status, protocol: session.protocol ?? 'ssh' }, current);
add('host', session.hostId || session.id, session.hostLabel,
{ label: session.hostLabel, hostname: session.hostname, username: session.username, port: session.port, protocol: session.protocol ?? 'ssh' }, current);
}
const note = snapshot.note;
if (note) sources.push({ key: note.id, scope: 'note', title: note.title.slice(0, 160), version: crypto.randomUUID(),
readText: () => note.content,
isCurrent: () => getSnapshot().note === note });
return sources;
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTrustedPluginAIHostRuntime } from './trustedHostRuntime';
import { buildSelectedPluginSources, type SelectedSnapshot } from './selectedSources';
const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerId: 'custom' as const, enabled: true, defaultModel: 'fixture', apiKey: 'synthetic-not-a-secret' }, label: 'Synthetic model', revision: '1' };
test('renderer-owned model handles never serialize provider config and cannot cross runtimes', async () => {
let active = true;
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
const model = await runtime.handle('model.select', {}, 'runtime-a') as { hostHandle: string; label: string };
assert.doesNotMatch(JSON.stringify(model), /synthetic-not-a-secret|apiKey|providerId|config/);
await assert.rejects(runtime.handle('model.current', model, 'runtime-b'));
assert.deepEqual(await runtime.handle('model.current', model, 'runtime-a'), { current: true });
await runtime.handle('model.release', model, 'runtime-a');
await assert.rejects(runtime.handle('model.current', model, 'runtime-a'));
active = false; await assert.rejects(runtime.handle('model.select', {}, 'runtime-a')); runtime.shutdown();
});
test('selected metadata uses explicit safe projections and current resource identity', async () => {
const session = { id: 's', hostId: 'h', hostLabel: 'Synthetic host', hostname: 'synthetic.invalid', username: 'test', status: 'connected' as const,
password: 'NEVER_SERIALIZE_PASSWORD', privateKey: 'NEVER_SERIALIZE_KEY', startupCommand: 'NEVER_SERIALIZE_COMMAND', environmentVariables: { PRIVATE: 'NEVER_SERIALIZE_ENV' } };
const note = { id: 'n', title: 'Synthetic Note', content: 'Synthetic note body', createdAt: 1, updatedAt: 1 };
let snapshot: SelectedSnapshot = { activeTabId: 's', sessions: [session], workspaces: [], note };
const sources = buildSelectedPluginSources(() => snapshot);
assert.deepEqual(sources.map((source) => source.scope), ['tab', 'session', 'host', 'note']);
for (const source of sources) {
assert.equal(await source.isCurrent(), true);
assert.doesNotMatch(await source.readText(), /NEVER_SERIALIZE/);
}
snapshot = { ...snapshot, activeTabId: 'another' };
assert.equal(await sources.find((source) => source.scope === 'session')!.isCurrent(), false);
snapshot = { ...snapshot, note: { ...note, content: 'Changed' } };
assert.equal(await sources.find((source) => source.scope === 'note')!.isCurrent(), false);
});
test('selected summaries never read content; late resource changes and runtime release deny content', async () => {
let current = true; let reads = 0; let resolve: (text: string) => void;
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, isActive: () => true,
chooseSelected: async () => [{ key: 'note', scope: 'note', title: 'Synthetic', version: '1', isCurrent: () => current,
readText: () => { reads++; return new Promise<string>((r) => { resolve = r; }); } }] });
const result = await runtime.handle('context.select', {}, 'runtime-a') as { items: Array<{ hostHandle: string; version: string }> };
assert.equal(reads, 0); const reference = result.items[0];
await assert.rejects(runtime.handle('context.read', reference, 'runtime-b'));
const reading = runtime.handle('context.read', reference, 'runtime-a');
await new Promise(setImmediate); current = false; resolve!('Late synthetic text'); await assert.rejects(reading);
runtime.releaseRuntime('runtime-a'); await assert.rejects(runtime.handle('context.current', reference, 'runtime-a')); runtime.shutdown();
});
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
let resume!: () => void; let current = syntheticModel;
const waiting = new Promise<void>((resolve) => { resume = resolve; });
const controller = new AbortController();
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
const rejection = assert.rejects(selecting);
if (action === 'release') runtime.releaseRuntime('runtime-a');
if (action === 'cancel') controller.abort();
if (action === 'change') current = { ...syntheticModel, revision: '2' };
if (action === 'shutdown') runtime.shutdown();
resume(); await rejection;
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
runtime.shutdown();
}
});
@@ -0,0 +1,153 @@
import type { PluginAIHostModelEvent, PluginAIHostResult } from '@netcatty/plugin-contract';
import { createPluginModelAdapter } from './modelInvocationAdapter';
import type { ProviderConfig } from '../types';
// Trusted application renderer only. This runtime is never loaded by a plugin.
export interface HostSelectedSource {
key: string;
scope: 'workspace' | 'tab' | 'session' | 'host' | 'note';
title: string;
version: string;
readText: () => string | Promise<string>;
isCurrent: () => boolean | Promise<boolean>;
}
type Model = ReturnType<typeof createPluginModelAdapter>;
type Iterator = ReturnType<Model['invoke']>;
type Owned<T> = T & { runtimeId: string };
export function createTrustedPluginAIHostRuntime(options: {
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
prepareModel: (revision: string) => Promise<void>;
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
isActive: () => boolean;
}) {
const models = new Map<string, Owned<{ model: Model }>>();
const selections = new Set<{ runtimeId: string; active: boolean }>();
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
let closed = false;
const assertActive = () => { if (closed || !options.isActive()) throw Error('Host is unavailable'); };
const owned = <T extends { runtimeId: string }>(collection: Map<string, T>, key: unknown, runtimeId: string): T => {
const item = typeof key === 'string' ? collection.get(key) : undefined;
if (!item || item.runtimeId !== runtimeId) throw Error('Host handle is unavailable');
return item;
};
const cancel = (handle: string) => {
const turn = turns.get(handle);
if (!turn) return;
turns.delete(handle); turn.controller.abort();
void turn.iterator.return(undefined).catch(() => {});
};
const releaseRuntime = (runtimeId: string) => {
for (const selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
};
return {
// The main-process bridge supplies canonical envelopes, validates the sender,
// and binds each request to its current window and plugin runtime.
async handle(kind: string, payload: Record<string, unknown>, runtimeId: string, signal: AbortSignal = new AbortController().signal): Promise<PluginAIHostResult> {
if (kind === 'runtime.release') { releaseRuntime(runtimeId); return { ok: true }; }
assertActive();
if (kind === 'model.select') {
const selected = options.getModel();
if (!selected || !selected.config.enabled || !selected.config.defaultModel) throw Error('Choose a configured model first');
if (models.size + selections.size >= 32) throw Error('Model handle limit reached');
const selection = { runtimeId, active: true };
selections.add(selection);
try {
await options.prepareModel(selected.revision);
signal.throwIfAborted(); assertActive();
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
const handle = crypto.randomUUID();
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
models.set(handle, { runtimeId, model });
return { hostHandle: handle, label: selected.label };
} finally { selections.delete(selection); }
}
if (kind === 'model.release') { owned(models, payload.hostHandle, runtimeId); models.delete(String(payload.hostHandle)); return { ok: true }; }
if (kind === 'model.current') return { current: await owned(models, payload.hostHandle, runtimeId).model.isCurrent() };
if (kind === 'model.start') {
const { model } = owned(models, payload.hostHandle, runtimeId);
if (!await model.isCurrent()) throw Error('Selected model changed');
if (turns.size >= 32 || typeof payload.prompt !== 'string' || new TextEncoder().encode(payload.prompt).byteLength > 16_384
|| !Array.isArray(payload.contexts) || payload.contexts.length > 4 || payload.contexts.some((text) => typeof text !== 'string')
|| !Number.isInteger(payload.maxOutputTokens) || Number(payload.maxOutputTokens) < 1 || Number(payload.maxOutputTokens) > 2048
|| !Number.isInteger(payload.maxChunkBytes) || Number(payload.maxChunkBytes) < 4 || Number(payload.maxChunkBytes) > 4096
|| payload.contexts.reduce((bytes, text) => bytes + new TextEncoder().encode(text).byteLength, new TextEncoder().encode(payload.prompt).byteLength) > 16_384) {
throw Error('Model input exceeds its limit');
}
assertActive();
const controller = new AbortController(); const handle = crypto.randomUUID();
const iterator = model.invoke({ prompt: payload.prompt, contexts: payload.contexts,
maxOutputTokens: Number(payload.maxOutputTokens), maxChunkBytes: Number(payload.maxChunkBytes), signal: controller.signal });
turns.set(handle, { runtimeId, iterator, controller, reading: false });
return { hostHandle: handle };
}
if (kind === 'model.next') {
const turn = owned(turns, payload.hostHandle, runtimeId);
if (turn.reading) throw Error('Model read already pending');
turn.reading = true;
const abort = () => cancel(String(payload.hostHandle));
signal.addEventListener('abort', abort, { once: true });
try {
signal.throwIfAborted();
const result = await turn.iterator.next();
assertActive();
turn.controller.signal.throwIfAborted();
if (turns.get(String(payload.hostHandle)) !== turn) throw Error('Model turn ended');
if (result.done) { turns.delete(String(payload.hostHandle)); return { done: true }; }
// Unknown usage values are omitted before canonical JSON validation.
if (!result.value || typeof result.value !== 'object') throw Error('Invalid model stream event');
const value = result.value as Record<string, unknown>;
let event: PluginAIHostModelEvent;
if (value.kind === 'text' && typeof value.text === 'string') event = { kind: 'text', text: value.text };
else if (value.kind === 'usage') {
event = { kind: 'usage' };
for (const key of ['inputTokens', 'outputTokens', 'totalTokens'] as const) {
const tokens = value[key];
if (tokens !== undefined) {
if (typeof tokens !== 'number' || !Number.isSafeInteger(tokens) || tokens < 0) throw Error('Invalid model usage');
event[key] = tokens;
}
}
} else throw Error('Unsupported model event');
return { done: false, event };
} catch { cancel(String(payload.hostHandle)); throw Error('Model invocation failed'); }
finally { turn.reading = false; signal.removeEventListener('abort', abort); }
}
if (kind === 'model.stop') { owned(turns, payload.hostHandle, runtimeId); cancel(String(payload.hostHandle)); return { ok: true }; }
if (kind === 'context.select') {
const selected = await options.chooseSelected(signal, runtimeId);
signal.throwIfAborted();
assertActive();
if (selected.length > 5 || new Set(selected.map((source) => source.scope)).size !== selected.length) throw Error('Selected scope limit reached');
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
if (sources.size + selected.length > 128) throw Error('Selected source limit reached');
const items = [];
for (const source of selected) {
if (!await source.isCurrent()) throw Error('Selected context changed');
const hostHandle = crypto.randomUUID(); sources.set(hostHandle, { runtimeId, source });
items.push({ hostHandle, scope: source.scope, title: source.title, version: source.version });
}
return { items };
}
if (kind === 'context.current' || kind === 'context.read') {
const { source } = owned(sources, payload.hostHandle, runtimeId);
const current = payload.version === source.version && await source.isCurrent();
assertActive();
if (kind === 'context.current') return { current };
if (!current) throw Error('Selected context changed');
const text = await source.readText();
assertActive();
if (!await source.isCurrent() || typeof text !== 'string' || new TextEncoder().encode(text).byteLength > 16_384) throw Error('Selected text is unavailable');
return { text };
}
throw Error('Unsupported host operation');
},
releaseRuntime,
shutdown() { closed = true; for (const selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
};
}
+3 -1
View File
@@ -23,6 +23,8 @@ export interface ProviderRequestContext {
requireBridge?: boolean;
/** Optional aggregate SSE payload cap, enforced before renderer buffering. */
maxStreamBytes?: number;
/** Host-owned tag for targeted plugin model transport teardown. */
requestIdPrefix?: string;
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
transformOpenAIChatAssistantFields?: (
fields: Array<OpenAIChatAssistantFields | undefined>,
@@ -494,7 +496,7 @@ export function createBridgeFetchForSDK(
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const requestId = `${requestContext?.requestIdPrefix ?? ''}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
@@ -3503,6 +3503,705 @@
"entries",
"diagnostics"
]
},
"PluginAIHostHandleRef": {
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
"PluginAIHostContextRef": {
"type": "object",
"required": [
"hostHandle",
"version"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"PluginAIHostModelStart": {
"type": "object",
"required": [
"hostHandle",
"prompt",
"contexts",
"maxOutputTokens",
"maxChunkBytes"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"prompt": {
"type": "string",
"minLength": 1,
"maxLength": 16384
},
"contexts": {
"type": "array",
"maxItems": 4,
"items": {
"type": "string",
"maxLength": 16384
}
},
"maxOutputTokens": {
"type": "integer",
"minimum": 1,
"maximum": 2048
},
"maxChunkBytes": {
"type": "integer",
"minimum": 4,
"maximum": 4096
}
},
"additionalProperties": false
},
"PluginAIHostSelectedSummary": {
"type": "object",
"required": [
"hostHandle",
"scope",
"title",
"version"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"scope": {
"enum": [
"workspace",
"tab",
"session",
"host",
"note"
]
},
"title": {
"type": "string",
"maxLength": 160
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"PluginAIHostModelEvent": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"text"
],
"properties": {
"kind": {
"const": "text"
},
"text": {
"type": "string",
"minLength": 0,
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"const": "usage"
},
"inputTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"outputTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"totalTokens": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": false
}
]
},
"PluginAIHostRequest": {
"description": "Main-process requests to the current trusted application renderer. Never exposed to plugin views.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.select"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.current"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.start"
},
"payload": {
"$ref": "#/$defs/PluginAIHostModelStart"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.next"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.stop"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "model.release"
},
"payload": {
"$ref": "#/$defs/PluginAIHostHandleRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.select"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.current"
},
"payload": {
"$ref": "#/$defs/PluginAIHostContextRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "context.read"
},
"payload": {
"$ref": "#/$defs/PluginAIHostContextRef"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "runtime.release"
},
"payload": {
"$ref": "#/$defs/PluginAIEmptyParams"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"runtimeId",
"kind",
"payload"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"runtimeId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9-]+$"
},
"kind": {
"const": "request.cancel"
},
"payload": {
"type": "object",
"required": [
"requestId"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
]
},
"PluginAIHostResult": {
"oneOf": [
{
"type": "object",
"required": [
"hostHandle",
"label"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string",
"maxLength": 160
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"hostHandle"
],
"properties": {
"hostHandle": {
"type": "string",
"format": "uuid"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"current"
],
"properties": {
"current": {
"type": "boolean"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done"
],
"properties": {
"done": {
"const": true
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"done",
"event"
],
"properties": {
"done": {
"const": false
},
"event": {
"$ref": "#/$defs/PluginAIHostModelEvent"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"items"
],
"properties": {
"items": {
"type": "array",
"maxItems": 5,
"items": {
"$ref": "#/$defs/PluginAIHostSelectedSummary"
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"text"
],
"properties": {
"text": {
"type": "string",
"maxLength": 16384
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"ok"
],
"properties": {
"ok": {
"type": "boolean"
}
},
"additionalProperties": false
}
]
},
"PluginAIHostResponse": {
"description": "One-use reply from the current trusted application renderer. Provider configuration, keys and diagnostics are excluded.",
"oneOf": [
{
"type": "object",
"required": [
"requestId",
"actionToken",
"result"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"result": {
"$ref": "#/$defs/PluginAIHostResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"requestId",
"actionToken",
"error"
],
"properties": {
"requestId": {
"type": "string",
"format": "uuid"
},
"actionToken": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{32}$"
},
"error": {
"const": true
}
},
"additionalProperties": false
}
]
}
}
}
@@ -366,7 +366,11 @@ test("required resource-scoped permissions declare activation-time bounds", () =
assert.equal(validate({
...validManifest,
permissions: { optional: [permission] },
}), permission !== "network.stream", JSON.stringify(validate.errors));
}), permission !== "network.stream", `${permission}: ${JSON.stringify(validate.errors)}`);
assert.equal(validate({
...validManifest,
permissions: { optional: [{ permission, resources: [resource] }] },
}), true, JSON.stringify(validate.errors));
}
});
@@ -531,6 +531,140 @@ export type PluginAIContextLimits = {"contexts":4,"inputBytes":16384,"chunkBytes
export type PluginAIEmptyParams = Record<string, never>;
export type PluginAIHostContextRef = {
hostHandle: string;
version: string;
};
export type PluginAIHostHandleRef = {
hostHandle: string;
};
export type PluginAIHostModelEvent = ({
kind: "text";
text: string;
}) | ({
kind: "usage";
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
});
export type PluginAIHostModelStart = {
hostHandle: string;
prompt: string;
contexts: Array<string>;
maxOutputTokens: number;
maxChunkBytes: number;
};
export type PluginAIHostRequest = ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.select";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.current";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.start";
payload: PluginAIHostModelStart;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.next";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.stop";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.release";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.select";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.current";
payload: PluginAIHostContextRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.read";
payload: PluginAIHostContextRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "runtime.release";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "request.cancel";
payload: {
requestId: string;
};
});
export type PluginAIHostResponse = ({
requestId: string;
actionToken: string;
result: PluginAIHostResult;
}) | ({
requestId: string;
actionToken: string;
error: true;
});
export type PluginAIHostResult = ({
hostHandle: string;
label: string;
}) | ({
hostHandle: string;
}) | ({
current: boolean;
}) | ({
done: true;
}) | ({
done: false;
event: PluginAIHostModelEvent;
}) | ({
items: Array<PluginAIHostSelectedSummary>;
}) | ({
text: string;
}) | ({
ok: boolean;
});
export type PluginAIHostSelectedSummary = {
hostHandle: string;
scope: "workspace" | "tab" | "session" | "host" | "note";
title: string;
version: string;
};
export type PluginAIModelInvocationFeature = "ai.model-invocation";
export type PluginAIStartTurnParams = {
+224
View File
@@ -0,0 +1,224 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
async function fixture(t, options = {}) {
const root = path.resolve(__dirname, "../examples/plugins/assistant");
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
t.after(() => dom.window.close());
const stylesheet = dom.window.document.createElement("style");
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
dom.window.document.head.append(stylesheet);
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
const calls = [];
const environmentListeners = new Set(); let environment;
const element = (id) => dom.window.document.getElementById(id);
dom.window.Date.now = options.clock ?? (() => now);
Object.defineProperty(dom.window, "netcattyView", { value: {
onDidReceiveMessage: () => () => {},
onDidChangeEnvironment: (listener) => {
environmentListeners.add(listener);
if (environment) {
const replay = environment;
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
}
return { dispose: () => environmentListeners.delete(listener) };
},
executeCommand: async (id, params) => {
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
if (options.commands) return options.commands(operation, structuredClone(params));
if (operation === "open") {
const count = ++opens;
if (count > 1 && options.opening) await options.opening;
return { chatHandle: `chat-${count}`, model: `Synthetic model ${count}`, expiresAt: now + 300_000 };
}
if (operation === "contexts") return [];
if (operation === "close" || operation === "stop") return { ok: true };
if (operation === "send") {
if (params.chatHandle === "chat-1" && (changed || now >= 300_000)) throw Error("Session unavailable");
modelCalls++;
if (options.uncertain) throw Error("Reply lost after invocation");
return { turnHandle: "synthetic-turn" };
}
if (operation === "read") return { kind: "end", sequence: 0 };
throw Error(`Unexpected operation ${operation}`);
},
} });
const waitFor = async (condition) => {
const deadline = Date.now() + 2_000;
while (!condition()) {
if (Date.now() >= deadline) throw Error(`Panel state timed out: ${element("status").textContent}`);
await new Promise(setImmediate);
}
};
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
await waitFor(() => element("status").textContent === "Ready");
element("prompt").value = "Synthetic draft must survive session recovery";
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
environmentListeners: () => environmentListeners.size,
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
}
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
const f = await fixture(t); const root = f.window.document.documentElement;
const computed = () => f.window.getComputedStyle(root);
const calls = f.calls.length;
assert.equal(computed().colorScheme, "light dark");
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
assert.equal(f.environmentListeners(), 1);
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
assert.equal(root.style.getPropertyValue("--private-token"), "");
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
assert.equal(root.style.colorScheme, "dark");
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
assert.equal(root.style.getPropertyValue("--muted"), "");
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
assert.equal(root.style.colorScheme, "light dark");
f.window.dispatchEvent(new f.window.Event("pageshow"));
await new Promise(setImmediate);
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
});
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
const f = await fixture(t); f.timeTo(300_001); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 2);
assert.deepEqual(f.calls.filter((call) => call.operation === "send").map((call) => call.params.chatHandle), ["chat-2"]);
assert.equal(f.modelCalls(), 1);
assert.equal(f.element("status").textContent, "Complete");
assert.match(f.element("prompt").value, /must survive/);
});
test("changed-model rejection offers explicit reopen without automatically resending", async (t) => {
const f = await fixture(t); f.changeModel(); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 1); assert.equal(f.modelCalls(), 0);
assert.ok(f.element("refresh"), "A changed model needs an explicit session refresh control");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 0);
assert.match(f.element("model").textContent, /model 2/);
assert.match(f.element("prompt").value, /must survive/);
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(f.modelCalls(), 1);
});
test("uncertain inference outcome is never automatically retried by session refresh", async (t) => {
const f = await fixture(t, { uncertain: true }); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.modelCalls(), 1); assert.equal(f.opens(), 1);
assert.ok(f.element("refresh"), "Recovery must reopen only, never retry inference");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 1);
assert.match(f.element("prompt").value, /must survive/);
});
test("Stop during expired-session reopening discards the late session without sending", async (t) => {
let resume; const opening = new Promise((resolve) => { resume = resolve; });
t.after(() => resume());
const f = await fixture(t, { opening }); f.timeTo(300_001); f.send();
await f.waitFor(() => f.opens() === 2);
f.element("stop").click(); resume();
await f.waitFor(() => f.calls.some((call) => call.operation === "close" && call.params.chatHandle === "chat-2"));
assert.equal(f.modelCalls(), 0); assert.equal(f.element("status").textContent, "Stopped");
assert.match(f.element("prompt").value, /must survive/);
});
test("expired real selected Note requires explicit re-selection, then fresh content permission and review", async (t) => {
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
let contentAllowed = false; let reviewAllowed = false;
const grants = []; const reviews = [];
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
const service = new PluginAIContextService({
database: {
getActivePlugin: () => ({ source, enabled: true, activeVersion: caller.pluginVersion, runtime: { status: "running" } }),
getRetainedPluginSource: () => source, privateOwner: () => "synthetic-private-owner", publicPluginId: () => caller.pluginId,
},
getRuntimeIdentity: () => ({ ...caller, runtimeKind: "browser" }),
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: async (_caller, grant) => {
grants.push(grant); if (!contentAllowed) throw Error("Synthetic content grant denied");
} },
hostAdapter: {
assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
modelCalls++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; })();
} }),
listSelected: async () => {
selections++;
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
readText: () => { reads++; return "Synthetic selected Note body"; } }];
},
reviewText: async (text) => { reviews.push(text); return reviewAllowed; },
},
});
service.selected.clock = () => now; service.broker.clock = () => now;
t.after(() => service.shutdown());
const commands = {
open: () => service.open({}, caller), contexts: async () => (await service.list({}, caller)).items,
send: (params) => service.start(params, caller), read: (params) => service.read(params, caller),
stop: (params) => service.stop(params, caller), close: (params) => service.close(params, caller),
};
const f = await fixture(t, { clock: () => now, commands: (operation, params) => commands[operation](params) });
const selector = f.element("context"); const oldHandle = selector.options[1].value;
selector.value = oldHandle;
now = 300_001;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(f.calls.filter((call) => call.operation === "send").length, 0);
assert.equal(selections, 1); assert.equal(modelCalls, 0); assert.equal(reads, 0);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(selections, 1); // Refresh model must not silently share the Note again.
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.ok(f.element("choose-reference"));
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
assert.equal(selections, 2); assert.equal(selector.value, "");
const newHandle = selector.options[1].value;
assert.notEqual(newHandle, oldHandle);
const [fresh] = await service.selected.summaries(caller);
assert.equal(fresh.handle, newHandle); assert.equal(fresh.expiresAt, 600_001);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
assert.equal(reads, 0); assert.equal(grants.length, 0); assert.equal(modelCalls, 0);
selector.value = newHandle;
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
contentAllowed = true;
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
assert.equal(reviews.length, 1); assert.equal(reviews[0].text, "Synthetic selected Note body"); assert.equal(modelCalls, 0);
reviewAllowed = true;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 1); assert.equal(reviews.length, 2);
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
&& grant.resources[0] === `selected:${newHandle}:v1`));
assert.match(f.element("prompt").value, /must survive/);
// Repeat with both lifetimes expired, without manually refreshing the model.
now = 600_002;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(modelCalls, 1); assert.equal(selections, 2);
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
const repeatedHandle = selector.options[1].value;
assert.notEqual(repeatedHandle, newHandle); assert.equal(selector.value, "");
selector.value = repeatedHandle;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 2); assert.equal(grants.at(-1).resources[0], `selected:${repeatedHandle}:v1`);
await assert.rejects(service.selected.assertCurrent({ handle: newHandle, version: "v1" }, caller));
assert.match(f.element("prompt").value, /must survive/);
});
@@ -0,0 +1,55 @@
import http from 'node:http';
import { pathToFileURL } from 'node:url';
// Isolated QA only: deterministic SSE, no model, outbound request, credentials,
// disk persistence, or request-body/header logging.
export function createAssistantProviderFixture({ delayMs = 250 } = {}) {
const counts = { requests: 0, completed: 0, aborted: 0 };
const server = http.createServer(async (request, response) => {
if (request.method === 'GET' && request.url === '/fixture/status') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ fixture: true, inference: false, ...counts })); return;
}
if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { response.writeHead(404); response.end(); return; }
let body = ''; let bytes = 0;
try {
for await (const chunk of request) {
bytes += chunk.length;
if (bytes > 65_536) { response.writeHead(413); response.end(); return; }
body += chunk;
}
const payload = JSON.parse(body);
if (payload.model !== 'deterministic-fixture' || payload.stream !== true || payload.tools !== undefined || payload.tool_choice !== undefined
|| !Array.isArray(payload.messages) || payload.messages.some((message) => message.role === 'system' || message.role === 'tool')
|| Number(payload.max_tokens ?? payload.max_completion_tokens ?? 2048) > 2048) {
response.writeHead(400, { 'content-type': 'application/json' }); response.end('{"error":{"message":"Fixture accepts only a tool-free synthetic chat stream"}}'); return;
}
} catch { response.writeHead(400); response.end(); return; }
counts.requests++;
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' });
const send = (delta, finish = null) => response.write(`data: ${JSON.stringify({ id: 'deterministic-fixture', object: 'chat.completion.chunk', created: 1,
model: 'deterministic-fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
send({ role: 'assistant' });
const parts = ['Synthetic plan: ', 'add a retry button ', 'and show ', 'a connection ', 'status label.'];
let index = 0; let finished = false;
const timer = setInterval(() => {
if (index < parts.length) { send({ content: parts[index++] }); return; }
send({}, 'stop');
response.write(`data: ${JSON.stringify({ id: 'deterministic-fixture', object: 'chat.completion.chunk', created: 1,
model: 'deterministic-fixture', choices: [], usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 } })}\n\n`);
finished = true; counts.completed++; clearInterval(timer); response.end('data: [DONE]\n\n');
}, delayMs);
response.once('close', () => { clearInterval(timer); if (!finished) counts.aborted++; });
});
return { server, counts };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const position = process.argv.indexOf('--port');
const port = position === -1 ? 9217 : Number(process.argv[position + 1]);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('Invalid fixture port');
const { server } = createAssistantProviderFixture();
server.listen(port, '127.0.0.1', () => process.stdout.write(`DETERMINISTIC_FIXTURE http://127.0.0.1:${port}/v1\nSTATUS http://127.0.0.1:${port}/fixture/status\nNo model inference or paid API.\n`));
server.on('error', () => { process.stderr.write('Fixture could not bind its loopback port.\n'); process.exitCode = 1; });
process.once('SIGINT', () => server.close()); process.once('SIGTERM', () => server.close());
}
@@ -0,0 +1,25 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { once } = require("node:events");
test("deterministic native fixture streams synthetic text, rejects tools, and records cancellation without content logs", async (t) => {
const { createAssistantProviderFixture } = await import("./plugin-assistant-provider-fixture.mjs");
const { server, counts } = createAssistantProviderFixture({ delayMs: 10 });
server.listen(0, "127.0.0.1"); await once(server, "listening");
t.after(() => { server.closeAllConnections(); server.close(); });
const endpoint = `http://127.0.0.1:${server.address().port}`;
const body = { model: "deterministic-fixture", stream: true, max_tokens: 100, messages: [{ role: "user", content: "Synthetic" }] };
const request = (payload, signal) => fetch(`${endpoint}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), signal });
const response = await request(body);
const text = await response.text(); assert.match(text, /Synthetic plan/); assert.match(text, /total_tokens/);
assert.equal(counts.completed, 1);
assert.equal((await request({ ...body, tools: [{}] })).status, 400);
assert.equal((await request({ ...body, messages: [{ role: "system", content: "Forbidden" }] })).status, 400);
const controller = new AbortController(); const pending = await request(body, controller.signal);
const reader = pending.body.getReader(); await reader.read(); controller.abort();
for (let attempts = 0; counts.aborted === 0 && attempts < 50; attempts++) await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(counts.aborted, 1);
const status = await (await fetch(`${endpoint}/fixture/status`)).json();
assert.deepEqual(status, { fixture: true, inference: false, requests: 2, completed: 1, aborted: 1 });
});