Compare commits

..
Author SHA1 Message Date
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +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
67 changed files with 3990 additions and 235 deletions

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ jobs:
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
+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,84 @@
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 } 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,
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 = () => {
+161
View File
@@ -0,0 +1,161 @@
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('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
// 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 { 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[] = [];
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 });
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/);
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
return { ok: status.statusCode === 200, ...status };
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
return handler({ sender }, structuredClone(payload));
};
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' }]));
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() {} }),
} });
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 }));
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
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;
send(); 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/);
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);
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|apiKey|privateOwner|permissionMode|mcp/);
} finally {
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>;
}
+130 -43
View File
@@ -2,23 +2,25 @@
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 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 +34,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,6 +49,12 @@ This branch does not claim that workflow is complete.
## Implemented authority
`PluginAISourceBinding` authenticates the current browser runtime before reading
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
database and sync implementation are unchanged. Source origin does not imply
that a package is officially maintained.
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
@@ -67,7 +78,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 +99,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 +158,82 @@ 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 |
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 independent review was
blocked by the review tool/model restriction. That review is incomplete and has
not been retried through another route. CI is not a substitute for that review.
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.
+120
View File
@@ -0,0 +1,120 @@
# 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
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
## 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.
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 independent review was
blocked by the review tool/model restriction. That review remains incomplete and
has not been retried through another route. CI and native evidence do not replace
that missing review.
@@ -1,5 +1,13 @@
# Byte stream acceptance evidence
This is the historical report published by the transport owner at #3988
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
and missing AI wiring describe that branch and environment. The current owner
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
[the integration handoff](byte-stream-native-qa.md) for current checks, the
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
recipe is not applicable to the occupied GUI machine without Docker.
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
+33 -15
View File
@@ -1,9 +1,16 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
The original transport checkpoint is #3988
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
The original transport branch remains untouched. Follow the
[current native handoff](byte-stream-native-qa.md) for the integrated package.
The late transport checkpoint `f1410f8` is subsequently preserved through an
ordinary merge. [The comparison record](late-transport-integration.md) lists its
remaining changes and the conflict resolutions that preserve the two P2 fixes
and newer connected AI files.
## Registered transport
@@ -51,7 +58,7 @@ Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Additive AI interface for the parallel owner
## Integrated AI interface
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
@@ -59,13 +66,12 @@ and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
and optional lifecycle callbacks. These callbacks are never exposed to plugin
views. The default adapter rejects model invocation, returns no selected
resources, and refuses text review. The AI owner still owns real renderer
wiring and model/context UX. A registered synthetic adapter test is not proof
of that remaining production wiring.
The connected host renderer and shared permission descriptions from #3986 are
preserved. Its owner documents the adapter and existing configured model path in
[the AI checkpoint](ai-context-wip.md), with a
[synthetic native assistant recipe](assistant-native-qa.md). No further
independent review of AI internals was performed. The prior review denial remains
unresolved and has not been retried through another route.
## Database example
@@ -106,6 +112,18 @@ connection-management subsystem. See the example README for native setup.
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
sample hash, and remaining native/exact-head review work. Do not disable the
Chromium sandbox to obtain that evidence.
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
Its regression mocks interface lookup and uses a fake dialer. It does not retry
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
Cancellation now discards buffered browser bytes, settles producer-cancelled
credit once, and retains terminal producer state only until outstanding credit
arrives. Existing sequence, schema, and negotiated-window checks still reject
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
and runtime shutdown, without another scheduler.
Normal native BrowserWindow and screenshot/video acceptance remain pending.
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
relax endpoint restrictions to obtain that evidence.
@@ -0,0 +1,176 @@
# Test integrated byte streams on the native desktop
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
## Prepare the desktop and fixture
Wait until the approved native GUI machine is free from SSH acceptance work.
Use a fresh disposable app profile, a working display, and the platform's normal
Electron sandbox. Keep the existing endpoint policy and system security settings.
The database pass requires a disposable MariaDB fixture at a reachable private
IPv4 address. The address must not belong to the GUI machine. The current native
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
already approved separate Docker host or disposable database machine with an
existing permitted network path. Do not install new software or create local
forwarding exceptions as part of this recipe. If no fixture is available, record
the database pass as pending.
There is also a separately labeled
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
It has only been prepared and checked offline. The parent must obtain exact
package and endpoint authorization before any install, permission activation,
or connection. Its authentication and TLS requirements remain unverified. This
candidate does not replace the owned fixture regression or prove that the GUI
can reach the coding container's private database.
The registered automated fixture uses
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
It runs on an internal Docker network, publishes no host ports, generates a
disposable password, and removes its container and network. On a prepared Linux
executor with that recognized image, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
That test exercises the protocol, broker, and installed package's SDK/RPC path
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
have its operator create only
synthetic data and a dedicated SELECT-only account. Run this SQL using the
fixture's administration channel, replacing the password placeholder with a
fresh disposable password that is never included in screenshots or logs:
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
within the approved isolated fixture network. Verify INSERT denial independently
through the fixture's client. The plugin intentionally offers only its fixed read.
## Build and identify the packages
Use Node 22 or later and the lockfile. Run these commands in sequence:
```sh
npm ci
npm run test:plugin-runtime
npm run test:plugin-contract
npm run test:plugin-examples
npm run generate:plugin-contract
npm run check:plugin-contract
npm run generate:capability-tools
npm run check:codex-app-server-schema
npm run lint
npm run build
git diff --exit-code
```
For the reproducible packaging-only example, use the fixed synthetic endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The two five-file packages built on Node 24.19.0 with the locked dependencies
match byte for byte. The current owned-fixture package SHA-256 is
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
fixture database, query, and opt-in live regression are retained.
The six-file assistant package retains SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
The preparation tool removes temporary build paths from its minified browser
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
and record that package's new hash. Preparation does not dial the endpoint.
Build the assistant and follow its existing
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
synthetic credential marker, and synthetic Note. No paid model call is required.
The AI internals were integrated without another independent review. The earlier
review denial remains in effect and is not replaced by these tests.
## Record the installed browser path
1. Launch the integrated app on the approved desktop with a new disposable
`--user-data-dir`. Capture the full app window and record the exact commit.
2. In Settings > Plugins, import the database package. Capture its ID, version,
package hash, source identity, browser entry, and exact endpoint permission.
The import must leave the plugin disabled and optional access ungranted.
3. Enable it and open **Disposable database example**. Deny the first endpoint
request. Capture the failure and verify the runtime remains usable.
4. Retry with only the disposable fixture credentials and allow the exact
endpoint. Capture the one-row result and the source-aware permission prompt.
The password field must clear when the request starts.
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
delay offered by the fixture operator. Do not add a host protocol scheduler
or change the query. Confirm the request stops and a fresh read still works.
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
failure and a new request prompts again. Disable, re-enable, close the view,
and restart the app. No old connection or result may resume.
7. Record the assistant's denial, exact-text review, streamed result, usage,
Stop, and post-revocation recovery through its linked recipe. Use the same
integrated head and preserve its separate permission descriptions.
8. Remove only the synthetic plugins and profile. Have the fixture operator
remove the disposable account, data, container, and network. Stop the synthetic
model provider.
## Record the remaining evidence
Save the exact head, app platform/version, package hashes, ordered commands and
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
synthetic Node runtime integration, and actual native sandbox acceptance.
The address regression uses a mocked `os.networkInterfaces()` result and a fake
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
regression uses the registered package, host service, SDK, RPC, and real loopback
echo fixture under the existing test-only override. This override is unavailable
through the production service constructor. Neither test certifies the native
BrowserWindow sandbox or a live database pass on the GUI machine.
The integration executor completed these checks in order on 2026-10-26:
| Check | Result |
| --- | --- |
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
| Plugin contract generate and drift check | Passed, no generated changes |
| Capability tool generation and drift check | Passed, no generated changes |
| Codex App Server schema check | Passed |
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
| Database repeat packaging and both package validations | Passed |
The late transport merge moves the opt-in database test into the example directory.
Run its panel and protocol tests explicitly with
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
still requires explicit opt-in and the native Electron suite remains separate.
This executor has Docker, but the pinned MariaDB image
is not cached. No image was installed and no new live database run is claimed.
Native GUI acceptance remains pending on the occupied desktop. Full repository
tests and platform packages run in the integration PR's exact-head CI. Record
their final workflow conclusions separately from these local results.
After the late transport merge and Rfam candidate preparation, the registered
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
and `npm run test:plugin-examples` has four passed and one Docker skip. The
example suite is now registered in CI. Public protocol checks use only synthetic
packet bytes and package preparation; no Rfam connection or permission was
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
head whose two workflows already succeeded.
@@ -0,0 +1,70 @@
# Reconcile the late transport checkpoint
The integration owner is #3993, branch
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
The original transport owner later published #3988 at
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
The source checkpoint is already an ancestor of #3993.
## Keep the remaining transport changes
The late delta is not entirely superseded. An ordinary merge retains its
published history and these useful changes:
- Attach cleanup cancels a producer returned after runtime release exactly
once. The new regression fails against the old #3993 service and passes
with the late service change. The test uses a loopback fixture and a fake
stream producer. It does not enumerate real interfaces.
- Service tests cover source replacement before permission publication and
during a pending read. A router test checks one close notification for idle
peer cancellation. The installed entrypoint also checks consumer cancellation.
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
manifest checks require the corresponding AI/context feature declarations.
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
- Database-specific testing moves out of `electron/plugins` into the minimal
example. The opt-in test also installs its real package and checks the
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
evidence, not a new live pass on the current integration head.
- The example panel test checks password clearing and text-only result output.
## Resolve overlap in favor of the integrated behavior
| Overlap | Resolution |
| --- | --- |
| Marketplace source files and lockfile | Already identical to the late checkpoint |
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
| Assistant preparation | Retain the newer six-file staging build |
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
The original reports are retained in
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
AI wiring status nor their environment-specific broad-test failures replace the
connected implementation and exact-head CI evidence on #3993. No main merge,
force-push, endpoint-policy relaxation, AI internal review retry, or release is
part of this reconciliation.
## Check the reconciled source
The merged transport source passed 627 registered runtime tests: 626 passed and
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
example panel passed; its opt-in database test remained skipped. The late attach
regression failed against the old #3993 service with two cancel calls instead
of one, then passed after the merge.
A subsequent example-only Rfam candidate reuses the limited browser protocol
through two fixed entrypoints. Its offline tests check the original owned
fixture database/query and the fixed public database/query, plus rejection of
an unsupported auth plugin. No general query API is exported and no host
database code is introduced. The owned MariaDB live regression remains opt-in.
@@ -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);
});
}
+10 -4
View File
@@ -49,10 +49,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) {
@@ -0,0 +1,66 @@
"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);
});
+29 -14
View File
@@ -36,6 +36,7 @@ class ModelInvocationBroker {
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;
@@ -81,22 +82,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) {
@@ -243,6 +255,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 +263,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 };
@@ -209,3 +209,14 @@ 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();
});
+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);
});
+13 -7
View File
@@ -32,22 +32,28 @@ function canonicalizeStreamEndpoint(value) {
});
}
function normalizeStreamAddress(address) {
if (net.isIP(address) === 4) return address;
if (net.isIP(address) !== 6) return null;
const canonical = new URL(`http://[${address.split("%")[0]}]`).hostname.slice(1, -1);
if (!canonical.startsWith("::ffff:")) return canonical;
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
}
function isLocalControlAddress(address) {
address = normalizeStreamAddress(address);
if (net.isIP(address) === 4) {
const [a, b] = address.split(".").map(Number);
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
}
if (net.isIP(address) !== 6) return true;
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
if (canonical.startsWith("::ffff:")) {
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
}
const canonical = address;
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
return canonical.startsWith("::") || canonical.startsWith("ff")
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|| canonical.startsWith("2001:0:");
}
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress, normalizeStreamAddress };
@@ -38,13 +38,14 @@ test("installed browser entrypoint uses registered SDK/RPC streams, denial and r
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
activationEvents: [`onCommand:${id}.run`],
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
contributes: { commands: ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
contributes: { commands: ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
};
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(packageRoot, "browser.js"), `
export default { activate(context) {
let connection;
for (const name of ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
for (const name of ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "readCancelled") return connection.readable.read();
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
@@ -67,6 +68,10 @@ export default { activate(context) {
connection.readable.cancel();
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
}
if (name === "retain") {
await connection.writable.write(Uint8Array.of(42));
return Array.from(await connection.readable.read());
}
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
@@ -120,10 +125,11 @@ export default { activate(context) {
]);
assert.equal((await run("selected")).text, "synthetic selected text");
await assert.rejects(run("aiSpoof"), /invalid/u);
assert.deepEqual(await run("pending"), { connected: true });
assert.deepEqual(await run("retain"), [42]);
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
await assert.rejects(run("readCancelled"), /cancel/u);
await run("cancel");
await run("pending");
await service.manager.setEnabled(id, false);
+6 -1
View File
@@ -2,6 +2,7 @@
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const { normalizeStreamAddress } = require("./byteStreamEndpoint.cjs");
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { createDefinitionValidator } = require("./contractValidator.cjs");
@@ -27,7 +28,11 @@ class PluginByteStreamService {
permissionEngine: options.permissionEngine,
quotaManager: options.quotaManager,
// The public host service never forwards transport fixture overrides.
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
isControlEndpoint: (address) => {
const normalized = normalizeStreamAddress(address);
return normalized !== null && Object.values(os.networkInterfaces()).flat()
.some((item) => item?.address && normalizeStreamAddress(item.address) === normalized);
},
audit: (pluginId, operation, fields, state) => {
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
},
+34 -2
View File
@@ -1,10 +1,42 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const test = require("node:test");
const os = require("node:os");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
test("own-machine denial normalizes both interface and destination addresses without blocking private peers", async (t) => {
let interfaces;
t.mock.method(os, "networkInterfaces", () => interfaces);
const service = new PluginByteStreamService({
permissionEngine: { authorize: async () => {}, onDidRevoke: () => ({ dispose() {} }) },
});
t.after(() => service.shutdown());
const dialed = [];
service.broker.connectSocket = ({ host }) => {
dialed.push(host);
throw new Error("synthetic dialer reached");
};
const context = { pluginId: "synthetic", runtimeId: "synthetic", assertActive: async () => {} };
for (const ownAddress of ["10.0.0.8", "::ffff:10.0.0.8", "::ffff:a00:8"]) {
interfaces = { synthetic: [{ address: ownAddress }] };
for (const endpoint of ["tcp://10.0.0.8:43210", "tcp://[::ffff:10.0.0.8]:43210", "tcp://[::ffff:a00:8]:43210"]) {
await assert.rejects(service.broker.connect(endpoint, context), /control endpoints/u);
}
}
assert.deepEqual(dialed, []);
interfaces = { synthetic: [{ address: "2001:0db8:0:0:0:0:0:8" }] };
await assert.rejects(service.broker.connect("tcp://[2001:db8::8]:43210", context), /control endpoints/u);
interfaces = { synthetic: [{ address: "10.0.0.8" }] };
for (const endpoint of ["tcp://10.0.0.9:43210", "tcp://[::ffff:10.0.0.9]:43210", "tcp://192.168.1.9:43210"]) {
await assert.rejects(service.broker.connect(endpoint, context), /synthetic dialer reached/u);
}
assert.deepEqual(dialed, ["10.0.0.9", "::ffff:a00:9", "192.168.1.9"]);
assert.equal(service.broker.connections.size, 0);
});
const net = require("node:net");
const { once } = require("node:events");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
async function fixture(t, options = {}) {
@@ -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");
@@ -102,3 +102,70 @@ test("runtime byte streams reject concurrent reads and bound pending accept rese
endpoint.rejectReadable("reserved");
await assert.rejects(reserved, /cancel/i);
});
test("host cancellation discards buffered bytes and settles retained credit before the next read", async (t) => {
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
const { PluginStreamRouter } = require("./streamRouter.cjs");
const toHost = [];
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } });
const host = new PluginStreamRouter({ send(message) { endpoint.accept(message); } });
t.after(() => { endpoint.close(); host.close(); });
const pending = endpoint.acceptReadable("host-cancel");
const output = await host.openOutgoing("host-cancel", 1024);
const readable = await pending;
await output.write(Uint8Array.of(1));
assert.deepEqual([...await readable.read()], [1]);
await output.write(Uint8Array.of(2));
output.cancel();
const count = toHost.length;
await assert.rejects(readable.read(), /cancel/u);
await assert.rejects(readable.read(), /cancel/u);
assert.equal(toHost.length, count, "reads after cancellation must not return credit");
assert.equal(toHost.reduce((sum, message) => sum + message.frame.creditBytes, 0), 2);
for (const message of toHost) await host.accept(message);
assert.equal(host.outgoing.size, 0);
const otherPending = endpoint.acceptReadable("unrelated");
const other = await host.openOutgoing("unrelated", 1024);
await otherPending;
other.cancel();
});
test("runtime producer cancellation drains discarded credit and rejects buffered reads", async () => {
const { left, right } = await createPair();
const pending = right.acceptReadable("producer-cancel");
const writable = await left.openWritable("producer-cancel", 1024);
const readable = await pending;
await writable.write(Uint8Array.of(1));
assert.deepEqual([...await readable.read()], [1]);
await writable.write(Uint8Array.of(2));
writable.cancel();
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(readable.read(), /cancel/u);
left.close();
right.close();
});
test("browser producer cancellation settles host receive credit without resuming writes", async (t) => {
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
const { PluginStreamRouter } = require("./streamRouter.cjs");
const toHost = [];
let release;
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } }, { maxStreams: 1 });
const host = new PluginStreamRouter({
send(message) { endpoint.accept(message); },
onIncomingStream({ bind }) { bind({ onChunk(_chunk, callback) { release = callback; } }); return true; },
});
t.after(() => { endpoint.close(); host.close(); });
const output = await endpoint.openWritable("browser-cancel", 1024);
await host.accept(toHost.shift());
await output.write(new Uint8Array(1024));
await host.accept(toHost.shift());
const queued = output.write(Uint8Array.of(1));
output.cancel();
await assert.rejects(queued, /cancel/u);
await host.accept(toHost.shift());
release();
assert.equal(toHost.length, 0);
assert.equal(host.incoming.size, 0);
await endpoint.openWritable("after-cancel", 1024);
});
@@ -86,16 +86,28 @@ export function createPluginStreamEndpoint(transport, options = {}) {
}
}
function closeIncoming(state, error, notify = false) {
function closeIncoming(state, error, notify = false, refundCancelledCredit = false) {
if (state.closed) return;
state.closed = true;
state.error = error ?? null;
incoming.delete(state.streamId);
if (error) {
// Discard unread bytes on cancellation/failure and settle their credit
// now. A later read must surface the error without sending more credit.
state.currentCreditBytes += state.queue.reduce((sum, chunk) => sum + chunk.creditBytes, 0);
state.queue.length = 0;
// A consumer-initiated cancel rejects the producer's queued writes.
// Returning credit first could let that producer send another chunk.
if (refundCancelledCredit) {
try { releaseCurrent(state); } catch {}
}
state.currentCreditBytes = 0;
}
if (notify) {
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence,
sequence: Math.max(1, state.updateSequence + 1),
kind: "cancel",
});
} catch {}
@@ -221,16 +233,21 @@ export function createPluginStreamEndpoint(transport, options = {}) {
cancel() {
if (state.closed) return;
state.closed = true;
outgoing.delete(state.streamId);
state.terminal = "cancel";
const error = new PluginError("cancelled", "Plugin stream was cancelled");
for (const pending of state.queue.splice(0)) pending.reject(error);
state.queuedBytes = 0;
state.endReject?.(error);
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence + 1,
kind: "cancel",
});
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence + 1,
kind: "cancel",
});
state.terminalSent = true;
} finally {
if (!state.terminalSent || state.availableBytes === state.windowBytes) outgoing.delete(state.streamId);
}
},
dispose() { this.cancel(); },
});
@@ -314,6 +331,8 @@ export function createPluginStreamEndpoint(transport, options = {}) {
: frame.kind === "cancel"
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
: null,
false,
frame.kind === "cancel",
);
return true;
}
+13 -3
View File
@@ -88,6 +88,7 @@ class PluginStreamRouter {
streamId: frame.streamId,
nextSequence: 1,
availableBytes: frame.windowBytes,
windowBytes: frame.windowBytes,
updateSequence: -1,
openController: new AbortController(),
closed: false,
@@ -139,7 +140,7 @@ class PluginStreamRouter {
if (outgoing.availableBytes > outgoing.maxCreditBytes) {
throw new Error(`Plugin stream credit exceeds its negotiated window: ${frame.streamId}`);
}
if (outgoing.terminalKind === "end") {
if (outgoing.terminalKind === "end" || outgoing.terminalKind === "cancel") {
if (outgoing.availableBytes === outgoing.maxCreditBytes) {
this.outgoing.delete(frame.streamId);
}
@@ -216,7 +217,17 @@ class PluginStreamRouter {
? frame.error
: new Error(`Plugin stream ${frame.kind}: ${frame.streamId}`);
state.openController.abort(abortReason);
await state.onClose?.(closeReason);
try {
if (frame.kind === "cancel" && state.availableBytes < state.windowBytes) {
// The producer has stopped. Return discarded credit once so it can
// retire its terminal state, without releasing more queued writes.
state.updateSequence += 1;
this.send({ frame: {
streamId: state.streamId, sequence: state.updateSequence,
kind: "windowUpdate", creditBytes: state.windowBytes - state.availableBytes,
} });
}
} finally { await state.onClose?.(closeReason); }
}
bindIncoming(streamId, handlers) {
@@ -379,7 +390,6 @@ class PluginStreamRouter {
state.queuedBytes = 0;
if (
!terminalSent
|| kind !== "end"
|| state.availableBytes === state.maxCreditBytes
) {
this.outgoing.delete(state.streamId);
+58
View File
@@ -155,6 +155,64 @@ test("ended outgoing streams accept final receive credit before retiring", async
assert.equal(router.outgoing.has("ended-output"), false);
});
test("cancelled outgoing streams accept only ordered outstanding credit and release their slot", async () => {
const router = new PluginStreamRouter({ send() {}, maxStreams: 1 });
const stream = await router.openOutgoing("cancel-in-flight", 1024);
await stream.write(new Uint8Array(1024));
const queued = stream.write(Uint8Array.of(1));
stream.cancel();
await assert.rejects(queued, /cancel/u);
await assert.rejects(stream.write(Uint8Array.of(1)), /closed/u);
const credit = (streamId, sequence, creditBytes) => ({ frame: { streamId, sequence, kind: "windowUpdate", creditBytes } });
await assert.rejects(router.accept(credit("unknown", 0, 1)), /Unknown outgoing/u);
await assert.rejects(router.accept(credit("cancel-in-flight", 1, 1)), /Out-of-order/u);
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 0)), /contract|creditBytes/u);
await router.accept(credit("cancel-in-flight", 0, 512));
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 1)), /Out-of-order/u);
await router.accept(credit("cancel-in-flight", 1, 512));
assert.equal(router.outgoing.size, 0);
await assert.rejects(router.accept(credit("cancel-in-flight", 2, 1)), /Unknown outgoing/u);
const other = await router.openOutgoing("next", 1024);
other.cancel();
assert.equal(router.outgoing.size, 0);
});
test("cancelled outgoing streams reject excess credit and clear retained state at runtime close", async () => {
const router = new PluginStreamRouter({ send() {} });
const stream = await router.openOutgoing("cancel-excess", 1024);
await stream.write(Uint8Array.of(1));
stream.cancel();
await assert.rejects(router.accept({ frame: {
streamId: "cancel-excess", sequence: 0, kind: "windowUpdate", creditBytes: 2,
} }), /exceeds/u);
router.close();
assert.equal(router.outgoing.size, 0);
});
test("failed cancellation credit refund still aborts and cleans up its incoming owner", async () => {
let ownerSignal;
let closeReason;
const router = new PluginStreamRouter({
send() { throw new Error("refund transport failed"); },
onIncomingStream({ signal, bind }) {
ownerSignal = signal;
bind({ onChunk() {}, onClose(reason) { closeReason = reason; } });
return true;
},
});
await router.accept({ frame: { streamId: "cancel-refund", sequence: 0, kind: "open", windowBytes: 1024 } });
await router.accept({ frame: {
streamId: "cancel-refund", sequence: 1, kind: "chunk",
data: { encoding: "base64", value: "AQ==", byteLength: 1 },
} });
await assert.rejects(router.accept({ frame: {
streamId: "cancel-refund", sequence: 2, kind: "cancel",
} }), /refund transport failed/u);
assert.equal(ownerSignal.aborted, true);
assert.equal(closeReason, "cancel");
assert.equal(router.incoming.size, 0);
});
test("unhandled incoming streams are cancelled immediately", async () => {
const sent = [];
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
+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
+18 -13
View File
@@ -1,16 +1,21 @@
# 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 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.
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.
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));
}
+6 -1
View File
@@ -27,6 +27,8 @@ async function initialize() {
element('status').textContent = 'Ready';
busy(false);
} catch {
const failedChat = chat; chat = undefined;
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
element('status').textContent = 'Choose a model and grant access in Netcatty.';
}
}
@@ -73,5 +75,8 @@ element('stop').addEventListener('click', async () => {
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', () => {
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
});
void initialize();
+29 -100
View File
@@ -1,111 +1,40 @@
# Run the disposable database example
# Read one disposable database row
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
example does not use saved SSH or AI credentials.
This browser plugin tests the generic `network.byte-streams` feature and exact
`network.stream` permission. The database protocol and fixed SELECT query live
only in this example. The host transports bounded bytes.
## Prepare the fixture and package
1. Check out the exact transport PR head and run `npm ci` followed by
`npm run build:plugin-runtime-deps`.
2. Create a dedicated internal Docker network. Do not alter an existing
network if this subnet is already in use.
```sh
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
docker run --detach --rm --name netcatty-plugin-db-qa \
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
```
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
to succeed. The container publishes no host ports and has no external route.
The empty administrative password exists only in this disposable container.
4. Open its local client with
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
Create the fixture below. Replace the password placeholder with a new
synthetic session password. Do not put a real password in this example.
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<new-synthetic-session-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
5. Build the normal browser package for this exact endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs \
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
```
The tool prints the package hash, not credentials. Its temporary staging
directory is removed. Only the manifest, browser bundle, and three view
assets enter the five-file package. No Node preparation tool, server setup,
dependency tree, or credential file enters the package.
If a different private subnet is needed, prepare a new package for its exact
fixture IP. Record the new package hash and review its endpoint permission.
The example page does not accept arbitrary hosts or SQL.
## Run automated entrypoint acceptance
From the repository root, run:
Prepare a package for one private IPv4 fixture endpoint:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
npm run build:plugin-runtime-deps
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The test creates its own isolated server, random session password, and temporary
package. It exercises both the transport core and the installed example entry
through the registered SDK/RPC path. It removes the container, network, package
and profile afterward. The in-process runtime harness does not prove native
browser sandbox behavior.
The address above is a packaging example. Preparation does not connect to it.
Use the actual disposable fixture address for native testing. A different
endpoint produces a different manifest, bundle, and package hash.
## Record native acceptance
Import the package through Settings > Plugins, enable it, and open **Disposable
database example**. Enter only the fixture's session credentials. Deny the first
endpoint prompt, then retry and grant that exact endpoint. A successful read
shows `synthetic stream result`. **Cancel** stops the active operation.
1. Start the exact build with the existing sandbox settings and isolated
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
is a local import, not a claim of official maintenance.
2. Enable the plugin and open **Disposable database example** from its view
action. Record the plugin detail screen, source, version, and exact endpoint
resource. The plugin has a browser entry only.
3. Enter `plugin_demo` and the synthetic session password. The password input
is masked and clears before dispatch. Click **Connect and read** and deny
the endpoint prompt. The page must show a bounded failure and remain usable.
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
Do not record the password or administrative client session.
5. Revoke the stream grant in the manager, retry, and deny. Check that no
connection remains. Test **Cancel**, close/reopen the view, and disable the
plugin during a pending request. Restart with the same QA profile: the
plugin must require the session password again.
6. Inspect generic connection audit records. Record endpoint, open/close and
byte counts; verify that neither the password, SQL nor result body appears.
Verify the account's server-side write denial in the disposable server.
The fixture must be reachable at a private address belonging to a different
machine or container, rather than an address assigned to the app's machine.
Loopback, the app machine's interface addresses, and mapped aliases remain
blocked. Do not replace the endpoint with a local SSH forwarding address.
Save screenshots of install/details, denial, endpoint approval and the result,
plus a short recording of denial/retry/query/revoke/disable. This recipe does
not claim that native QA has run. The automated Node entrypoint harness is not
a BrowserWindow sandbox test.
The example supports protocol 4.1 and `mysql_native_password` over plain TCP.
Its server account needs only SELECT on `plugin_demo.example_result`. The query
is `SELECT value FROM plugin_demo.example_result LIMIT 1`. Server grants enforce
read-only access. The example does not support database TLS or STARTTLS.
The example database connection is plaintext only inside the disposable
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
real-socket certificate and hostname validation tests. Do not use this example
with real data or an existing database.
Credentials are session-only and are not written to plugin settings, logs, or
secrets. JavaScript does not guarantee memory zeroization. The plugin has no
query editor, schema browser, or connection manager.
## Remove the fixture
Disable and uninstall the example, then remove the temporary container and
network:
```sh
docker rm --force netcatty-plugin-db-qa
docker network rm netcatty-plugin-db-qa
```
The container, its synthetic credential hashes, and data disappear. Remove the
prepared package and isolated QA profile after retaining the required evidence.
Follow [the native handoff](../../../docs/plugin-platform/byte-stream-native-qa.md)
for fixture prerequisites, screenshots, video, cancellation, and cleanup.
@@ -25,8 +25,7 @@ export async function prepareDatabaseExample(targetEndpoint, outputPath) {
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
bundle: true, platform: "browser", format: "esm", minify: true,
nodePaths: [path.resolve(root, "../../../node_modules")] });
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
await fs.rm(path.join(temporary, "protocol.mjs"));
await fs.rm(path.join(temporary, "fixture-config.mjs"));
+13 -3
View File
@@ -128,7 +128,7 @@ function greeting(bytes) {
}
/** stream is a plugin-owned browser adapter over the generic host byte stream. */
export async function queryExample(stream, username, sessionPassword) {
async function queryFixedExample(stream, username, sessionPassword, database, query) {
if (!/^[A-Za-z0-9_]{1,32}$/u.test(username) || typeof sessionPassword !== "string" || sessionPassword.length > 256) {
throw new Error("Enter the disposable fixture username and session password");
}
@@ -139,10 +139,10 @@ export async function queryExample(stream, username, sessionPassword) {
sessionPassword = "";
try {
await packets.write(concat(uint32(CLIENT_FLAGS), uint32(MAX_PACKET), Uint8Array.of(45), new Uint8Array(23),
zeroText(username), Uint8Array.of(token.length), token, zeroText("plugin_demo"), zeroText("mysql_native_password")), 1);
zeroText(username), Uint8Array.of(token.length), token, zeroText(database), zeroText("mysql_native_password")), 1);
} finally { token.fill(0); nonce.fill(0); }
if ((await packets.read(2))[0] !== 0) reject();
await packets.write(concat(Uint8Array.of(3), encoder.encode(READ_QUERY)), 0);
await packets.write(concat(Uint8Array.of(3), encoder.encode(query)), 0);
const count = await packets.read(1);
if (count.length !== 1 || count[0] !== 1) reject();
const column = new Cursor(await packets.read(2));
@@ -161,3 +161,13 @@ export async function queryExample(stream, username, sessionPassword) {
await stream.close();
}
}
export function queryExample(stream, username, sessionPassword) {
return queryFixedExample(stream, username, sessionPassword, "plugin_demo", READ_QUERY);
}
// This second fixed profile is a public third-party acceptance candidate.
// No caller can supply a database, query, username, or password to this entry.
export function queryRfamReadOnly(stream) {
return queryFixedExample(stream, "rfamro", "", "Rfam", "SELECT 1 AS value");
}
@@ -0,0 +1,57 @@
"use strict";
const assert = require("node:assert/strict");
const { Buffer } = require("node:buffer");
const test = require("node:test");
function bytes(...parts) { return Buffer.concat(parts.map((part) => Buffer.from(part))); }
function text(value) { return bytes(Buffer.from(value), [0]); }
function packet(sequence, payload) { return bytes([payload.length & 255, payload.length >>> 8, 0, sequence], payload); }
function greeting(plugin = "mysql_native_password") {
const flags = 1 | 4 | 8 | 0x200 | 0x2000 | 0x8000 | 0x80000;
return bytes([10], text("synthetic-offline-server"), [1, 0, 0, 0], new Uint8Array(8).fill(1),
[0, flags & 255, (flags >>> 8) & 255, 45, 0, 0, (flags >>> 16) & 255, (flags >>> 24) & 255, 21],
new Uint8Array(10), new Uint8Array(12).fill(2), [0], text(plugin));
}
function streamFixture(plugin) {
const column = bytes(...["def", "synthetic", "", "", "value"].map((value) => bytes([value.length], Buffer.from(value))));
const eof = [0xfe, 0, 0, 2, 0];
let pending = bytes(packet(0, greeting(plugin)), packet(2, [0]), packet(1, [1]),
packet(2, column), packet(3, eof), packet(4, [1, 49]), packet(5, eof));
const writes = [];
let closes = 0;
return {
writes, get closes() { return closes; },
stream: {
async read() { const value = pending; pending = null; return value; },
async write(value) { writes.push(Buffer.from(value)); },
async close() { closes++; },
},
};
}
test("owned fixture and public Rfam entries each send only their fixed database and query offline", async () => {
const { queryExample, queryRfamReadOnly, READ_QUERY } = await import("./protocol.mjs");
for (const profile of [
{ run: (stream) => queryExample(stream, "plugin_demo", ""), user: "plugin_demo", database: "plugin_demo", query: READ_QUERY },
{ run: queryRfamReadOnly, user: "rfamro", database: "Rfam", query: "SELECT 1 AS value" },
]) {
const f = streamFixture();
assert.deepEqual(await profile.run(f.stream), { columns: ["value"], rows: [["1"]] });
assert.equal(f.writes.length, 2);
const response = f.writes[0].subarray(4);
const expectedTail = bytes(text(profile.user), [0], text(profile.database), text("mysql_native_password"));
assert.deepEqual(response.subarray(32), expectedTail);
assert.equal(f.writes[1][4], 3);
assert.equal(f.writes[1].subarray(5).toString(), profile.query);
assert.equal(f.closes, 1);
}
});
test("public profile rejects unsupported authentication offline without a fallback", async () => {
const { queryRfamReadOnly } = await import("./protocol.mjs");
const f = streamFixture("caching_sha2_password");
await assert.rejects(queryRfamReadOnly(f.stream), /Unsupported or invalid/u);
assert.equal(f.writes.length, 0);
assert.equal(f.closes, 1);
});
@@ -0,0 +1,77 @@
# Prepare the public Rfam read-only acceptance sample
This is a public third-party acceptance candidate, separate from the owned
MariaDB fixture. It uses the existing generic byte-stream capability. No host
database business, server-specific production API, or endpoint-policy exception
is added.
[Rfam's official database documentation](https://docs.rfam.org/en/latest/database.html)
lists `mysql-rfam-public.ebi.ac.uk:4497`, database `Rfam`, public user `rfamro`,
and no password. The sample sends only `SELECT 1 AS value`. It does not read Rfam
tables and offers no query, host, database, or credential input. The documented
public account name is compiled into the example. No private credential is used
or persisted, and the sample has no storage or secret permission.
## Build the candidate without connecting
Use the integration head and locked dependencies:
```sh
npm run build:plugin-runtime-deps
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-rfam-readonly.ncpkg
sha256sum /tmp/database-rfam-readonly.ncpkg
```
The separate preparation command accepts only that exact endpoint. It performs
no DNS lookup, connection, query, plugin installation, or permission activation.
It creates five files: a manifest, browser bundle, and three panel assets.
The owned fixture's preparation command still rejects public hostnames.
Package identity and permission scope:
- ID: `com.netcatty.database-rfam-readonly`, version `0.1.0`.
- Runtime: browser only. Required feature: `network.byte-streams`.
- Required permissions: `commands` and `views` for its own callbacks and panel.
- Optional permission: `network.stream`, resource exactly
`tcp://mysql-rfam-public.ebi.ac.uk:4497`.
- Five-file package: 11,003 bytes. Two builds matched byte for byte with SHA-256
`a9d56b69a4d2c587a4712d1b2fe395c381bcf8b15e34d476c4ba8b5ca967c35a`.
- Expected result after a separately authorized compatible run:
`{"columns":["value"],"rows":[["1"]]}`.
## Obtain authorization before native acceptance
Record the exact integration commit, package hash, ID, version, and permissions.
The parent must obtain authorization for that specific package and public
endpoint before installation, enabling permissions, or querying the service.
No connection or query has been performed as part of preparing this candidate.
After authorization, use the available native desktop and a disposable profile.
Record the local-import source, exact endpoint prompt, denial, retry, fixed
result, cancellation, and post-revocation recovery. Opening the panel only reads
its endpoint metadata. A connection starts only from **Run fixed public query**.
Do not treat this public service as an isolated synthetic fixture or evidence of
database TLS or native sandbox certification.
## Stop if the protocol is unsupported
The reused example protocol supports only protocol 4.1 and
`mysql_native_password`. It uses plain TCP and does not implement MySQL STARTTLS,
auth-plugin switching, or other authentication methods. The service's current
authentication and TLS requirements remain unverified. An unsupported greeting
or server rejection fails closed and closes the byte stream. Record a sanitized
failure and stop. Do not add an authentication fallback or TLS bypass for QA.
Offline protocol tests inspect synthetic packet bytes for the two fixed
profiles and reject an unsupported auth plugin. They do not contact Rfam or
activate plugin permissions:
```sh
npm run test:plugin-examples
```
The owned MariaDB regression remains at
`examples/plugins/database-minimal/protocol.live.test.cjs`. Its explicit Docker
opt-in, isolated network, temporary account, SELECT result, and server-side write
denial remain separate from this public-service candidate.
@@ -0,0 +1,34 @@
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
import { queryRfamReadOnly } from "../database-minimal/protocol.mjs";
const endpoint = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
export default {
activate(context) {
let pending;
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.netcatty.database-rfam-readonly.${suffix}`, handler,
));
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
register("cancel", cancel);
register("endpoint", () => ({ endpoint }));
context.subscriptions.add({ dispose: cancel });
register("query", async (args) => {
if (args !== undefined && args !== null) throw new Error("The public sample accepts no query or credential parameters");
if (pending) throw new Error("A public sample request is already active");
const cancellation = new CancellationTokenSource();
pending = cancellation;
let connection;
try {
connection = await context.network.connect({ endpoint }, cancellation.token);
return await queryRfamReadOnly({
read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
close: () => connection.close(),
});
} finally {
await connection?.close().catch(() => {});
cancellation.dispose();
pending = undefined;
}
});
},
};
@@ -0,0 +1,26 @@
{
"manifestVersion": 1,
"id": "com.netcatty.database-rfam-readonly",
"name": "database-rfam-readonly",
"displayName": "Rfam public read-only acceptance sample",
"description": "Public third-party sample: one fixed SELECT 1 through an exact approved byte-stream endpoint. Compatibility is unverified.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["network.byte-streams"] },
"main": { "browser": "browser.js" },
"activationEvents": ["onCommand:com.netcatty.database-rfam-readonly.query", "onView:com.netcatty.database-rfam-readonly.panel"],
"permissions": {
"required": ["commands", "views"],
"optional": [{ "permission": "network.stream", "resources": ["tcp://mysql-rfam-public.ebi.ac.uk:4497"], "reason": "Run the fixed public Rfam acceptance query only after explicit authorization." }]
},
"contributes": {
"commands": [
{ "id": "com.netcatty.database-rfam-readonly.query", "title": "Run public Rfam SELECT 1" },
{ "id": "com.netcatty.database-rfam-readonly.cancel", "title": "Cancel public Rfam sample" },
{ "id": "com.netcatty.database-rfam-readonly.endpoint", "title": "Show public Rfam endpoint" }
],
"views": [{ "id": "com.netcatty.database-rfam-readonly.panel", "title": "Rfam public read-only sample", "location": "modal", "entry": "panel.html" }]
}
}
@@ -0,0 +1 @@
:root{color-scheme:dark light;font:14px system-ui,sans-serif;color:hsl(var(--foreground,210 20% 90%));background:hsl(var(--background,220 15% 12%))}*{box-sizing:border-box}body{margin:0}main{max-width:640px;padding:24px;margin:auto}h1{font-size:24px}.eyebrow{font-size:11px;letter-spacing:.1em;opacity:.7}p{line-height:1.5}#endpoint,pre{overflow-wrap:anywhere;white-space:pre-wrap}label{display:block;margin:16px 0 6px}input{width:100%;padding:10px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;background:hsl(var(--background,220 15% 12%));color:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}button{padding:10px 16px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;color:inherit;background:hsl(var(--secondary,220 12% 22%));cursor:pointer}button:disabled{opacity:.5;cursor:default}input:focus-visible,button:focus-visible{outline:2px solid hsl(var(--primary,210 90% 60%));outline-offset:2px}pre{padding:14px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;min-height:80px}
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rfam public read-only sample</title><link rel="stylesheet" href="panel.css"></head>
<body><main><p class="eyebrow">PUBLIC THIRD-PARTY ACCEPTANCE SAMPLE</p><h1>Rfam: SELECT 1</h1>
<p>This contacts Rfam's public read-only MySQL service. It is not an isolated fixture. The fixed query is SELECT 1 AS value in Rfam, using the documented public account with no password.</p>
<p>This sample uses plain TCP. Authentication and TLS compatibility remain unverified. Run only after the package and exact endpoint are authorized.</p>
<p id="endpoint">Loading exact endpoint…</p>
<div class="actions"><button id="query" type="button">Run fixed public query</button><button id="cancel" type="button" disabled>Cancel</button></div>
<pre id="result" role="status" aria-live="polite">Prepared only. No connection starts until you click Run.</pre>
</main><script src="panel.js"></script></body></html>
@@ -0,0 +1,28 @@
/* global window, document */
"use strict";
const query = document.getElementById("query");
const cancel = document.getElementById("cancel");
const result = document.getElementById("result");
const command = (suffix) => window.netcattyView.executeCommand(`com.netcatty.database-rfam-readonly.${suffix}`);
window.netcattyView.onDidChangeEnvironment((environment) => {
document.documentElement.style.colorScheme = environment.theme === "light" ? "light" : "dark";
for (const name of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]) {
const value = environment.themeTokens?.[name];
if (typeof value === "string" && value.length <= 96) document.documentElement.style.setProperty(name, value);
else document.documentElement.style.removeProperty(name);
}
});
void command("endpoint").then((value) => { document.getElementById("endpoint").textContent = value.endpoint; });
query.addEventListener("click", async () => {
query.disabled = true;
cancel.disabled = false;
result.textContent = "Requesting the exact public endpoint…";
try { result.textContent = JSON.stringify(await command("query"), null, 2); }
catch { result.textContent = "Public sample failed. Authentication, TLS requirements, and service reachability may be unsupported. No fallback is attempted."; }
finally { query.disabled = false; cancel.disabled = true; }
});
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try { await command("cancel"); result.textContent = "Public sample cancelled."; }
catch { result.textContent = "Could not cancel the public sample."; }
});
@@ -0,0 +1,31 @@
import fs from "node:fs/promises";
import process from "node:process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildPluginPackage } from "@netcatty/plugin-cli";
import { build } from "esbuild";
export const RFAM_ENDPOINT = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
const root = path.dirname(fileURLToPath(import.meta.url));
export async function prepareRfamExample(targetEndpoint, outputPath) {
// This separate sample accepts one documented hostname. It does not change
// the private-fixture preparer, host endpoint policy, or runtime permissions.
if (targetEndpoint !== RFAM_ENDPOINT) throw new Error("Use only the documented exact Rfam sample endpoint");
const temporary = await fs.mkdtemp(path.join(path.dirname(outputPath), ".rfam-example-"));
try {
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js"]) {
await fs.copyFile(path.join(root, file), path.join(temporary, file));
}
const bundle = await build({ entryPoints: [path.join(root, "browser.js")], write: false,
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
return await buildPluginPackage(temporary, outputPath);
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const [endpoint, outputPath] = process.argv.slice(2);
if (!endpoint || !outputPath) throw new Error("Usage: node prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 <output.ncpkg>");
process.stdout.write(`${JSON.stringify(await prepareRfamExample(endpoint, path.resolve(outputPath)))}\n`);
}
@@ -0,0 +1,15 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
test("public preparation accepts only its exact documented endpoint and private preparation stays private", async () => {
const { prepareRfamExample } = await import("./prepare.mjs");
const { prepareDatabaseExample } = await import("../database-minimal/prepare.mjs");
for (const endpoint of ["tcp://example.org:4497", "tcp://mysql-rfam-public.ebi.ac.uk:3306",
"tcp://mysql-rfam-public.ebi.ac.uk:4497/", "tls://mysql-rfam-public.ebi.ac.uk:4497",
"tcp://127.0.0.1:4497", "tcp://10.0.0.9:4497"]) {
await assert.rejects(prepareRfamExample(endpoint, "/unused/output.ncpkg"), /exact Rfam/u);
}
await assert.rejects(prepareDatabaseExample("tcp://mysql-rfam-public.ebi.ac.uk:4497", "/unused/output.ncpkg"), /private IPv4/u);
});
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,16 @@
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';
// 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) ?? [];
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) };
}
@@ -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 }],
@@ -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,48 @@
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, 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, 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();
});
@@ -0,0 +1,143 @@
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;
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
isActive: () => boolean;
}) {
const models = new Map<string, Owned<{ model: Model }>>();
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 [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 >= 32) throw Error('Model handle limit reached');
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 };
}
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 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);
+1
View File
@@ -57,6 +57,7 @@
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"test:plugin-examples": "node --test examples/plugins/database-minimal/*.test.cjs examples/plugins/database-rfam-readonly/*.test.cjs",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs",
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
@@ -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 = {
@@ -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 });
});