mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 11:22:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
42a9826603 | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
dc45d03c7b | ||
|
|
9290debe91 | ||
|
|
6cf931ac4d |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); } });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
.package/
|
||||
*.ncpkg
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user