mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
f1410f8bb1 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
42a9826603 | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
dc45d03c7b | ||
|
|
9290debe91 | ||
|
|
6cf931ac4d | ||
|
|
c4433d4961 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 |
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>;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
# Plugin AI and selected context: implementation checkpoint
|
||||
|
||||
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
Branch: `feat/plugin-ai-context`.
|
||||
Status: source-bound services, default trusted main/renderer adapters, app preload,
|
||||
the AppView host chooser, canonical private IPC types, and an installable minimal
|
||||
example are connected. Complete independent review and native acceptance remain
|
||||
pending. This source is not ready to merge or release.
|
||||
|
||||
## Public boundaries
|
||||
|
||||
The parent approved `ai.model-invocation` and `context.selected-read`. The
|
||||
transport task owns the public schema, SDK, and runtime peer. Its
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
|
||||
routes and is merged into this branch. The parent assigned this task the additive
|
||||
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
|
||||
|
||||
The model API uses a host-selected configured model. Plugins receive a chat
|
||||
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
|
||||
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
|
||||
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
|
||||
`read` pulls one chunk with one pending read per turn through the registered SDK
|
||||
and runtime routes.
|
||||
|
||||
Context summary enumeration returns only references explicitly shared with the
|
||||
current runtime and source owner. Content reads require an opaque handle and an
|
||||
expected version. Summary enumeration never calls the content reader. Supported
|
||||
selected scopes are workspace, tab, session, host, and Note. There is no inventory
|
||||
or arbitrary entity-ID lookup.
|
||||
|
||||
## File ownership
|
||||
|
||||
| Owner | Files or responsibility |
|
||||
| --- | --- |
|
||||
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
|
||||
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
|
||||
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
|
||||
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
|
||||
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
|
||||
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
|
||||
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
|
||||
| Transport task | Generic TCP and TLS streams |
|
||||
| Logging task | Optional local plugin logs |
|
||||
|
||||
The existing `feat/plugin-terminal-sharing` commit
|
||||
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
|
||||
`terminal-sharing-wip.md` describes incomplete selection publication and review.
|
||||
This branch does not claim that workflow is complete.
|
||||
|
||||
## Implemented authority
|
||||
|
||||
`PluginAISourceBinding` authenticates the current browser runtime before reading
|
||||
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
|
||||
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
|
||||
database and sync implementation are unchanged. Source origin does not imply
|
||||
that a package is officially maintained.
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner` 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;
|
||||
no private storage is keyed by `pluginId`.
|
||||
|
||||
Each handle is bound to a data owner, a runtime ID, and a plugin version.
|
||||
Every read rechecks runtime authority, the current model, handle lifetime, and
|
||||
selected context versions. Revocation aborts pending turns. Late provider results
|
||||
are discarded. Another source with the same plugin ID cannot read, cancel, or
|
||||
close the original source's handles.
|
||||
|
||||
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
|
||||
call it directly. Its descriptor accepts only a scope, a title, a version, a text
|
||||
reader, and a current-selection guard. Unknown Host fields are rejected. Content
|
||||
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
|
||||
reader must project safe fields explicitly and exclude passwords, private keys,
|
||||
TOTP values, identity credentials, startup commands, and environment variables.
|
||||
Free-form Notes can contain secrets that field projection cannot detect. The
|
||||
trusted sharing UI must preview the selected text before sharing it.
|
||||
|
||||
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
|
||||
`streamText`, through the existing host provider transport. Credential injection
|
||||
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
|
||||
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
|
||||
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
|
||||
total deadline. Host-owned request tags allow sender/runtime cancellation
|
||||
without cancelling the internal Agent's ordinary streams. SDK retries are
|
||||
disabled. Only text and allowlisted token totals reach the broker. Provider
|
||||
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
|
||||
|
||||
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
|
||||
raw host IPC, or global renderer store is exposed to plugins. A model tool request
|
||||
ends the turn without executing an action. Notes edits and terminal input are
|
||||
outside this checkpoint. No command is written to a terminal or followed by Enter.
|
||||
|
||||
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
|
||||
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
|
||||
input and output bytes, output token caps, event count, requests per minute, and
|
||||
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
|
||||
bytes for framing and the output token cap. This is a conservative estimate, not
|
||||
a tokenizer or a monetary billing cap. Provider usage is reported separately.
|
||||
Reservations are not refunded after cancellation. Rate limits survive runtime
|
||||
restart within the broker's lifetime. Host shutdown clears this in-memory state.
|
||||
|
||||
## Connected host path
|
||||
|
||||
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
|
||||
the existing interaction window, app-lock subscription, and native dialog. It
|
||||
registers the trusted ready/response IPC handlers. App preload exposes the request
|
||||
subscription and validated responses only to the trusted application renderer;
|
||||
the sandbox view preload does not expose these methods.
|
||||
|
||||
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
|
||||
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
|
||||
provider adapter and selected readers in renderer-local maps; only canonical,
|
||||
validated request/results cross the main/renderer IPC boundary. The default
|
||||
adapter authenticates the current main-window sender, uses one-use nonces,
|
||||
rechecks the current owner after awaits, and invalidates on lock, navigation,
|
||||
hide, destruction, or renderer replacement. Plugin permission revocation,
|
||||
disable, and runtime release abort that runtime's turns and transport requests.
|
||||
|
||||
Summary enumeration, content reads, and model invocation require separate
|
||||
permissions. Before selected text becomes model input, a native host dialog
|
||||
shows that exact text and version with Cancel as the default. Model invocation
|
||||
permission grants neither context nor action access. Menu IDs are never data
|
||||
authority. While the app-owned chooser is present, the active native modal/tab
|
||||
plugin view yields so it cannot cover the chooser. Existing embedded views use
|
||||
their host-overlay occlusion observer. Native stacking still requires desktop QA.
|
||||
|
||||
The selected-context authority is read-only text. Its constructor and lifecycle
|
||||
API are documented in [Selected context authority](selected-context-authority.md).
|
||||
Host metadata handles cannot be promoted into edit/connect authority; those
|
||||
actions require separate typed host targets, grants, and confirmations.
|
||||
|
||||
## QA evidence
|
||||
|
||||
The domain suites and transport suite use synthetic data only. The transport
|
||||
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
|
||||
checks this path:
|
||||
|
||||
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
|
||||
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
|
||||
-> local fixture -> text and usage.
|
||||
|
||||
This is not real model inference or paid API testing. It uses no paid keys, real
|
||||
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
|
||||
the existing model factory replaces it with its IPC placeholder. The test does
|
||||
not exercise native credential storage or production credential injection.
|
||||
|
||||
The tests cover source, runtime, and version denial; separate summaries and
|
||||
content reads; context invalidation; provider cancellation and late results;
|
||||
deadlines; request, token, event, input, output, and stream-byte limits; and
|
||||
allowlisted response serialization. Raw provider diagnostics remain inside the
|
||||
host.
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
node --test electron/plugins/modelInvocation.test.cjs
|
||||
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
|
||||
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
|
||||
```
|
||||
|
||||
The installed assistant command test passes on this branch's ordinary merge with
|
||||
transport head `c4433d496`. It installs and
|
||||
activates the six-file sample through the actual package manager and runtime peer,
|
||||
then checks canonical SDK/RPC, independent model/content denial, text-review
|
||||
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
|
||||
The in-process browser harness is a Node test transport, not native sandbox proof.
|
||||
|
||||
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
|
||||
app preload factory, mounted host chooser, installed sample command/runtime peer,
|
||||
existing provider factory, and existing main HTTP transport to the loopback
|
||||
fixture. It checks content denial, review cancellation, streaming usage, Stop,
|
||||
lock revocation, and absence of keys/configuration/policy on the trusted wire.
|
||||
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
|
||||
and teardown without restoring an old view. DOM tests do not establish native
|
||||
WebContentsView stacking or Chromium sandbox behavior.
|
||||
|
||||
The trusted main/renderer suites cover nonce replay, sender substitution, owner
|
||||
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
|
||||
safe field projection, and late selected-content results. The main transport test
|
||||
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
|
||||
stream running and aborts an oversized plugin stream.
|
||||
|
||||
Failed aggregate runs remain evidence, not passing acceptance records:
|
||||
|
||||
| Log in `/tmp` | Result |
|
||||
| --- | --- |
|
||||
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
|
||||
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
|
||||
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
|
||||
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
|
||||
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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`.
|
||||
The transport delta does not edit the source database or sync implementation.
|
||||
Draft PR #3988 records the published review head. No merge or release is authorized.
|
||||
|
||||
## Completed local checks
|
||||
|
||||
The delegated environment uses Node 24.19.0 on Linux.
|
||||
|
||||
- `npm run test:plugin-contract`: 90 passed, zero skipped.
|
||||
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
|
||||
Database-specific testing is now entirely in the example directory.
|
||||
- Final focused transport, source-isolation, installed entrypoint and disposable
|
||||
database run before the test-file move: 32 passed, zero skipped. After the
|
||||
move, the same installed database test passed again at its example path.
|
||||
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
|
||||
real MariaDB result and server-side INSERT denial passed through both the
|
||||
core and the ordinary installed example entrypoint. No host ports, fixture
|
||||
override, real hosts, or real credentials were used by this database test.
|
||||
Its container, network, package and profile were removed.
|
||||
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
|
||||
clearing before dispatch and safe text rendering passed.
|
||||
- Production `npm run build`, generated contract drift, changed-source lint,
|
||||
and whitespace checks passed.
|
||||
|
||||
Real broker TLS tests validate trust and the original requested hostname using
|
||||
a disposable fixture CA. Database traffic is plain TCP inside an internal
|
||||
Docker network. Neither check proves native BrowserWindow sandbox behavior,
|
||||
database TLS, device testing, or real model inference. The registered AI/context
|
||||
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
|
||||
wiring owned by the AI task.
|
||||
|
||||
The EOF and last-await cancellation regressions each failed before their fix
|
||||
and passed after it with real loopback sockets. Source changes before grant
|
||||
publication, source changes during a pending read, and a stream returned after
|
||||
runtime release have separate passing regressions.
|
||||
|
||||
## Reproducible native sample
|
||||
|
||||
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
|
||||
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
|
||||
Two separate staging directories produced the same five-file package:
|
||||
|
||||
- Archive bytes: 11,621.
|
||||
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
|
||||
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
|
||||
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
|
||||
|
||||
The artifact contains no credentials or server setup. Changing its approved
|
||||
fixture endpoint requires a newly prepared package and a recorded new hash.
|
||||
|
||||
## Checks that did not pass locally
|
||||
|
||||
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
|
||||
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
|
||||
Electron build caches under `/workspace` passed that step, then failed while
|
||||
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
|
||||
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
|
||||
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
|
||||
directory packaging remains failed locally and needs the normal CI or native
|
||||
QA build environment.
|
||||
|
||||
The broad `npm test` run reported failures in existing transfer-resume,
|
||||
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
|
||||
tests, then stalled without a final aggregate. Its owned process tree was
|
||||
terminated. It is not reported as a passing full test run.
|
||||
|
||||
An independent source-baseline worktree at `4213d8e` ran five affected files in
|
||||
the same environment with shared dependencies: 145 tests, 118 passed,
|
||||
24 failed, three skipped. It reproduced failures in transfer-resume,
|
||||
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
|
||||
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
|
||||
This diagnoses that tested subset only. It does not classify every broad-suite
|
||||
failure, replace exact-head CI, or make any failed result successful.
|
||||
|
||||
## Pending acceptance
|
||||
|
||||
Exact published-head CI and independent review remain pending. The parent owns
|
||||
sequential native GUI acceptance for streams and the database example, then AI,
|
||||
then logging integration. The native recipe covers ordinary install/source and
|
||||
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
|
||||
password, audit inspection, screenshots, and a short recording. No native GUI
|
||||
run or screenshot/video result is claimed by this delegated executor.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
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
|
||||
|
||||
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
|
||||
`network.byte-streams` negotiates the capability. The separate `network.stream`
|
||||
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
|
||||
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
|
||||
payload limits are unchanged.
|
||||
|
||||
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
|
||||
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
|
||||
`dispose()`. The control methods are `network.stream.connect`,
|
||||
`network.stream.attach`, and `network.stream.close`. Descriptors, request
|
||||
shapes, feature names, limits, generated SDK types, and host validators share
|
||||
the canonical schema. Unknown request properties are rejected.
|
||||
|
||||
The registered service authenticates the host runtime before source lookup.
|
||||
Runtime startup captures the existing opaque source owner. Every asynchronous
|
||||
commit revalidates the runtime, active and retained source, and opaque owner
|
||||
through the source task's APIs. Caller-supplied owner/source parameters are
|
||||
rejected. Audit commits also verify the source binding.
|
||||
|
||||
Connection and stream IDs are host-issued and bound to that runtime and owner.
|
||||
The SDK prepares its incoming waiter before attach starts the host producer.
|
||||
Both directions reuse the existing binary stream router and credit windows.
|
||||
Input credit returns after the socket write callback. Output backpressure
|
||||
bounds the producer. The router has one optional producer `onClose` hook;
|
||||
peer cancellation releases the socket even while it is idle.
|
||||
|
||||
The host validates all DNS answers and pins a literal IP. Production rejects
|
||||
loopback, link-local, unspecified, multicast, mapped local addresses, selected
|
||||
IPv6 tunnel forms, and the machine's own interface addresses. The production
|
||||
host constructor never forwards the core's explicit local-test override.
|
||||
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
|
||||
plugin runtime, or filesystem escape.
|
||||
|
||||
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
|
||||
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
|
||||
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
|
||||
seconds. Close tombstones are capped at 256 small identity records. Revocation,
|
||||
runtime stop, disable, shutdown, and stream cancellation release resources.
|
||||
|
||||
TLS requires trust and original-hostname validation with TLS 1.2 or later.
|
||||
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
|
||||
implemented. Generic audit records contain endpoint, runtime, handle, and byte
|
||||
counts. The host does not inspect SQL or record payloads or credentials.
|
||||
|
||||
## 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`
|
||||
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 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
|
||||
|
||||
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
|
||||
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
|
||||
endpoint. The output contains a browser bundle, a normal manifest, and view
|
||||
assets. The preparation tool and its Node dependencies are not in the plugin.
|
||||
|
||||
Only the example contains the database protocol: common protocol 4.1,
|
||||
`mysql_native_password`, and
|
||||
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
|
||||
The view accepts session-only fixture credentials and clears its password
|
||||
field before dispatch. It does not save view state, settings, logs, or secrets.
|
||||
The host never exposes SSH or AI credentials. JavaScript does not promise
|
||||
memory zeroization. The account's server-side SELECT grant enforces read-only
|
||||
access. The fixed query is not a SQL security classifier.
|
||||
|
||||
The example has no query editor, schema browser, pool, arbitrary SQL, or host
|
||||
connection-management subsystem. See the example README for native setup.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Both source-reviewed races have executable real-loopback regressions here.
|
||||
Delayed final-byte consumption previously lost EOF after the socket closed.
|
||||
Double-microtask cancellation previously created an untracked late socket.
|
||||
Each regression failed before its respective fix and passed afterward.
|
||||
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
|
||||
untrusted certificate and wrong-hostname denial. Connect and idle deadline
|
||||
cases use mock clocks around real sockets or delayed DNS.
|
||||
- The installed entrypoint test builds and installs a normal package, executes
|
||||
its actual browser entry module through SDK/RPC/binary streams, and exercises
|
||||
grant denial, exact resources, unknown-field rejection, close, revoke, and
|
||||
disable. It also exercises registered AI/context SDK routes with a synthetic
|
||||
trusted adapter. Its runtime harness is Node and is explicitly not a browser
|
||||
sandbox or OS sandbox test.
|
||||
- The disposable MariaDB test checks a synthetic SELECT result and server-side
|
||||
INSERT denial. It creates an internal network, publishes no host ports, and
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
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,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
|
||||
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
|
||||
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
|
||||
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
|
||||
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
|
||||
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
|
||||
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
|
||||
const ROUTES = Object.freeze([
|
||||
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
|
||||
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
|
||||
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
|
||||
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
|
||||
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
|
||||
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
|
||||
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
|
||||
]);
|
||||
|
||||
class PluginAIContextService {
|
||||
constructor(options) {
|
||||
this.host = options.hostAdapter;
|
||||
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
|
||||
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
|
||||
}
|
||||
this.source = new PluginAISourceBinding(options);
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.resolveOwner = (context) => this.source.resolve(context);
|
||||
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
|
||||
this.runtimeContexts = new Map();
|
||||
this.registrations = [];
|
||||
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
|
||||
selectModel: async (context) => {
|
||||
await this.#guard(context);
|
||||
const model = await this.host.selectModel(context);
|
||||
await this.#guard(context);
|
||||
return model;
|
||||
},
|
||||
contexts: {
|
||||
read: (reference, context) => this.readSelected(reference, context),
|
||||
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
|
||||
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
|
||||
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
|
||||
releaseOwner: (owner) => this.selected.releaseOwner(owner),
|
||||
},
|
||||
});
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
|
||||
}
|
||||
|
||||
async #guard(context) {
|
||||
try {
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.host.assertActive(context);
|
||||
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
|
||||
return owner;
|
||||
} catch (error) {
|
||||
const binding = this.source.bindings.get(context.runtimeId);
|
||||
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#authorization(permission, params) {
|
||||
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
|
||||
return { permission, resources: [resource],
|
||||
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
|
||||
: permission === "context.selected.list" ? "List summaries of the current selected context"
|
||||
: "Review and share this selected context version",
|
||||
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
if (this.registrations.length) throw new Error("AI/context routes are already registered");
|
||||
// Canonical validators are created only when transport integration registers
|
||||
// this service. There is no second plugin wire schema in these modules.
|
||||
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
|
||||
method, permission, operation,
|
||||
validateParams: createDefinitionValidator(paramsType),
|
||||
validateResult: createDefinitionValidator(resultType),
|
||||
}));
|
||||
for (const route of prepared) {
|
||||
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
|
||||
await this.#guard(context);
|
||||
const result = await this[route.operation](params, context);
|
||||
await this.#guard(context);
|
||||
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
|
||||
return result;
|
||||
}, {
|
||||
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
|
||||
validateParams: (params) => {
|
||||
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
|
||||
return Object.freeze(structuredClone(params));
|
||||
},
|
||||
authorization: async (params, context) => {
|
||||
await this.#guard(context);
|
||||
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
|
||||
return this.#authorization(route.permission, params);
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
open(_params, context) { return this.broker.open(context); }
|
||||
start(params, context) { return this.broker.start(params, context); }
|
||||
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
|
||||
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
|
||||
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
|
||||
|
||||
async list(_params, context) {
|
||||
const owner = await this.#guard(context);
|
||||
const selections = await this.host.listSelected(context);
|
||||
await this.#guard(context);
|
||||
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
|
||||
throw fail("resourceExhausted", "Selected scope limit reached");
|
||||
}
|
||||
const previous = this.runtimeContexts.get(context.runtimeId);
|
||||
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
|
||||
return { items: await this.selected.summaries(context) };
|
||||
}
|
||||
this.selected.releaseRuntime(context.runtimeId);
|
||||
const created = [];
|
||||
try {
|
||||
for (const { key, scope, title, version, readText, isCurrent } of selections) {
|
||||
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
|
||||
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
|
||||
created.push(summary.handle);
|
||||
}
|
||||
await this.#guard(context);
|
||||
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
|
||||
return { items: await this.selected.summaries(context) };
|
||||
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
|
||||
}
|
||||
|
||||
async readSelected(reference, context) {
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
const authorization = this.#authorization("context.selected.read", reference);
|
||||
// Direct RPC reads have already passed the registry permission middleware.
|
||||
// A model turn must obtain its separate content grant here.
|
||||
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|
||||
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
|
||||
await this.permissionEngine.authorize(context, authorization);
|
||||
}
|
||||
await this.#guard(context);
|
||||
const selected = await this.selected.read(reference, context);
|
||||
await this.#guard(context);
|
||||
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
|
||||
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
return selected;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
this.runtimeContexts.delete(runtimeId);
|
||||
this.source.releaseRuntime(runtimeId);
|
||||
this.host.releaseRuntime?.(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
|
||||
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
|
||||
this.source.releasePlugin(pluginId);
|
||||
}
|
||||
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
|
||||
shutdown() {
|
||||
for (const registration of this.registrations.splice(0)) registration.dispose();
|
||||
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
|
||||
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
|
||||
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
|
||||
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
|
||||
|
||||
function boundedLimits(overrides) {
|
||||
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = overrides[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
}
|
||||
|
||||
function abortable(promise, signal) {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
|
||||
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
|
||||
class ModelInvocationBroker {
|
||||
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.selectModel = selectModel;
|
||||
this.contexts = contexts;
|
||||
this.clock = clock;
|
||||
this.limits = boundedLimits(limits);
|
||||
this.chats = new Map();
|
||||
this.openings = new Set();
|
||||
this.turns = new Map();
|
||||
this.rates = new Map();
|
||||
this.closed = false;
|
||||
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
|
||||
for (const turn of [...this.turns.values()]) {
|
||||
if (turn.references.some((reference) => reference.handle === handle)) {
|
||||
this.#end(turn, fail("cancelled", "Selected context was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #binding(context) {
|
||||
await context.assertActive();
|
||||
const owner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
|
||||
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
}
|
||||
|
||||
async #ownedChat(chat, context) {
|
||||
const binding = await this.#binding(context);
|
||||
if (this.closed || this.chats.get(chat.handle) !== chat
|
||||
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async #check(chat, context) {
|
||||
await this.#ownedChat(chat, context);
|
||||
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
|
||||
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
|
||||
}
|
||||
|
||||
async open(context) {
|
||||
const binding = await this.#binding(context);
|
||||
this.#prune();
|
||||
if (this.closed || this.chats.size + this.openings.size >= this.limits.chats
|
||||
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Plugin chat limit reached");
|
||||
}
|
||||
// Reserve before awaiting the host picker. Revoke/close can invalidate this
|
||||
// admission even if a host callback ignores cancellation and returns late.
|
||||
const opening = { ...binding, active: true };
|
||||
this.openings.add(opening);
|
||||
let model;
|
||||
try {
|
||||
model = await this.selectModel(context);
|
||||
const current = await this.#binding(context);
|
||||
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
|
||||
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|
||||
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
|
||||
throw fail("unavailable", "Choose a configured model in the host first");
|
||||
}
|
||||
await context.assertActive();
|
||||
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
|
||||
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
|
||||
this.chats.set(chat.handle, chat);
|
||||
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
|
||||
} catch (error) {
|
||||
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
|
||||
throw this.#safe(error);
|
||||
} finally { this.openings.delete(opening); }
|
||||
}
|
||||
|
||||
async start({ chatHandle, prompt, contexts = [] }, context) {
|
||||
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
|
||||
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|
||||
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
|
||||
throw fail("invalidArgument", "Invalid model input");
|
||||
}
|
||||
const references = contexts.map((reference) => {
|
||||
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|
||||
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|
||||
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
|
||||
throw fail("invalidArgument", "Invalid context reference");
|
||||
}
|
||||
return Object.freeze({ handle: reference.handle, version: reference.version });
|
||||
});
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
await this.#check(chat, context);
|
||||
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
|
||||
const controller = new AbortController();
|
||||
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
|
||||
chat.turn = turn;
|
||||
this.turns.set(turn.handle, turn);
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
|
||||
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
|
||||
try {
|
||||
await this.#guard(turn, context);
|
||||
const selected = [];
|
||||
let bytes = Buffer.byteLength(prompt);
|
||||
for (const reference of references) {
|
||||
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
|
||||
bytes += Buffer.byteLength(result.text);
|
||||
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
|
||||
selected.push(result.text);
|
||||
}
|
||||
await this.#guard(turn, context);
|
||||
this.#prune();
|
||||
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
|
||||
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
|
||||
// not refund unknown provider usage after cancellation or disconnect.
|
||||
const reserved = bytes + 1_024 + this.limits.outputTokens;
|
||||
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
|
||||
throw fail("resourceExhausted", "Model request/token budget reached");
|
||||
}
|
||||
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
|
||||
await this.#guard(turn, context);
|
||||
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
|
||||
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
|
||||
await this.#guard(turn, context);
|
||||
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
|
||||
turn.iterator = iterator;
|
||||
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #guard(turn, context) {
|
||||
turn.controller.signal.throwIfAborted();
|
||||
await abortable(this.#check(turn.chat, context), turn.controller.signal);
|
||||
for (const reference of turn.references) {
|
||||
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
|
||||
}
|
||||
turn.controller.signal.throwIfAborted();
|
||||
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
|
||||
}
|
||||
|
||||
#safe(error) {
|
||||
// SDK/provider errors may contain headers, URLs or echoed request content.
|
||||
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
|
||||
}
|
||||
|
||||
async read(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
|
||||
// Validate owner before mutating/aborting another caller's turn.
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
|
||||
turn.reading = true;
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
if (context.signal?.aborted) abort();
|
||||
await this.#guard(turn, context);
|
||||
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
|
||||
const next = await abortable(turn.iterator.next(), turn.controller.signal);
|
||||
await this.#guard(turn, context);
|
||||
if (next.done) {
|
||||
this.#end(turn);
|
||||
return { kind: "end", sequence: turn.sequence++ };
|
||||
}
|
||||
const event = next.value;
|
||||
if (event?.kind === "text" && typeof event.text === "string") {
|
||||
const bytes = Buffer.byteLength(event.text);
|
||||
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
|
||||
throw fail("resourceExhausted", "Model output byte budget exceeded");
|
||||
}
|
||||
turn.bytes += bytes;
|
||||
return { kind: "text", sequence: turn.sequence++, text: event.text };
|
||||
}
|
||||
if (event?.kind === "usage") {
|
||||
const usage = {};
|
||||
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
|
||||
if (event[key] !== undefined) {
|
||||
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
|
||||
usage[key] = event[key];
|
||||
}
|
||||
}
|
||||
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
|
||||
return { kind: "usage", sequence: turn.sequence++, ...usage };
|
||||
}
|
||||
// A model's tool request is data, never host authorization. This phase
|
||||
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
|
||||
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
|
||||
}
|
||||
|
||||
async stop(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) return false;
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
return true;
|
||||
}
|
||||
|
||||
#end(turn, reason = fail("cancelled", "Model turn ended")) {
|
||||
if (this.turns.get(turn.handle) !== turn) return;
|
||||
this.turns.delete(turn.handle);
|
||||
if (turn.chat.turn === turn) turn.chat.turn = null;
|
||||
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
|
||||
// Never wait for a provider which ignores cancellation. Its late next()
|
||||
// result is observed by abortable() and cannot re-enter the output path.
|
||||
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
|
||||
turn.iterator = null;
|
||||
}
|
||||
|
||||
#close(chat) {
|
||||
if (chat.turn) this.#end(chat.turn);
|
||||
this.chats.delete(chat.handle);
|
||||
try { Promise.resolve(chat.model.dispose?.()).catch(() => {}); } catch {}
|
||||
}
|
||||
async close(chatHandle, context) {
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) return false;
|
||||
await this.#ownedChat(chat, context); this.#close(chat); return true;
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const opening of [...this.openings]) if (opening.runtimeId === runtimeId) { opening.active = false; this.openings.delete(opening); }
|
||||
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
|
||||
this.contexts?.releaseRuntime(runtimeId);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const opening of [...this.openings]) if (opening.owner === owner) { opening.active = false; this.openings.delete(opening); }
|
||||
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
|
||||
this.contexts?.releaseOwner(owner);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const opening of this.openings) opening.active = false; this.openings.clear(); for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
|
||||
}
|
||||
|
||||
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,222 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
|
||||
function fixture(options = {}) {
|
||||
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
|
||||
const request = new AbortController();
|
||||
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
|
||||
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
|
||||
const calls = [];
|
||||
const model = { label: "Host selected model", isCurrent: () => current,
|
||||
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
|
||||
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
|
||||
const resolveDataOwner = async () => currentOwner;
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
|
||||
clock: () => now, limits: options.limits });
|
||||
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
|
||||
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
|
||||
}
|
||||
|
||||
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
|
||||
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
|
||||
assert.equal(f.calls.length, 0);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
|
||||
assert.equal(f.calls[0].tools, undefined);
|
||||
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
|
||||
assert.equal(events[0].text, "Summary");
|
||||
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
|
||||
assert.equal(events[2].kind, "end");
|
||||
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
|
||||
});
|
||||
|
||||
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
}
|
||||
f.setOwner({});
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
|
||||
f.setOwner(f.owner);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
|
||||
});
|
||||
|
||||
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
|
||||
const pending = deferred(); let signal;
|
||||
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
|
||||
t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
|
||||
assert.equal(signal.aborted, true);
|
||||
pending.resolve({ value: { kind: "text", text: "Late output" } });
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
});
|
||||
|
||||
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
|
||||
for (const action of ["runtime", "owner", "abort"]) {
|
||||
const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
if (action === "runtime") f.broker.releaseRuntime("r1");
|
||||
if (action === "owner") f.broker.releaseOwner(f.owner);
|
||||
if (action === "abort") f.request.abort();
|
||||
await assert.rejects(reading);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline also aborts providers that ignore AbortSignal", async () => {
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("model switch and chat expiration reject results", async () => {
|
||||
for (const action of ["model", "expiry"]) {
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading, denied); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
|
||||
const f = fixture({ limits: { requestsPerMinute: 1 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await f.broker.stop(turn.turnHandle, f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.releaseRuntime("r1");
|
||||
const again = await f.broker.open(f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.shutdown();
|
||||
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
|
||||
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
|
||||
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
|
||||
}
|
||||
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
|
||||
const selected = await broken.broker.open(broken.context);
|
||||
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
|
||||
broken.broker.shutdown();
|
||||
});
|
||||
|
||||
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
|
||||
for (const action of ["revoke", "version"]) {
|
||||
let current = true; const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
|
||||
readText: () => "Synthetic", isCurrent: () => current }, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "revoke") f.contexts.release(note.handle); else current = false;
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
|
||||
const cases = [
|
||||
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
|
||||
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
|
||||
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
|
||||
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
|
||||
if (scenario.start) await assert.rejects(start());
|
||||
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
|
||||
const f = fixture(); const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
|
||||
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
const controller = new AbortController();
|
||||
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
|
||||
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
|
||||
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
|
||||
});
|
||||
|
||||
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
|
||||
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
|
||||
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
|
||||
})() } });
|
||||
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
|
||||
const entered = deferred(); const resume = deferred(); let checks = 0;
|
||||
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
|
||||
t.after(() => { resume.resolve(); f.broker.shutdown(); });
|
||||
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
|
||||
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
|
||||
const outcome = start.then(() => "accepted", (error) => error);
|
||||
// On the old implementation, reach the reported post-publication model guard.
|
||||
// On the fixed implementation, validation rejects before that await exists.
|
||||
await Promise.race([entered.promise, outcome]);
|
||||
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
|
||||
assert.equal(f.contexts.handles.size, 0);
|
||||
resume.resolve();
|
||||
const error = await outcome;
|
||||
assert.equal(error.code, RPC_ERRORS.invalidArgument);
|
||||
assert.equal(f.broker.turns.size, 0);
|
||||
assert.equal(f.calls.length, 0);
|
||||
f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("pending model selections reserve capacity and cannot publish after revocation", async () => {
|
||||
const f = fixture({ limits: { perRuntime: 1 } }); const pending = deferred(); let disposed = 0;
|
||||
f.broker.selectModel = () => pending.promise;
|
||||
const opening = f.broker.open(f.context); const rejected = assert.rejects(opening);
|
||||
await new Promise(setImmediate);
|
||||
await assert.rejects(f.broker.open(f.context), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.releaseRuntime("r1");
|
||||
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
|
||||
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
|
||||
|
||||
// Consume the market task's host-owned API. No source or owner is read from RPC
|
||||
// parameters, and a missing API/retained source fails closed.
|
||||
class PluginAISourceBinding {
|
||||
constructor({ database, getRuntimeIdentity }) {
|
||||
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
|
||||
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
|
||||
}
|
||||
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
|
||||
this.database = database;
|
||||
this.getRuntimeIdentity = getRuntimeIdentity;
|
||||
this.bindings = new Map();
|
||||
}
|
||||
|
||||
#snapshot(context) {
|
||||
const identity = this.getRuntimeIdentity(context.pluginId);
|
||||
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|
||||
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
|
||||
const plugin = this.database.getActivePlugin(identity.pluginId);
|
||||
const source = plugin?.source;
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|
||||
|| !["starting", "running"].includes(plugin.runtime?.status)
|
||||
|| typeof source.id !== "string" || typeof source.kind !== "string"
|
||||
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
|
||||
const privateOwner = this.database.privateOwner(identity.pluginId);
|
||||
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
|
||||
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
|
||||
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
|
||||
}
|
||||
|
||||
async resolve(context) {
|
||||
// Authenticate first. Even the database source lookup follows this guard.
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const snapshot = this.#snapshot(context);
|
||||
const previous = this.bindings.get(snapshot.runtimeId);
|
||||
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|
||||
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.#snapshot(context);
|
||||
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
|
||||
if (previous) return previous;
|
||||
const binding = Object.freeze(snapshot);
|
||||
this.bindings.set(binding.runtimeId, binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
|
||||
}
|
||||
shutdown() { this.bindings.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { PluginAISourceBinding };
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
function fixture() {
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://registry.example" };
|
||||
let owner = "source:opaque-a:com.example.assistant"; let active = true; let runtimeId = "r1"; let reads = 0;
|
||||
const context = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", assertActive: async () => { if (!active) throw Error("Inactive"); } };
|
||||
const database = {
|
||||
getActivePlugin: () => { reads++; return { source, enabled: true, activeVersion: "1", runtime: { status: "running" } }; },
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => owner,
|
||||
publicPluginId: (value) => value === owner ? context.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => ({ ...context, runtimeId, runtimeKind: "browser" }) });
|
||||
return { binding, context, database, reads: () => reads, activeTo: (v) => { active = v; }, runtimeTo: (v) => { runtimeId = v; },
|
||||
sourceTo: (v) => { source = v; }, ownerTo: (v) => { owner = v; } };
|
||||
}
|
||||
|
||||
test("host authentication precedes database access; forged runtime and missing provenance fail closed", async () => {
|
||||
const f = fixture(); f.activeTo(false); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.activeTo(true); f.runtimeTo("different"); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.runtimeTo("r1"); f.database.getRetainedPluginSource = () => null; await assert.rejects(f.binding.resolve(f.context));
|
||||
assert.throws(() => new PluginAISourceBinding({ database: {}, getRuntimeIdentity: () => null }));
|
||||
});
|
||||
|
||||
test("opaque host owner remains stable and source changes cannot resume the old runtime", async () => {
|
||||
const f = fixture(); const owner = await f.binding.resolve({ ...f.context, source: "attacker", privateOwner: "attacker" });
|
||||
assert.equal(owner.privateOwner, "source:opaque-a:com.example.assistant");
|
||||
assert.equal(await f.binding.resolve(f.context), owner);
|
||||
f.sourceTo({ id: "source-b", kind: "third-party", origin: "https://other.example" });
|
||||
f.ownerTo("source:opaque-b:com.example.assistant"); await assert.rejects(f.binding.resolve(f.context));
|
||||
// A fresh authenticated runtime may acquire its own distinct binding.
|
||||
f.binding.releaseRuntime("r1"); const next = await f.binding.resolve(f.context); assert.notEqual(next, owner);
|
||||
});
|
||||
|
||||
test("source/owner changes during asynchronous authentication cannot commit a binding", async () => {
|
||||
const f = fixture(); let checks = 0;
|
||||
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
|
||||
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
getProcessId() {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
// Host-only transport core. No protocol, credential lookup, or plugin-supplied
|
||||
// Node runs here. The registered service authenticates source-bound runtimes.
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const dns = require("node:dns/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
|
||||
const LIMITS = Object.freeze(require("./generated/plugin-contract.schema.json").$defs.ByteStreamLimits.const);
|
||||
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
function failure(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
|
||||
|
||||
class PluginByteStreamBroker {
|
||||
constructor(options) {
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.quotaManager = options.quotaManager;
|
||||
this.audit = options.audit ?? (() => {});
|
||||
this.lookup = options.lookup ?? dns.lookup;
|
||||
this.connectSocket = options.connectSocket ?? ((params) => net.createConnection(params));
|
||||
this.connectTls = options.connectTls ?? ((params) => tls.connect(params));
|
||||
// Only a host test harness can opt a specific loopback endpoint in. The
|
||||
// production default excludes all loopback/link-local control surfaces.
|
||||
this.testLocalEndpoints = new Set(options.testLocalEndpoints ?? []);
|
||||
this.isControlEndpoint = options.isControlEndpoint ?? (() => false);
|
||||
this.connections = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.pluginId === event.pluginId
|
||||
&& (event.permission === undefined || event.permission === "network.stream")
|
||||
&& (event.resource === undefined || event.resource === state.endpoint)) {
|
||||
this.finish(state, failure("permissionDenied", "Byte stream permission was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record(state, operation) {
|
||||
// Audit only host-observed generic operations, never bytes or peer messages.
|
||||
try { this.audit(state.pluginId, `network.stream.${operation}`, {
|
||||
runtimeId: state.runtimeId, endpoint: state.endpoint,
|
||||
handleId: state.id, bytesRead: state.bytesRead, bytesWritten: state.bytesWritten,
|
||||
}, state); } catch { /* An audit sink cannot keep a revoked socket alive. */ }
|
||||
}
|
||||
|
||||
finish(state, reason) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
clearTimeout(state.connectTimer);
|
||||
clearTimeout(state.idleTimer);
|
||||
clearTimeout(state.lifetimeTimer);
|
||||
state.error = reason;
|
||||
this.connections.delete(state.id);
|
||||
state.controller.abort(reason ?? failure("cancelled", "Byte stream closed"));
|
||||
state.socket?.destroy();
|
||||
this.record(state, "close");
|
||||
for (const listener of this.closeListeners) {
|
||||
try { listener(state, reason); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onDidClose(listener) {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
touch(state) {
|
||||
clearTimeout(state.idleTimer);
|
||||
state.idleTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream idle timeout")), LIMITS.idleMs);
|
||||
state.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
async active(state, context) {
|
||||
this.assertCommit(state, context);
|
||||
await context.assertActive();
|
||||
this.assertCommit(state, context);
|
||||
}
|
||||
|
||||
assertCommit(state, context) {
|
||||
if (this.closed || state.closed || this.connections.get(state.id) !== state) {
|
||||
throw state.error ?? failure("unavailable", "Byte stream is closed");
|
||||
}
|
||||
state.controller.signal.throwIfAborted();
|
||||
context.signal?.throwIfAborted();
|
||||
context.assertCommit?.();
|
||||
}
|
||||
|
||||
async connect(endpoint, context) {
|
||||
let target;
|
||||
try { target = canonicalizeStreamEndpoint(endpoint); }
|
||||
catch { throw failure("invalidArgument", "Byte stream endpoint is invalid"); }
|
||||
if (this.closed) throw failure("unavailable", "Byte stream broker stopped");
|
||||
if (!context.runtimeId || !context.pluginId || typeof context.assertActive !== "function") {
|
||||
throw failure("unauthenticated", "Byte streams require a host runtime identity");
|
||||
}
|
||||
const count = [...this.connections.values()].filter((state) => state.runtimeId === context.runtimeId).length;
|
||||
if (count >= LIMITS.connectionsPerRuntime || this.connections.size >= LIMITS.connectionsTotal) {
|
||||
throw failure("resourceExhausted", "Byte stream connection limit exceeded");
|
||||
}
|
||||
const state = {
|
||||
...target, id: randomUUID(), pluginId: context.pluginId, runtimeId: context.runtimeId,
|
||||
controller: new AbortController(), bytesRead: 0, bytesWritten: 0,
|
||||
sourceOwner: context.sourceOwner, sourceId: context.sourceId,
|
||||
closed: false, reading: false, writing: false, socket: null,
|
||||
};
|
||||
this.connections.set(state.id, state);
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream connection cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
await raceWithAbort(Promise.resolve(this.permissionEngine.authorize({ ...context, signal: state.controller.signal }, {
|
||||
permission: "network.stream", resources: [target.endpoint],
|
||||
reason: `Open byte stream to ${target.endpoint}`,
|
||||
operationId: `network.stream:${target.endpoint}`,
|
||||
validateBeforeGrant: () => this.assertCommit(state, context),
|
||||
})), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
state.connectTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream connect timeout")), LIMITS.connectMs);
|
||||
const addresses = net.isIP(target.host)
|
||||
? [{ address: target.host, family: net.isIP(target.host) }]
|
||||
: await raceWithAbort(Promise.resolve(this.lookup(target.host, { all: true, verbatim: true })), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (!Array.isArray(addresses) || addresses.length === 0 || addresses.length > 64) {
|
||||
throw failure("unavailable", "Byte stream hostname could not be resolved");
|
||||
}
|
||||
// Validate the whole DNS answer and pin one literal address. No second
|
||||
// lookup, redirect, proxy discovery, or reconnect can change the peer.
|
||||
for (const peer of addresses) {
|
||||
if (![4, 6].includes(peer.family) || net.isIP(peer.address) !== peer.family) {
|
||||
throw failure("unavailable", "Byte stream resolver returned an invalid address");
|
||||
}
|
||||
const local = isLocalControlAddress(peer.address);
|
||||
const allowedTestPeer = this.testLocalEndpoints.has(target.endpoint)
|
||||
&& !this.isControlEndpoint(peer.address, target.port);
|
||||
if ((local && !allowedTestPeer) || (!local && this.isControlEndpoint(peer.address, target.port))) {
|
||||
throw failure("permissionDenied", "Byte streams cannot reach local or application control endpoints");
|
||||
}
|
||||
}
|
||||
const peer = addresses[0];
|
||||
const params = {
|
||||
host: peer.address, port: target.port, family: peer.family,
|
||||
highWaterMark: LIMITS.bufferBytes,
|
||||
};
|
||||
if (target.transport === "tls") {
|
||||
Object.assign(params, {
|
||||
rejectUnauthorized: true, minVersion: "TLSv1.2",
|
||||
...(net.isIP(target.host) ? {} : { servername: target.host }),
|
||||
checkServerIdentity: (_name, certificate) => tls.checkServerIdentity(target.host, certificate),
|
||||
});
|
||||
}
|
||||
this.assertCommit(state, context);
|
||||
const socket = target.transport === "tls" ? this.connectTls(params) : this.connectSocket(params);
|
||||
state.socket = socket;
|
||||
if (state.closed) { socket.destroy(); throw state.error ?? failure("cancelled", "Byte stream connection cancelled"); }
|
||||
socket.on("error", () => this.finish(state, failure("unavailable", "Byte stream transport failed")));
|
||||
// Preserve one EOF observation for a delayed consumer. This reservation
|
||||
// still counts toward quotas and expires on the existing idle/life timer.
|
||||
socket.on("close", () => {
|
||||
if (!state.eof) this.finish(state, failure("unavailable", "Byte stream transport closed"));
|
||||
});
|
||||
socket.on("end", () => { state.eof = true; });
|
||||
socket.pause();
|
||||
await raceWithAbort(new Promise((resolve) => socket.once(
|
||||
target.transport === "tls" ? "secureConnect" : "connect", resolve,
|
||||
)), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (target.transport === "tls" && !socket.authorized) {
|
||||
throw failure("permissionDenied", "Byte stream TLS validation failed");
|
||||
}
|
||||
clearTimeout(state.connectTimer);
|
||||
state.lifetimeTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream lifetime exceeded")), LIMITS.lifetimeMs);
|
||||
state.lifetimeTimer.unref?.();
|
||||
this.touch(state);
|
||||
this.assertCommit(state, context);
|
||||
this.record(state, "open");
|
||||
return Object.freeze({ handleId: state.id, endpoint: state.endpoint });
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
get(id, context) {
|
||||
const state = this.connections.get(id);
|
||||
if (!state || state.pluginId !== context.pluginId || state.runtimeId !== context.runtimeId
|
||||
|| state.sourceOwner !== context.sourceOwner || state.sourceId !== context.sourceId) {
|
||||
throw failure("permissionDenied", "Byte stream handle does not belong to this runtime");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
charge(state, direction, size) {
|
||||
if (state.bytesRead + state.bytesWritten + size > LIMITS.connectionBytes) {
|
||||
throw failure("resourceExhausted", "Byte stream byte limit exceeded");
|
||||
}
|
||||
this.quotaManager?.chargeBytes(state.runtimeId, "network.stream", size);
|
||||
state[direction] += size;
|
||||
this.touch(state);
|
||||
}
|
||||
|
||||
async read(id, context) {
|
||||
const state = this.get(id, context);
|
||||
if (state.reading) throw failure("failedPrecondition", "Byte stream has a pending read");
|
||||
state.reading = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream read cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
while (true) {
|
||||
this.assertCommit(state, context);
|
||||
const available = state.socket.readableLength;
|
||||
const bytes = state.socket.read(Math.min(available || LIMITS.chunkBytes, LIMITS.chunkBytes));
|
||||
if (bytes !== null) {
|
||||
this.charge(state, "bytesRead", bytes.byteLength);
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
if (state.eof || state.socket.readableEnded) { this.finish(state); return null; }
|
||||
await raceWithAbort(new Promise((resolve) => {
|
||||
const readable = () => {
|
||||
state.socket.off("end", readable);
|
||||
state.socket.off("readable", readable);
|
||||
state.controller.signal.removeEventListener("abort", readable);
|
||||
resolve();
|
||||
};
|
||||
state.socket.once("readable", readable);
|
||||
state.socket.once("end", readable);
|
||||
// Remove listeners even when cancellation wins the race.
|
||||
state.controller.signal.addEventListener("abort", readable, { once: true });
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async write(id, bytes, context) {
|
||||
const state = this.get(id, context);
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > LIMITS.chunkBytes) {
|
||||
throw failure("invalidArgument", "Byte stream chunk size is invalid");
|
||||
}
|
||||
if (state.writing) throw failure("resourceExhausted", "Byte stream has a pending write");
|
||||
state.writing = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream write cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
this.charge(state, "bytesWritten", bytes.byteLength);
|
||||
const owned = Buffer.from(bytes);
|
||||
await raceWithAbort(new Promise((resolve, reject) => {
|
||||
state.socket.write(owned, (error) => error ? reject(error) : resolve());
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(id, context) { this.finish(this.get(id, context)); }
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.runtimeId === runtimeId) this.finish(state,
|
||||
failure("unavailable", "Byte stream runtime stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const state of this.connections.values()) this.finish(state,
|
||||
failure("unavailable", "Byte stream broker stopped"));
|
||||
this.revocation.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
@@ -0,0 +1,330 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { once } = require("node:events");
|
||||
const { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamBroker.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function context(runtimeId = "runtime-a", controller = new AbortController()) {
|
||||
return { pluginId: "test.stream", runtimeId, signal: controller.signal,
|
||||
assertActive: async () => controller.signal.throwIfAborted() };
|
||||
}
|
||||
|
||||
async function server(t, listener, options) {
|
||||
const sockets = new Set();
|
||||
const instance = options ? tls.createServer(options, listener) : net.createServer(listener);
|
||||
instance.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
});
|
||||
instance.on("tlsClientError", () => {});
|
||||
instance.listen(0, "127.0.0.1");
|
||||
await once(instance, "listening");
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => instance.close(resolve));
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function broker(t, endpoint, options = {}) {
|
||||
const audit = [];
|
||||
const requests = [];
|
||||
let revoke;
|
||||
const instance = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_ctx, request) => requests.push(request),
|
||||
onDidRevoke(listener) { revoke = listener; return { dispose() {} }; },
|
||||
},
|
||||
audit: (...args) => audit.push(args),
|
||||
testLocalEndpoints: endpoint ? [endpoint] : [],
|
||||
...options,
|
||||
});
|
||||
t.after(() => instance.shutdown());
|
||||
return { instance, requests, audit, revoke: (event) => revoke(event) };
|
||||
}
|
||||
|
||||
test("stream endpoint canonicalization requires one exact host, transport and port", () => {
|
||||
assert.equal(canonicalizeStreamEndpoint("tls://EXAMPLE.test:443").endpoint, "tls://example.test:443");
|
||||
assert.equal(canonicalizeStreamEndpoint("tcp://[0:0:0:0:0:0:0:1]:3306").endpoint, "tcp://[::1]:3306");
|
||||
for (const value of ["*", "tcp://*:3306", "tcp://x:0", "tcp://x:01", "tcp://x:65536", "udp://x:22",
|
||||
"tcp://x", "tcp://x:22/", "tcp://user@x:22", "tcp://127.1:22", "tcp://x:22?x", "tcp://x.:22",
|
||||
"tcp://[::1%25lo]:22", "tcp://x:22#x", "tcp://-x:22"]) {
|
||||
assert.throws(() => canonicalizeStreamEndpoint(value), TypeError, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("local/control address classification handles mapped IPv6 and alternate literals", () => {
|
||||
for (const address of ["127.0.0.1", "127.9.8.7", "0.0.0.0", "169.254.169.254", "224.0.0.1",
|
||||
"::1", "::", "::ffff:127.0.0.1", "::ffff:7f00:1", "fe80::1", "ff02::1", "2002:7f00:1::"]) {
|
||||
assert.equal(isLocalControlAddress(address), true, address);
|
||||
}
|
||||
for (const address of ["192.168.1.2", "10.0.0.8", "203.0.113.1", "2001:db8::1", "fd00::1"]) {
|
||||
assert.equal(isLocalControlAddress(address), false, address);
|
||||
}
|
||||
});
|
||||
|
||||
test("real TCP transport exchanges binary bytes and records no payloads", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.on("data", (bytes) => socket.write(bytes)));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, requests, audit } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const handle = await instance.connect(endpoint, ctx);
|
||||
const bytes = Uint8Array.from([0, 255, 1, 254, 19]);
|
||||
await instance.write(handle.handleId, bytes, ctx);
|
||||
assert.deepEqual(await instance.read(handle.handleId, ctx), bytes);
|
||||
instance.close(handle.handleId, ctx);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
assert.deepEqual(requests[0].resources, [endpoint]);
|
||||
assert.equal(requests[0].permission, "network.stream");
|
||||
assert.deepEqual(audit.map((entry) => entry[1]), ["network.stream.open", "network.stream.close"]);
|
||||
assert.equal(audit[1][2].bytesRead, bytes.byteLength);
|
||||
assert.equal(audit[1][2].bytesWritten, bytes.byteLength);
|
||||
assert.equal(JSON.stringify(audit).includes("data"), false);
|
||||
});
|
||||
|
||||
test("remote EOF returns the final bytes and releases the connection", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("synthetic final bytes"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const chunk = await instance.read(handleId, ctx);
|
||||
if (chunk === null) break;
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
assert.equal(Buffer.concat(chunks).toString(), "synthetic final bytes");
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("a delayed consumer can observe EOF after the real socket has closed", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("final"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const socket = instance.connections.get(handleId).socket;
|
||||
const closed = once(socket, "close");
|
||||
assert.equal(Buffer.from(await instance.read(handleId, ctx)).toString(), "final");
|
||||
await closed;
|
||||
await assert.rejects(instance.read(handleId, context("other-runtime")), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await instance.read(handleId, ctx), null);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
const second = await instance.connect(endpoint, ctx);
|
||||
const secondClosed = once(instance.connections.get(second.handleId).socket, "close");
|
||||
await instance.read(second.handleId, ctx);
|
||||
await secondClosed;
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(instance.read(second.handleId, ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("denial happens before DNS or a socket is created", async (t) => {
|
||||
let lookedUp = false;
|
||||
const { instance } = broker(t, null, {
|
||||
permissionEngine: { authorize: async () => { throw new Error("denied"); }, onDidRevoke: () => ({ dispose() {} }) },
|
||||
lookup: () => { lookedUp = true; },
|
||||
});
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", context()), /denied/u);
|
||||
assert.equal(lookedUp, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("production rejects loopback, mixed DNS answers, and app control peers", async (t) => {
|
||||
const endpoint = "tcp://example.test:22";
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: async () => [{ address: "203.0.113.1", family: 4 }, { address: "127.0.0.1", family: 4 }],
|
||||
});
|
||||
await assert.rejects(instance.connect(endpoint, context()), { code: RPC_ERRORS.permissionDenied });
|
||||
await assert.rejects(instance.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
const protectedBroker = broker(t, "tcp://127.0.0.1:22", { isControlEndpoint: () => true }).instance;
|
||||
await assert.rejects(protectedBroker.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("runtime handles cannot cross runtime or plugin boundaries", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
for (const other of [context("runtime-b"), { ...ctx, pluginId: "test.other" }]) {
|
||||
await assert.rejects(instance.read(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.throws(() => instance.close(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(1), ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("revocation and runtime release cancel pending real reads", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
let handle = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handle.handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
handle = await instance.connect(endpoint, ctx);
|
||||
const secondRead = instance.read(handle.handleId, ctx);
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
await assert.rejects(secondRead, { code: RPC_ERRORS.unavailable });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation during DNS rejects promptly and ignores late resolution", async (t) => {
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = instance.connect("tcp://example.test:22", context("runtime-a", controller));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation after the last async guard cannot create a late real socket", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, endpoint, { connectSocket(options) {
|
||||
dialed = true;
|
||||
const socket = net.createConnection(options);
|
||||
t.after(() => socket.destroy());
|
||||
return socket;
|
||||
} });
|
||||
const controller = new AbortController();
|
||||
let guards = 0;
|
||||
const ctx = context("runtime-a", controller);
|
||||
ctx.assertActive = async () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (++guards === 3) queueMicrotask(() => queueMicrotask(() => controller.abort()));
|
||||
};
|
||||
await assert.rejects(instance.connect(endpoint, ctx), { code: RPC_ERRORS.cancelled });
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("connect and idle deadlines close resources and reject pending operations", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.idleMs);
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.deadlineExceeded });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const slow = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
}).instance;
|
||||
const pending = slow.connect("tcp://example.test:22", ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.connectMs);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.deadlineExceeded });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(slow.connections.size, 0);
|
||||
});
|
||||
|
||||
test("concurrent operations and oversized writes cannot grow an unbounded queue", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const pending = instance.read(handleId, ctx);
|
||||
await assert.rejects(instance.read(handleId, ctx), { code: RPC_ERRORS.failedPrecondition });
|
||||
await assert.rejects(instance.write(handleId, new Uint8Array(LIMITS.chunkBytes + 1), ctx), { code: RPC_ERRORS.invalidArgument });
|
||||
const writing = instance.write(handleId, Uint8Array.of(1), ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(2), ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
await writing;
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
});
|
||||
|
||||
test("connection reservations bound concurrent DNS operations", async (t) => {
|
||||
const { instance } = broker(t, null, { lookup: () => new Promise(() => {}) });
|
||||
const ctx = context();
|
||||
const pending = Array.from({ length: LIMITS.connectionsPerRuntime }, () => instance.connect("tcp://example.test:22", ctx));
|
||||
const results = Promise.allSettled(pending);
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
assert.ok((await results).every((result) => result.status === "rejected"));
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real unread TCP output stays bounded and byte quota closes the socket", async (t) => {
|
||||
const peer = await server(t, (socket) => {
|
||||
const flood = () => { while (!socket.destroyed && socket.write(Buffer.alloc(LIMITS.chunkBytes))) {} };
|
||||
socket.on("drain", flood);
|
||||
flood();
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint, { quotaManager: {
|
||||
chargeBytes() { throw new Error("quota reached"); },
|
||||
} });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const state = instance.connections.get(handleId);
|
||||
assert.ok(state.socket.readableLength <= LIMITS.bufferBytes + LIMITS.chunkBytes);
|
||||
await assert.rejects(instance.read(handleId, ctx), /quota reached/u);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real TLS validates trust and the original hostname against a pinned IP", async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-stream-tls-test-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const keyPath = path.join(directory, "test.key");
|
||||
const certPath = path.join(directory, "test.crt");
|
||||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath,
|
||||
"-out", certPath, "-days", "1", "-subj", "/CN=stream-fixture.test", "-addext", "subjectAltName=DNS:stream-fixture.test"],
|
||||
{ stdio: "ignore" });
|
||||
const cert = fs.readFileSync(certPath);
|
||||
const peer = await server(t, (socket) => socket.on("data", (data) => socket.write(data)), {
|
||||
key: fs.readFileSync(keyPath), cert,
|
||||
});
|
||||
const endpoint = `tls://stream-fixture.test:${peer.address().port}`;
|
||||
const lookup = async () => [{ address: "127.0.0.1", family: 4 }];
|
||||
const untrusted = broker(t, endpoint, { lookup }).instance;
|
||||
await assert.rejects(untrusted.connect(endpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
let dialOptions;
|
||||
const trusted = broker(t, endpoint, { lookup,
|
||||
connectTls: (options) => { dialOptions = options; return tls.connect({ ...options, ca: cert }); },
|
||||
}).instance;
|
||||
const ctx = context();
|
||||
const { handleId } = await trusted.connect(endpoint, ctx);
|
||||
assert.equal(dialOptions.host, "127.0.0.1");
|
||||
assert.equal(dialOptions.servername, "stream-fixture.test");
|
||||
assert.equal(dialOptions.rejectUnauthorized, true);
|
||||
await trusted.write(handleId, Uint8Array.of(42), ctx);
|
||||
assert.deepEqual(await trusted.read(handleId, ctx), Uint8Array.of(42));
|
||||
trusted.close(handleId, ctx);
|
||||
const wrongEndpoint = `tls://wrong-fixture.test:${peer.address().port}`;
|
||||
const wrong = broker(t, wrongEndpoint, { lookup, connectTls: (options) => tls.connect({ ...options, ca: cert }) }).instance;
|
||||
await assert.rejects(wrong.connect(wrongEndpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
function failure(_code, message) { return new TypeError(message); }
|
||||
|
||||
function canonicalizeStreamEndpoint(value) {
|
||||
if (typeof value !== "string" || value.length > 512) {
|
||||
throw failure("invalidArgument", "Byte stream endpoint is invalid");
|
||||
}
|
||||
// URL's permissive non-special scheme parser accepts paths, userinfo, and
|
||||
// alternate IPv4 spellings. Require a complete unambiguous authority instead.
|
||||
const match = /^(tcp|tls):\/\/(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$/u.exec(value);
|
||||
if (!match) throw failure("invalidArgument", "Byte streams require an exact TCP or TLS host and port");
|
||||
let host = match[2].toLowerCase();
|
||||
const port = Number(match[3]);
|
||||
if (port < 1 || port > 65535 || String(port) !== match[3]) {
|
||||
throw failure("invalidArgument", "Byte stream port is invalid");
|
||||
}
|
||||
if (host.startsWith("[")) {
|
||||
host = host.slice(1, -1);
|
||||
if (net.isIP(host) !== 6) throw failure("invalidArgument", "Byte stream IPv6 address is invalid");
|
||||
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
||||
} else if (!net.isIP(host)) {
|
||||
if (host.length > 253 || /^[0-9.]+$/u.test(host) || host.split(".").some((label) => (
|
||||
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)
|
||||
))) throw failure("invalidArgument", "Byte stream hostname is invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: `${match[1]}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`,
|
||||
transport: match[1], host, port,
|
||||
});
|
||||
}
|
||||
|
||||
function 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 = 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, normalizeStreamAddress };
|
||||
@@ -0,0 +1,138 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
|
||||
|
||||
test("installed browser entrypoint uses registered SDK/RPC streams, denial and revoke", { timeout: 15_000 }, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-byte-entry-"));
|
||||
let service;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
socket.on("data", (bytes) => socket.write(bytes));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(async () => {
|
||||
await service?.manager.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const packageRoot = path.join(root, "source");
|
||||
await fs.mkdir(packageRoot);
|
||||
const id = "com.netcatty.byte-entry-test";
|
||||
const manifest = {
|
||||
manifestVersion: 1, id, name: "byte-entry-test", version: "0.1.0", publisher: "netcatty",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal.1 <0.2.0" },
|
||||
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
|
||||
activationEvents: [`onCommand:${id}.run`],
|
||||
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
|
||||
contributes: { commands: ["run", "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", "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({});
|
||||
return context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
|
||||
}
|
||||
if (name === "ai") {
|
||||
const chat = await context.ai.openChat({});
|
||||
try {
|
||||
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic prompt" });
|
||||
const events = [];
|
||||
while (true) { const event = await context.ai.readTurn({ turnHandle: turn.turnHandle }); events.push(event); if (event.kind === "end") return events; }
|
||||
} finally { await context.ai.closeChat({ chatHandle: chat.chatHandle }); }
|
||||
}
|
||||
if (name === "cancel") { await connection?.close(); return true; }
|
||||
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
|
||||
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
|
||||
if (name === "pending") return { connected: true };
|
||||
if (name === "cancelRead") {
|
||||
const reading = connection.readable.read();
|
||||
connection.readable.cancel();
|
||||
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
|
||||
}
|
||||
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());
|
||||
} finally { await connection.close(); await connection.close(); }
|
||||
}));
|
||||
} };`);
|
||||
let allow = false;
|
||||
const decisions = [];
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
pluginAIHostAdapter: {
|
||||
assertActive: (context) => context.assertActive(),
|
||||
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: async () => true,
|
||||
invoke: async () => (async function* () { yield { kind: "text", text: "synthetic model result" }; yield { kind: "usage", inputTokens: 2, outputTokens: 3, totalTokens: 5 }; })(),
|
||||
}),
|
||||
listSelected: async () => [{ key: "fixture", scope: "tab", title: "Synthetic tab", version: "v1",
|
||||
readText: async () => "synthetic selected text", isCurrent: async () => true }],
|
||||
reviewText: async () => true,
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
return request.permission === "network.stream" && !allow
|
||||
? { requestId: request.requestId, decision: "deny" }
|
||||
: { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
|
||||
},
|
||||
});
|
||||
// Explicit test-only loopback fixture injection. The production constructor
|
||||
// exposes no option that forwards these overrides to the broker.
|
||||
service.byteStreamService.broker.testLocalEndpoints.add(endpoint);
|
||||
service.byteStreamService.broker.isControlEndpoint = () => false;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const archive = path.join(root, "fixture.ncpkg");
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
|
||||
await assert.rejects(run("run"), /denied/u);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
allow = true;
|
||||
assert.deepEqual(await run("run"), [0, 255, 42]);
|
||||
assert.equal(await run("cancelRead"), true);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
|
||||
await assert.rejects(run("spoof"), /contract/u);
|
||||
assert.deepEqual(await run("ai"), [
|
||||
{ kind: "text", sequence: 0, text: "synthetic model result" },
|
||||
{ kind: "usage", sequence: 1, inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
||||
{ kind: "end", sequence: 2 },
|
||||
]);
|
||||
assert.equal((await run("selected")).text, "synthetic selected text");
|
||||
await assert.rejects(run("aiSpoof"), /invalid/u);
|
||||
assert.deepEqual(await run("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);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
assert.equal(service.byteStreamService.closedHandles.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
"use strict";
|
||||
|
||||
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");
|
||||
|
||||
const FEATURE = require("./generated/plugin-contract.schema.json").$defs.ByteStreamFeature.const;
|
||||
function denied() { return new PluginRpcError(RPC_ERRORS.permissionDenied, "Byte stream runtime or source binding is invalid"); }
|
||||
function validator(name) {
|
||||
const validate = createDefinitionValidator(name);
|
||||
return (params) => {
|
||||
if (!validate(params)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Byte stream parameters violate the contract");
|
||||
return params;
|
||||
};
|
||||
}
|
||||
|
||||
class PluginByteStreamService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.records = new Map();
|
||||
this.closedHandles = new Map();
|
||||
this.closed = false;
|
||||
this.broker = new PluginByteStreamBroker({
|
||||
permissionEngine: options.permissionEngine,
|
||||
quotaManager: options.quotaManager,
|
||||
// The public host service never forwards transport fixture overrides.
|
||||
isControlEndpoint: (address) => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
this.brokerClose = this.broker.onDidClose((state, reason) => {
|
||||
if (reason) this.closeRecord(this.records.get(state.id), reason);
|
||||
});
|
||||
}
|
||||
|
||||
sourceMatches(identity) {
|
||||
const active = this.database.getActivePlugin(identity.pluginId);
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
return Boolean(active?.enabled && identity.sourceOwner && identity.sourceId
|
||||
&& active.source?.id === identity.sourceId && retained?.id === identity.sourceId
|
||||
&& this.database.privateOwner(identity.pluginId) === identity.sourceOwner
|
||||
&& this.database.publicPluginId(identity.sourceOwner) === identity.pluginId);
|
||||
}
|
||||
|
||||
async authenticate(context) {
|
||||
await context.assertActive();
|
||||
const assertCommit = () => {
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.runtimeSupervisor.getRuntimeIdentity(context.pluginId);
|
||||
if (this.closed || !current || current.runtimeId !== context.runtimeId
|
||||
|| current.pluginVersion !== context.pluginVersion
|
||||
|| current.securityPrincipal !== context.securityPrincipal
|
||||
|| current.sourceOwner !== context.sourceOwner || current.sourceId !== context.sourceId
|
||||
|| !this.sourceMatches(context)) throw denied();
|
||||
};
|
||||
assertCommit();
|
||||
if (!context.manifest?.features?.required?.includes(FEATURE)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Byte stream feature was not negotiated");
|
||||
}
|
||||
return Object.freeze({ ...context, assertCommit,
|
||||
assertActive: async () => { await context.assertActive(); assertCommit(); },
|
||||
});
|
||||
}
|
||||
|
||||
register(registry) {
|
||||
// These routes own authorization: connect authenticates before the broker
|
||||
// obtains its exact endpoint grant; attach/close prove handle ownership.
|
||||
// The registry public flag skips only the generic permission middleware.
|
||||
// Runtime authentication and quota middleware still apply to every call.
|
||||
registry.registerRequest("network.stream.connect", async ({ endpoint }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const handle = await this.broker.connect(endpoint, context);
|
||||
try {
|
||||
context.assertCommit();
|
||||
this.broker.get(handle.handleId, context);
|
||||
const record = {
|
||||
...handle, context,
|
||||
inputStreamId: `byte-in-${randomUUID()}`, outputStreamId: `byte-out-${randomUUID()}`,
|
||||
attached: false, closing: false, input: null, output: null,
|
||||
};
|
||||
this.records.set(handle.handleId, record);
|
||||
record.abort = () => this.closeRecord(record);
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
return { ...handle, inputStreamId: record.inputStreamId,
|
||||
outputStreamId: record.outputStreamId, windowBytes: LIMITS.bufferBytes };
|
||||
} catch (error) {
|
||||
const state = this.broker.connections.get(handle.handleId);
|
||||
if (state) this.broker.finish(state, error);
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamConnectRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.attach", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const record = this.get(handleId, context);
|
||||
if (record.attached) throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Byte stream is already attached");
|
||||
record.attached = true;
|
||||
try {
|
||||
record.output = await this.runtimeSupervisor.openStream(context.pluginId, record.outputStreamId, LIMITS.bufferBytes, {
|
||||
expectedIdentity: context,
|
||||
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
|
||||
});
|
||||
context.assertCommit();
|
||||
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
// A cancelled attachment cannot leave a producer running after its RPC.
|
||||
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
|
||||
return { attached: true };
|
||||
} catch (error) {
|
||||
const alreadyClosing = record.closing;
|
||||
this.closeRecord(record, error);
|
||||
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.close", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
if (!this.wasClosed(handleId, context)) this.closeRecord(this.get(handleId, context));
|
||||
return null;
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
this.streamRegistration = registry.registerIncomingStream(async (stream, caller) => {
|
||||
const record = [...this.records.values()].find((item) => item.inputStreamId === stream.streamId);
|
||||
if (!record) return false;
|
||||
const context = await this.authenticate(caller);
|
||||
this.get(record.handleId, context);
|
||||
if (record.input || stream.windowBytes !== LIMITS.bufferBytes) throw denied();
|
||||
record.input = stream;
|
||||
stream.bind({
|
||||
onChunk: async (chunk, release) => {
|
||||
try {
|
||||
if (chunk.encoding !== "binary" || !(chunk.bytes instanceof Uint8Array)) throw denied();
|
||||
await this.broker.write(record.handleId, chunk.bytes, record.context);
|
||||
record.context.assertCommit();
|
||||
release();
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
},
|
||||
onClose: () => this.closeRecord(record),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get(handleId, context) {
|
||||
const record = this.records.get(handleId);
|
||||
if (!record || record.closing || record.context.runtimeId !== context.runtimeId
|
||||
|| record.context.pluginId !== context.pluginId || record.context.sourceOwner !== context.sourceOwner
|
||||
|| record.context.sourceId !== context.sourceId) throw denied();
|
||||
return record;
|
||||
}
|
||||
|
||||
wasClosed(handleId, context) {
|
||||
const owner = this.closedHandles.get(handleId);
|
||||
return owner && owner.runtimeId === context.runtimeId && owner.pluginId === context.pluginId
|
||||
&& owner.sourceOwner === context.sourceOwner && owner.sourceId === context.sourceId;
|
||||
}
|
||||
|
||||
async pump(record) {
|
||||
try {
|
||||
while (!record.closing) {
|
||||
const bytes = await this.broker.read(record.handleId, record.context);
|
||||
if (record.closing) return;
|
||||
record.context.assertCommit();
|
||||
if (bytes === null) { record.output.end(); break; }
|
||||
await record.output.write(bytes);
|
||||
}
|
||||
this.closeRecord(record);
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
}
|
||||
|
||||
closeRecord(record, reason) {
|
||||
if (!record || record.closing) return;
|
||||
record.closing = true;
|
||||
record.context.signal?.removeEventListener("abort", record.abort);
|
||||
this.records.delete(record.handleId);
|
||||
const { pluginId, runtimeId, sourceOwner, sourceId } = record.context;
|
||||
this.closedHandles.set(record.handleId, { pluginId, runtimeId, sourceOwner, sourceId });
|
||||
while (this.closedHandles.size > 256) this.closedHandles.delete(this.closedHandles.keys().next().value);
|
||||
const state = this.broker.connections.get(record.handleId);
|
||||
if (state) this.broker.finish(state, reason);
|
||||
try { record.output?.cancel(); } catch {}
|
||||
try { void Promise.resolve(record.input?.cancel()).catch(() => {}); } catch {}
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.context.runtimeId === runtimeId) this.closeRecord(record);
|
||||
}
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
for (const [id, owner] of this.closedHandles) {
|
||||
if (owner.runtimeId === runtimeId) this.closedHandles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const record of this.records.values()) this.closeRecord(record);
|
||||
this.broker.shutdown();
|
||||
this.brokerClose.dispose();
|
||||
this.streamRegistration?.dispose();
|
||||
this.closedHandles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginByteStreamService, FEATURE };
|
||||
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
|
||||
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 { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
let peer;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
let service;
|
||||
t.after(async () => {
|
||||
service?.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
let source = "first";
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
|
||||
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
|
||||
manifest: { features: { required: ["network.byte-streams"] } },
|
||||
};
|
||||
const audit = [];
|
||||
const writes = [];
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service = new PluginByteStreamService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
|
||||
getRetainedPluginSource: () => ({ id: source }),
|
||||
privateOwner: () => `opaque-${source}`,
|
||||
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
|
||||
recordSecurityAudit: (...args) => audit.push(args),
|
||||
},
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
|
||||
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
|
||||
})) },
|
||||
});
|
||||
service.broker.testLocalEndpoints.add(endpoint);
|
||||
service.broker.isControlEndpoint = () => false;
|
||||
service.register(registry);
|
||||
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
|
||||
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
|
||||
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
|
||||
}
|
||||
|
||||
test("source changes before grant publication cannot create a socket or grant", async (t) => {
|
||||
let approve;
|
||||
let grants = 0;
|
||||
const f = await fixture(t, { authorize: async (_context, descriptor) => {
|
||||
await new Promise((resolve) => { approve = resolve; });
|
||||
descriptor.validateBeforeGrant();
|
||||
grants++;
|
||||
} });
|
||||
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
while (!approve) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.swap(); approve();
|
||||
await assert.rejects(pending, /source binding/u);
|
||||
assert.equal(grants, 0);
|
||||
assert.equal(f.peer(), undefined);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.equal(f.audit.length, 0);
|
||||
});
|
||||
|
||||
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
await f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
f.swap();
|
||||
f.peer().write("synthetic old-source bytes");
|
||||
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
|
||||
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
|
||||
});
|
||||
|
||||
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
|
||||
let opened;
|
||||
let cancelled = 0;
|
||||
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
while (!opened) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.service.releaseRuntime("runtime-one");
|
||||
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
|
||||
await assert.rejects(attaching, /cancelled/u);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const SCOPES = new Set(["workspace", "tab", "session", "host", "note"]);
|
||||
const LIMITS = Object.freeze({ handles: 128, perRuntime: 8, contentBytes: 16_384, ttlMs: 300_000, readMs: 5_000 });
|
||||
|
||||
// Internal host authority. Only trusted selection/review UI may call share().
|
||||
// A menu id, plugin supplied entity id or global inventory is never accepted.
|
||||
class SelectedContextAuthority {
|
||||
constructor({ resolveDataOwner, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function") throw new TypeError("Host data-owner resolver required");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.clock = clock;
|
||||
this.limits = Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = limits[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
this.handles = new Map();
|
||||
this.closed = false;
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
async binding(context) {
|
||||
context.signal?.throwIfAborted();
|
||||
await context.assertActive();
|
||||
const owner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) {
|
||||
throw fail("permissionDenied", "Host data owner is unavailable");
|
||||
}
|
||||
context.signal?.throwIfAborted();
|
||||
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
}
|
||||
|
||||
async #check(entry, context) {
|
||||
const binding = await this.binding(context);
|
||||
if (this.closed || this.handles.get(entry.handle) !== entry || entry.expiresAt <= this.clock()
|
||||
|| binding.owner !== entry.owner || binding.runtimeId !== entry.runtimeId
|
||||
|| binding.pluginVersion !== entry.pluginVersion) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
context.signal?.throwIfAborted();
|
||||
// The trusted reader checks the current window/resource generation and owner.
|
||||
// Recheck our map after awaiting it: close/revoke/version changes can race.
|
||||
const current = await entry.isCurrent();
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
if (!current || currentOwner !== entry.owner || this.handles.get(entry.handle) !== entry || this.closed || entry.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Selected context changed");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const [handle, entry] of this.handles) if (entry.expiresAt <= this.clock()) this.release(handle);
|
||||
}
|
||||
|
||||
async share(selection, context) {
|
||||
if (this.closed) throw fail("unavailable", "Selected context is closed");
|
||||
assertPluginJsonValue({ scope: selection.scope, title: selection.title, version: selection.version }, { maxBytes: 1024 });
|
||||
if (!SCOPES.has(selection.scope) || typeof selection.title !== "string" || selection.title.length > 160
|
||||
|| typeof selection.version !== "string" || !selection.version || selection.version.length > 128
|
||||
|| typeof selection.readText !== "function" || typeof selection.isCurrent !== "function") {
|
||||
throw fail("invalidArgument", "Invalid selected context");
|
||||
}
|
||||
// This API accepts a curated text reader, never a Host/Vault DTO. Passwords,
|
||||
// private keys and arbitrary entity fields have no place in the descriptor.
|
||||
if (Object.keys(selection).some((key) => !["scope", "title", "version", "readText", "isCurrent"].includes(key))) {
|
||||
throw fail("invalidArgument", "Selected context contains unsupported fields");
|
||||
}
|
||||
const binding = await this.binding(context);
|
||||
if (!await selection.isCurrent()) throw fail("permissionDenied", "Selection is no longer current");
|
||||
await context.assertActive();
|
||||
this.#prune();
|
||||
if (this.closed || this.handles.size >= this.limits.handles
|
||||
|| [...this.handles.values()].filter((entry) => entry.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Selected context handle limit reached");
|
||||
}
|
||||
const entry = { ...binding, ...selection, handle: randomUUID(), expiresAt: this.clock() + this.limits.ttlMs };
|
||||
this.handles.set(entry.handle, entry);
|
||||
return this.#summary(entry);
|
||||
}
|
||||
|
||||
#summary(entry) {
|
||||
return { handle: entry.handle, scope: entry.scope, title: entry.title, version: entry.version, expiresAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
async summaries(context) {
|
||||
const binding = await this.binding(context);
|
||||
this.#prune();
|
||||
const summaries = [];
|
||||
for (const entry of [...this.handles.values()]) {
|
||||
if (entry.owner !== binding.owner || entry.runtimeId !== binding.runtimeId || entry.pluginVersion !== binding.pluginVersion) continue;
|
||||
try { await this.#check(entry, context); summaries.push(this.#summary(entry)); }
|
||||
catch (error) { if (context.signal?.aborted) throw error; this.release(entry.handle); }
|
||||
}
|
||||
await context.assertActive();
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async #read({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
let text;
|
||||
try { text = await entry.readText({ signal: context.signal, maxBytes: this.limits.contentBytes }); }
|
||||
catch { throw fail("unavailable", "Selected context reader failed"); }
|
||||
await this.#check(entry, context);
|
||||
if (typeof text !== "string" || text.includes("\0") || Buffer.byteLength(text) > this.limits.contentBytes) {
|
||||
throw fail("resourceExhausted", "Selected context exceeds its text limit");
|
||||
}
|
||||
// Do not disclose private key material even if a curated text reader errs.
|
||||
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text)) {
|
||||
throw fail("permissionDenied", "Private key material cannot be shared");
|
||||
}
|
||||
return { ...this.#summary(entry), text };
|
||||
}
|
||||
|
||||
async read(reference, context) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(fail("deadlineExceeded", "Selected context read timed out")), this.limits.readMs);
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
|
||||
let abort;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
return await new Promise((resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
this.#read(reference, { ...context, signal }).then(resolve, reject);
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (abort) signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCurrent({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
}
|
||||
onDidInvalidate(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
release(handle) {
|
||||
if (!this.handles.delete(handle)) return;
|
||||
for (const listener of [...this.listeners]) {
|
||||
// Revocation must finish even when an observer fails. Never log selected
|
||||
// text or let an observer exception retain another runtime handle.
|
||||
try { listener(handle); } catch {}
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [handle, entry] of this.handles) if (entry.runtimeId === runtimeId) this.release(handle);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const [handle, entry] of this.handles) if (entry.owner === owner) this.release(handle);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const handle of [...this.handles.keys()]) this.release(handle); this.listeners.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { SelectedContextAuthority, SELECTED_CONTEXT_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { SelectedContextAuthority } = require("./selectedContextAuthority.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
function fixture() {
|
||||
const owner = {}; let currentOwner = owner; let current = true; let version = "1"; let reads = 0; let now = 0;
|
||||
const context = { pluginId: "same", pluginVersion: "1", runtimeId: "r1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: async () => currentOwner, clock: () => now });
|
||||
const selection = { scope: "note", title: "Synthetic Note", version: "1", isCurrent: () => current && version === "1",
|
||||
readText: () => { reads++; return "synthetic content"; } };
|
||||
return { owner, context, authority, selection, reads: () => reads, ownerTo: (v) => { currentOwner = v; },
|
||||
versionTo: (v) => { version = v; }, currentTo: (v) => { current = v; }, timeTo: (v) => { now = v; } };
|
||||
}
|
||||
|
||||
test("summaries enumerate only explicitly shared selection and never read content", async () => {
|
||||
const f = fixture(); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
const note = await f.authority.share(f.selection, f.context);
|
||||
assert.deepEqual(await f.authority.summaries(f.context), [note]); assert.equal(f.reads(), 0);
|
||||
assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
assert.equal(f.reads(), 1);
|
||||
assert.deepEqual(await f.authority.summaries({ ...f.context, runtimeId: "r2" }), []);
|
||||
f.ownerTo({}); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
f.ownerTo(f.owner); assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
});
|
||||
|
||||
test("scope, owner, expected version and resource lifetime are enforced before read", async () => {
|
||||
for (const action of ["version", "owner", "closed", "expiry", "release", "runtime"]) {
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "closed") f.authority.shutdown();
|
||||
if (action === "expiry") f.timeTo(300_001);
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "runtime") f.authority.releaseRuntime("r1");
|
||||
await assert.rejects(f.authority.read(note, f.context)); assert.equal(f.reads(), 0);
|
||||
}
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
await assert.rejects(f.authority.read({ ...note, version: "2" }, f.context));
|
||||
await assert.rejects(f.authority.read({ handle: "menu-id", version: "1" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, scope: "all" }, f.context));
|
||||
});
|
||||
|
||||
test("revocation/version/owner replacement while reader awaits suppresses late content", async () => {
|
||||
for (const action of ["release", "version", "owner", "source"]) {
|
||||
const f = fixture(); const pending = deferred(); let started;
|
||||
const entered = new Promise((r) => { started = r; });
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => { started(); return pending.promise; } }, f.context);
|
||||
const reading = f.authority.read(note, f.context); await entered;
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "source") f.ownerTo({});
|
||||
pending.resolve("late secret text"); await assert.rejects(reading);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Host fields, private keys and oversized content are excluded", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(f.authority.share({ ...f.selection, password: "forbidden" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, privateKey: "forbidden" }, f.context));
|
||||
for (const text of ["-----BEGIN OPENSSH PRIVATE KEY-----\nforbidden", "X".repeat(16_385)]) {
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => text }, f.context);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
}
|
||||
});
|
||||
|
||||
test("content reader has a deadline and respects request cancellation", async () => {
|
||||
const owner = {}; const context = { runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: () => owner, limits: { readMs: 20 } });
|
||||
const note = await authority.share({ scope: "note", title: "Synthetic", version: "1", isCurrent: () => true,
|
||||
readText: () => new Promise(() => {}) }, context);
|
||||
await assert.rejects(authority.read(note, context), /timed out/);
|
||||
const controller = new AbortController(); const reading = authority.read(note, { ...context, signal: controller.signal });
|
||||
controller.abort(); await assert.rejects(reading); authority.shutdown();
|
||||
});
|
||||
|
||||
test("one failing invalidation listener cannot interrupt other listeners or handle cleanup", async () => {
|
||||
const f = fixture(); const first = await f.authority.share(f.selection, f.context); const second = await f.authority.share(f.selection, f.context);
|
||||
const notified = [];
|
||||
f.authority.onDidInvalidate(() => { throw Error("listener failed"); });
|
||||
f.authority.onDidInvalidate((handle) => notified.push(handle));
|
||||
assert.doesNotThrow(() => f.authority.releaseRuntime("r1"));
|
||||
assert.equal(f.authority.handles.size, 0);
|
||||
assert.deepEqual(notified, [first.handle, second.handle]);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
// Test transport harness only. This is Node, not an OS/browser sandbox test.
|
||||
// Production continues to use the sandboxed BrowserPluginRuntime unchanged.
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginRpcRouter } = require("../rpcRouter.cjs");
|
||||
|
||||
function inProcessBrowserRuntime(options) {
|
||||
let host;
|
||||
let peer;
|
||||
let port;
|
||||
return {
|
||||
async start(config) {
|
||||
const { startPluginRuntime } = await import("../runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
port = port1;
|
||||
host = new PluginRpcRouter({ pluginId: config.pluginId,
|
||||
send: (message, transfer) => port1.postMessage(message, transfer),
|
||||
requestHandlers: options.requestHandlers, notificationHandlers: options.notificationHandlers,
|
||||
onIncomingStream: options.onIncomingStream, onBeforeMessage: options.onBeforeMessage,
|
||||
onProtocolError: options.onProtocolError,
|
||||
});
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
peer = await startPluginRuntime({ port: port2, config,
|
||||
loadPlugin: () => import(pathToFileURL(path.join(options.packageRoot, options.plugin.manifest.main.browser)).href),
|
||||
});
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: config.netcattyVersion, apiVersion: config.apiVersion, supportedFeatures: config.supportedFeatures,
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
return initialized;
|
||||
},
|
||||
request: (...args) => host.request(...args),
|
||||
notify: (...args) => host.notify(...args),
|
||||
openStream: (...args) => host.streams.openOutgoing(...args),
|
||||
async stop() {
|
||||
try { if (host) await host.request("plugin.deactivate", {}); }
|
||||
finally { await peer?.dispose(); host?.close(); port?.close(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { inProcessBrowserRuntime };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -79,6 +79,7 @@ function normalizeRuntimeIdentity(identity) {
|
||||
runtimeKind: identity.runtimeKind,
|
||||
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
|
||||
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
|
||||
...(identity.sourceOwner === undefined ? {} : { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId }),
|
||||
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
|
||||
...(identity.logger === undefined ? {} : { logger: identity.logger }),
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
|
||||
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
|
||||
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
|
||||
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
@@ -90,8 +93,12 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
let aiContextService;
|
||||
let aiHostAdapter;
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -99,7 +106,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -239,7 +246,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -249,12 +256,33 @@ function createPluginHostService(options) {
|
||||
getInitialEnvironment: () => contributionService.getEnvironment(),
|
||||
runtimeResourceMonitor: quotaManager,
|
||||
runtimeCleanup: async (identity) => {
|
||||
byteStreamService?.releaseRuntime(identity.runtimeId);
|
||||
aiContextService?.releaseRuntime(identity.runtimeId);
|
||||
interactionService?.releaseRuntime(identity.runtimeId);
|
||||
leaseStore.revokeRuntime(identity.runtimeId);
|
||||
await companionSupervisor.releaseRuntime(identity.runtimeId);
|
||||
},
|
||||
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
|
||||
});
|
||||
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
|
||||
byteStreamService.register(rpcRegistry);
|
||||
aiHostAdapter = options.pluginAIHostAdapter ?? new PluginAITrustedHostAdapter({
|
||||
getWindow: options.getInteractionWindow ?? (() => null),
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
dialog: options.electron.dialog,
|
||||
});
|
||||
if (options.electron.ipcMain) aiHostAdapter.registerIpc?.(options.electron.ipcMain);
|
||||
aiContextService = new PluginAIContextService({ database, permissionEngine,
|
||||
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
|
||||
hostAdapter: aiHostAdapter,
|
||||
});
|
||||
aiContextService.registerRpcCapabilities(rpcRegistry);
|
||||
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
|
||||
contributionService.onPluginDisabled = (pluginId) => {
|
||||
aiContextService.releasePlugin(pluginId);
|
||||
return originalOnPluginDisabled(pluginId);
|
||||
};
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
@@ -382,6 +410,8 @@ function createPluginHostService(options) {
|
||||
beforeClose: async () => {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
byteStreamService.shutdown();
|
||||
aiContextService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
@@ -412,6 +442,8 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
protocol,
|
||||
networkBroker,
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
@@ -423,6 +455,9 @@ function createPluginHostService(options) {
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
// Include the isolated domain suites in npm run test:plugin-runtime.
|
||||
require("./ai/modelInvocationBroker.test.cjs");
|
||||
require("./context/selectedContextAuthority.test.cjs");
|
||||
require("./ai/sourceBinding.test.cjs");
|
||||
require("./ai/trustedHostAdapter.test.cjs");
|
||||
require("./ai/aiContextService.test.cjs");
|
||||
require("./ai/aiContextEntrypoint.test.cjs");
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { canonicalizeStreamEndpoint } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
const contractSchema = require("./generated/plugin-contract.schema.json");
|
||||
|
||||
@@ -62,6 +63,7 @@ function canonicalizeFilesystemResource(value) {
|
||||
|
||||
function canonicalizePermissionResource(permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
if (permission === "network.stream") return canonicalizeStreamEndpoint(resource).endpoint;
|
||||
if (resource === "*") return resource;
|
||||
if (permission === "network") return canonicalizeNetworkOrigin(resource);
|
||||
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
|
||||
@@ -86,6 +88,7 @@ function permissionResourceCovers(
|
||||
resourceKind = "exact",
|
||||
requestedResourceKind = "exact",
|
||||
) {
|
||||
if (permission === "network.stream") return grantResource !== "*" && grantResource === requestedResource;
|
||||
if (grantResource === "*") return true;
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||||
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
|
||||
@@ -112,7 +115,7 @@ function normalizePermissionDeclarations(manifest) {
|
||||
: [...new Set((value.resources ?? []).map((resource) => (
|
||||
canonicalizePermissionResource(permission, resource)
|
||||
)))].sort();
|
||||
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
|
||||
if ((required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) || permission === "network.stream") {
|
||||
if (resources.length === 0) {
|
||||
throw new TypeError(`Required permission ${permission} must declare resources`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
import { PLUGIN_BYTE_STREAM_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export async function connectByteStream({ client, streams, subscriptions, enabledFeatures }, request, cancellationToken) {
|
||||
if (!enabledFeatures.includes(PLUGIN_BYTE_STREAM_FEATURE)) throw new PluginError("unsupported", "Byte stream feature is unavailable");
|
||||
const descriptor = await client.request("network.stream.connect", request, { cancellationToken });
|
||||
let readable;
|
||||
let writable;
|
||||
let readablePromise;
|
||||
let closing;
|
||||
let cancellation;
|
||||
let connection;
|
||||
const close = () => {
|
||||
closing ??= client.request("network.stream.close", { handleId: descriptor.handleId })
|
||||
.finally(() => {
|
||||
cancellation?.dispose();
|
||||
streams.rejectReadable(descriptor.outputStreamId);
|
||||
if (connection) subscriptions.delete(connection);
|
||||
});
|
||||
return closing;
|
||||
};
|
||||
const cancel = () => { void close().catch(() => {}); };
|
||||
const checkCancelled = async () => {
|
||||
if (!cancellationToken?.isCancellationRequested) return;
|
||||
await close().catch(() => {});
|
||||
throw new PluginError("cancelled", "Byte stream connection cancelled");
|
||||
};
|
||||
try {
|
||||
await checkCancelled();
|
||||
readablePromise = streams.acceptReadable(descriptor.outputStreamId);
|
||||
void readablePromise.catch(() => {});
|
||||
writable = await streams.openWritable(descriptor.inputStreamId, descriptor.windowBytes);
|
||||
const attaching = client.request("network.stream.attach", { handleId: descriptor.handleId }, { cancellationToken });
|
||||
[readable] = await Promise.all([readablePromise, attaching]);
|
||||
await checkCancelled();
|
||||
connection = Object.freeze({
|
||||
endpoint: descriptor.endpoint,
|
||||
readable: Object.freeze({
|
||||
id: readable.id, read: () => readable.read(), cancel, dispose: cancel,
|
||||
}),
|
||||
writable: Object.freeze({
|
||||
id: writable.id, write: (bytes) => writable.write(bytes), end: () => writable.end(),
|
||||
fail: cancel, cancel, dispose: cancel,
|
||||
}),
|
||||
close, dispose: cancel,
|
||||
});
|
||||
cancellation = cancellationToken?.onCancellationRequested(cancel);
|
||||
subscriptions.add(connection);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
await close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
@@ -164,6 +165,7 @@ function createHostClient(transport) {
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
request.cancellation?.dispose();
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
@@ -174,17 +176,24 @@ function createHostClient(transport) {
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
if (options.cancellationToken?.isCancellationRequested) return Promise.reject(new PluginError("cancelled", "Plugin request cancelled"));
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
const cancellationId = options.cancellationToken ? `browser-${id}` : undefined;
|
||||
const pendingRequest = { resolve, reject };
|
||||
pending.set(`${typeof id}:${String(id)}`, pendingRequest);
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(cancellationId === undefined ? {} : { cancellationId }),
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
pendingRequest.cancellation = options.cancellationToken?.onCancellationRequested(() => {
|
||||
notify("$/cancelRequest", { cancellationId });
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
@@ -192,7 +201,7 @@ function createHostClient(transport) {
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
for (const request of pending.values()) { request.cancellation?.dispose(); request.reject(error); }
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
@@ -376,10 +385,19 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
|
||||
};
|
||||
const network = {
|
||||
connect: (request, cancellationToken) => connectByteStream({ client, streams: runtimeApi.streams, subscriptions,
|
||||
enabledFeatures: config.enabledFeatures }, request, cancellationToken),
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const ai = Object.fromEntries(["openChat", "startTurn", "readTurn", "stopTurn", "closeChat"].map((operation) => [
|
||||
operation, (params) => client.request(`ai.${operation}`, params, operation === "readTurn" ? { deadlineMs: 65_000 } : {}),
|
||||
]));
|
||||
const selectedContext = {
|
||||
list: (params) => client.request("context.selected.list", params),
|
||||
read: (params) => client.request("context.selected.read", params),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
@@ -553,6 +571,8 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
ai,
|
||||
selectedContext,
|
||||
ui,
|
||||
clipboard,
|
||||
filesystem,
|
||||
|
||||
@@ -239,6 +239,10 @@ class RuntimeSupervisor {
|
||||
signal.throwIfAborted();
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
||||
// Capture the host's opaque source owner before async package/placement work.
|
||||
// Test doubles without provenance APIs cannot use source-bound capabilities.
|
||||
const sourceOwner = plugin.source && typeof this.database.privateOwner === "function"
|
||||
? this.database.privateOwner(pluginId) : null;
|
||||
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
||||
if (plugin.manifest.main.data) throw new Error("Static theme packs have no executable runtime");
|
||||
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
||||
@@ -273,6 +277,8 @@ class RuntimeSupervisor {
|
||||
const currentPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
!currentPlugin?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && currentPlugin.source?.id !== plugin.source.id)
|
||||
|| currentPlugin.activeVersion !== plugin.activeVersion
|
||||
|| currentPlugin.runtime.quarantinedAt != null
|
||||
) {
|
||||
@@ -286,6 +292,7 @@ class RuntimeSupervisor {
|
||||
runtimeId: randomUUID(),
|
||||
runtimeKind: kind,
|
||||
securityPrincipal,
|
||||
...(sourceOwner === null ? {} : { sourceOwner, sourceId: plugin.source.id }),
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
logger,
|
||||
@@ -294,6 +301,8 @@ class RuntimeSupervisor {
|
||||
if (
|
||||
this.runtimes.get(pluginId) !== runtime
|
||||
|| !active?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && active.source?.id !== plugin.source.id)
|
||||
|| active.activeVersion !== plugin.activeVersion
|
||||
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
|
||||
) {
|
||||
@@ -543,6 +552,7 @@ class RuntimeSupervisor {
|
||||
async #cleanupRuntime(identity) {
|
||||
if (!identity || !this.runtimeCleanup) return;
|
||||
await this.runtimeCleanup(Object.freeze({
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
@@ -575,6 +585,7 @@ class RuntimeSupervisor {
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -651,7 +662,7 @@ class RuntimeSupervisor {
|
||||
if (typeof runtime.openStream !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
|
||||
}
|
||||
const stream = await runtime.openStream(streamId, windowBytes);
|
||||
const stream = await runtime.openStream(streamId, windowBytes, { onClose: options?.onClose });
|
||||
try {
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -158,6 +159,8 @@ class PluginStreamRouter {
|
||||
this.outgoing.delete(frame.streamId);
|
||||
for (const pending of outgoing.queue) pending.reject(new Error(`Plugin stream cancelled: ${frame.streamId}`));
|
||||
outgoing.queue.length = 0;
|
||||
outgoing.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(outgoing, "cancel");
|
||||
return;
|
||||
}
|
||||
const state = this.incoming.get(frame.streamId);
|
||||
@@ -214,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) {
|
||||
@@ -233,7 +246,8 @@ class PluginStreamRouter {
|
||||
state.onClose = handlers.onClose;
|
||||
}
|
||||
|
||||
async openOutgoing(streamId, windowBytes) {
|
||||
async openOutgoing(streamId, windowBytes, options = {}) {
|
||||
if (options.onClose !== undefined && typeof options.onClose !== "function") throw new TypeError("Stream onClose must be a function");
|
||||
if (
|
||||
this.closed
|
||||
|| this.outgoing.has(streamId)
|
||||
@@ -268,6 +282,7 @@ class PluginStreamRouter {
|
||||
queuedBytes: 0,
|
||||
closed: false,
|
||||
terminalKind: null,
|
||||
onClose: options.onClose,
|
||||
};
|
||||
this.outgoing.set(streamId, state);
|
||||
try {
|
||||
@@ -347,6 +362,13 @@ class PluginStreamRouter {
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
state.queue.length = 0;
|
||||
state.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
|
||||
#notifyOutgoingClosed(state, reason) {
|
||||
if (state.closeNotified) return;
|
||||
state.closeNotified = true;
|
||||
try { state.onClose?.(reason); } catch {}
|
||||
}
|
||||
|
||||
#finishOutgoing(state, kind) {
|
||||
@@ -368,11 +390,11 @@ class PluginStreamRouter {
|
||||
state.queuedBytes = 0;
|
||||
if (
|
||||
!terminalSent
|
||||
|| kind !== "end"
|
||||
|| state.availableBytes === state.maxCreditBytes
|
||||
) {
|
||||
this.outgoing.delete(state.streamId);
|
||||
}
|
||||
this.#notifyOutgoingClosed(state, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +435,7 @@ class PluginStreamRouter {
|
||||
for (const state of this.outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
for (const state of this.incoming.values()) {
|
||||
|
||||
@@ -5,6 +5,17 @@ const test = require("node:test");
|
||||
|
||||
const { PluginStreamRouter } = require("./streamRouter.cjs");
|
||||
|
||||
test("outgoing close hook observes idle peer cancellation exactly once", async () => {
|
||||
const closed = [];
|
||||
const router = new PluginStreamRouter({ send() {} });
|
||||
const stream = await router.openOutgoing("idle-close-hook", 1024, { onClose: (reason) => closed.push(reason) });
|
||||
await router.accept({ frame: { streamId: "idle-close-hook", sequence: 1, kind: "cancel" } });
|
||||
stream.cancel();
|
||||
router.close();
|
||||
assert.deepEqual(closed, ["cancel"]);
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
|
||||
const sent = [];
|
||||
const received = [];
|
||||
@@ -144,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); } });
|
||||
|
||||
@@ -208,9 +208,9 @@ class UtilityPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
# Selected Note Assistant
|
||||
|
||||
A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
The host must support `ai.model-invocation` and `context.selected-read`, with the
|
||||
trusted renderer adapter connected. AI invocation, selected-summary enumeration,
|
||||
and selected-content reading have separate optional permissions. The host shows
|
||||
the exact selected text for review before the plugin or model receives it.
|
||||
A menu or command ID grants no authority over a resource.
|
||||
|
||||
Build the six-file staging directory with `node build.mjs`, then pack `.package`
|
||||
with Netcatty's plugin CLI. Build scripts are not included in the installed package.
|
||||
The plugin uses only the canonical `context.ai` and `context.selectedContext` SDK
|
||||
methods through its own declared view commands. No chat history is persisted.
|
||||
|
||||
For isolated QA, use the deterministic loopback provider and synthetic Note recipe
|
||||
in `docs/plugin-platform/assistant-native-qa.md` in the Netcatty repository. Fixture
|
||||
QA performs no model inference or paid API call. Native acceptance and the remaining
|
||||
independent review are separate requirements.
|
||||
@@ -0,0 +1,12 @@
|
||||
import { URL } from 'node:url';
|
||||
import { mkdir, copyFile, rm } from 'node:fs/promises';
|
||||
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
|
||||
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
|
||||
// Build a six-file staging directory. Build scripts and source tooling are never
|
||||
// part of the installed browser package.
|
||||
const staging = new URL('./.package/', import.meta.url);
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
await mkdir(new URL('./dist/', staging), { recursive: true });
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md', 'dist/index.js']) {
|
||||
await copyFile(new URL(file, import.meta.url), new URL(file, staging));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.assistant",
|
||||
"name": "assistant-example",
|
||||
"displayName": "Selected Note Assistant",
|
||||
"description": "Minimal sandbox assistant using a host-selected model and one explicitly selected reference. No tools or commands are executed.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"main": { "browser": "dist/index.js" },
|
||||
"activationEvents": [ "onView:com.netcatty.assistant.panel" ],
|
||||
"permissions": {
|
||||
"required": [ "commands", "views" ],
|
||||
"optional": [ "ai.invoke", "context.selected.list", "context.selected.read" ]
|
||||
},
|
||||
"features": { "required": [ "ai.model-invocation", "context.selected-read" ] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.assistant.open", "title": "Assistant: Open Chat" },
|
||||
{ "id": "com.netcatty.assistant.contexts", "title": "Assistant: Selected References" },
|
||||
{ "id": "com.netcatty.assistant.send", "title": "Assistant: Send" },
|
||||
{ "id": "com.netcatty.assistant.read", "title": "Assistant: Read Stream" },
|
||||
{ "id": "com.netcatty.assistant.stop", "title": "Assistant: Stop" },
|
||||
{ "id": "com.netcatty.assistant.close", "title": "Assistant: Close Chat" }
|
||||
],
|
||||
"views": [ { "id": "com.netcatty.assistant.panel", "title": "Selected Note Assistant", "location": "modal", "entry": "panel.html" } ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@netcatty/example-assistant-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; padding:36px; margin:auto; }
|
||||
header { margin-bottom:26px; }
|
||||
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
|
||||
p { color:#a9b4c7; margin:4px 0; }
|
||||
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
|
||||
#model { font-weight:600; }
|
||||
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
|
||||
textarea { resize:vertical; min-height:84px; }
|
||||
form { margin:22px 0; }
|
||||
.actions { display:flex; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#stop { background:transparent; border-color:#56657b; }
|
||||
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
|
||||
.response { min-height:160px; }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
|
||||
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>Assistant example</title>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header><span class="badge">PLUGIN EXAMPLE</span><h1>Summarize a selected Note</h1><p>Use the model and reference shared by Netcatty.</p></header>
|
||||
<section class="card"><label>Model selected in Netcatty</label><div id="model">Connecting...</div></section>
|
||||
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p></section>
|
||||
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
|
||||
<section class="card response"><label>Response</label><pre id="response" aria-live="polite">Your response appears here.</pre><p id="usage" class="hint"></p></section>
|
||||
<footer id="qa"></footer>
|
||||
</main>
|
||||
<script src="panel.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
/* global document, window */
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
const references = new Map();
|
||||
function busy(value) {
|
||||
element('send').disabled = value || !chat;
|
||||
element('stop').disabled = !value;
|
||||
element('context').disabled = value;
|
||||
element('prompt').disabled = value;
|
||||
}
|
||||
async function initialize() {
|
||||
try {
|
||||
chat = await invoke('open');
|
||||
element('model').textContent = chat.model;
|
||||
const summaries = await invoke('contexts');
|
||||
for (const reference of summaries) {
|
||||
if (reference.scope !== 'note') continue;
|
||||
references.set(reference.handle, { handle: reference.handle, version: reference.version });
|
||||
const option = document.createElement('option');
|
||||
option.value = reference.handle; option.textContent = reference.title;
|
||||
element('context').append(option);
|
||||
}
|
||||
element('status').textContent = 'Ready';
|
||||
busy(false);
|
||||
} catch {
|
||||
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.';
|
||||
}
|
||||
}
|
||||
element('compose').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!chat || element('send').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Streaming...';
|
||||
element('response').textContent = ''; element('usage').textContent = '';
|
||||
try {
|
||||
const reference = references.get(element('context').value);
|
||||
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
|
||||
contexts: reference ? [reference] : [] });
|
||||
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
|
||||
turn = started.turnHandle;
|
||||
for (;;) {
|
||||
const chunk = await invoke('read', { turnHandle: turn });
|
||||
if (run !== generation) return;
|
||||
if (chunk.kind === 'end') break;
|
||||
if (chunk.kind === 'text') element('response').textContent += chunk.text;
|
||||
if (chunk.kind === 'usage') element('usage').textContent = `${chunk.inputTokens ?? '?'} input tokens · ${chunk.outputTokens ?? '?'} output tokens`;
|
||||
}
|
||||
element('status').textContent = 'Complete';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Request ended. Check access or try again.';
|
||||
} finally {
|
||||
if (run === generation) { turn = undefined; busy(false); }
|
||||
}
|
||||
});
|
||||
element('stop').addEventListener('click', async () => {
|
||||
++generation;
|
||||
const stoppedTurn = turn; turn = undefined;
|
||||
element('status').textContent = 'Stopping...';
|
||||
try {
|
||||
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
|
||||
else if (chat) {
|
||||
await invoke('close', { chatHandle: chat.chatHandle });
|
||||
chat = await invoke('open');
|
||||
}
|
||||
}
|
||||
finally { element('status').textContent = 'Stopped'; busy(false); }
|
||||
});
|
||||
// QA supplies this text through the regular view message surface.
|
||||
window.netcattyView.onDidReceiveMessage((message) => {
|
||||
if (message?.qaFixture === true) element('qa').textContent = 'Isolated QA · deterministic local provider · no model inference or paid API';
|
||||
});
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
|
||||
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
|
||||
});
|
||||
void initialize();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Ordinary browser runtime. The host SDK supplies the only privileged surface.
|
||||
export default {
|
||||
activate(context) {
|
||||
const register = (name, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.assistant.${name}`, handler,
|
||||
));
|
||||
register('open', () => context.ai.openChat({}));
|
||||
register('contexts', async () => (await context.selectedContext.list({})).items);
|
||||
register('send', (params) => context.ai.startTurn(params));
|
||||
register('read', (params) => context.ai.readTurn(params));
|
||||
register('stop', (params) => context.ai.stopTurn(params));
|
||||
register('close', (params) => context.ai.closeChat(params));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
# Read one disposable database row
|
||||
|
||||
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 a package for one private IPv4 fixture endpoint:
|
||||
|
||||
```sh
|
||||
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 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Follow [the native handoff](../../../docs/plugin-platform/byte-stream-native-qa.md)
|
||||
for fixture prerequisites, screenshots, video, cancellation, and cleanup.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
|
||||
import { queryExample } from "./protocol.mjs";
|
||||
import { endpoint } from "./fixture-config.mjs";
|
||||
|
||||
export default {
|
||||
activate(context) {
|
||||
let pending;
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.database-minimal.${suffix}`, handler,
|
||||
));
|
||||
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
|
||||
register("cancel", cancel);
|
||||
register("endpoint", () => ({ endpoint }));
|
||||
context.subscriptions.add({ dispose: cancel });
|
||||
register("query", async (args) => {
|
||||
if (pending) throw new Error("An example connection is already active");
|
||||
if (!args || Array.isArray(args) || Object.keys(args).some((key) => !["username", "password"].includes(key))
|
||||
|| typeof args.username !== "string" || typeof args.password !== "string") throw new Error("Enter disposable fixture credentials");
|
||||
const cancellation = new CancellationTokenSource();
|
||||
pending = cancellation;
|
||||
let connection;
|
||||
try {
|
||||
connection = await context.network.connect({ endpoint }, cancellation.token);
|
||||
return await queryExample({ read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
|
||||
close: () => connection.close() }, args.username, args.password);
|
||||
} finally {
|
||||
args.password = "";
|
||||
await connection?.close().catch(() => {});
|
||||
cancellation.dispose();
|
||||
pending = undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const endpoint = "tcp://database-fixture.invalid:3306";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"name": "database-minimal",
|
||||
"displayName": "Disposable database example",
|
||||
"description": "Connect to one prepared synthetic fixture and read one row.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["network.byte-streams"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onCommand:com.netcatty.database-minimal.query", "onView:com.netcatty.database-minimal.panel"],
|
||||
"permissions": {
|
||||
"required": ["commands", "views"],
|
||||
"optional": [{ "permission": "network.stream", "resources": ["tcp://database-fixture.invalid:3306"], "reason": "Connect only to the prepared disposable fixture." }]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.database-minimal.query", "title": "Read one synthetic database row" },
|
||||
{ "id": "com.netcatty.database-minimal.cancel", "title": "Cancel example connection" },
|
||||
{ "id": "com.netcatty.database-minimal.endpoint", "title": "Show example endpoint" }
|
||||
],
|
||||
"views": [{ "id": "com.netcatty.database-minimal.panel", "title": "Disposable database example", "location": "modal", "entry": "panel.html" }]
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Disposable database example</title><link rel="stylesheet" href="panel.css"></head>
|
||||
<body><main><p class="eyebrow">NETCATTY PLUGIN EXAMPLE</p><h1>Read one synthetic row</h1>
|
||||
<p>This example connects only to its prepared disposable test server. Enter the fixture account. Credentials stay in this session.</p>
|
||||
<p id="endpoint">Loading prepared endpoint…</p><form id="form" autocomplete="off">
|
||||
<label for="username">Fixture username</label><input id="username" name="fixture-user" autocomplete="off" maxlength="32" required>
|
||||
<label for="password">Session password</label><input id="password" name="fixture-password" type="password" autocomplete="new-password" maxlength="256" required>
|
||||
<div class="actions"><button id="query" type="submit">Connect and read</button><button id="cancel" type="button" disabled>Cancel</button></div></form>
|
||||
<pre id="result" role="status" aria-live="polite">Ready for a disposable fixture.</pre>
|
||||
</main><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,35 @@
|
||||
/* global window, document */
|
||||
"use strict";
|
||||
const username = document.getElementById("username");
|
||||
const password = document.getElementById("password");
|
||||
const query = document.getElementById("query");
|
||||
const cancel = document.getElementById("cancel");
|
||||
const result = document.getElementById("result");
|
||||
const command = (suffix, args) => window.netcattyView.executeCommand(`com.netcatty.database-minimal.${suffix}`, args);
|
||||
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; });
|
||||
document.getElementById("form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
query.disabled = true;
|
||||
cancel.disabled = false;
|
||||
result.textContent = "Connecting to the disposable fixture…";
|
||||
const credentials = { username: username.value, password: password.value };
|
||||
password.value = "";
|
||||
try {
|
||||
const response = await command("query", credentials);
|
||||
result.textContent = JSON.stringify(response, null, 2);
|
||||
} catch { result.textContent = "Connection or read failed. Check the fixture, exact endpoint grant, and session credentials."; }
|
||||
finally { credentials.password = ""; query.disabled = false; cancel.disabled = true; }
|
||||
});
|
||||
cancel.addEventListener("click", async () => {
|
||||
cancel.disabled = true;
|
||||
try { await command("cancel"); result.textContent = "Example connection cancelled."; }
|
||||
catch { result.textContent = "Could not cancel the example connection."; }
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
/* global __dirname */
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const test = require("node:test");
|
||||
|
||||
test("the example clears session passwords before dispatch and renders result text safely", async () => {
|
||||
const elements = new Map();
|
||||
for (const id of ["username", "password", "query", "cancel", "result", "form", "endpoint"]) {
|
||||
const element = { value: "", textContent: "", disabled: false, listeners: {},
|
||||
addEventListener(event, listener) { this.listeners[event] = listener; },
|
||||
};
|
||||
Object.defineProperty(element, "innerHTML", { set() { throw new Error("HTML output is forbidden"); } });
|
||||
elements.set(id, element);
|
||||
}
|
||||
elements.get("username").value = "plugin_demo";
|
||||
elements.get("password").value = "synthetic-session-only";
|
||||
let settle;
|
||||
let submitted;
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "panel.js"), "utf8"), {
|
||||
document: { getElementById: (id) => elements.get(id), documentElement: { style: { setProperty() {}, removeProperty() {} } } },
|
||||
window: { netcattyView: {
|
||||
onDidChangeEnvironment() {},
|
||||
executeCommand: async (name, params) => {
|
||||
if (name.endsWith(".endpoint")) return { endpoint: "tcp://172.30.247.2:3306" };
|
||||
assert.ok(name.endsWith(".query"));
|
||||
assert.equal(elements.get("password").value, "");
|
||||
submitted = params;
|
||||
return new Promise((resolve) => { settle = resolve; });
|
||||
},
|
||||
} },
|
||||
});
|
||||
const pending = elements.get("form").listeners.submit({ preventDefault() {} });
|
||||
assert.equal(submitted.password, "synthetic-session-only");
|
||||
assert.equal(elements.get("query").disabled, true);
|
||||
settle({ columns: ["value"], rows: [["<img src=x onerror=alert(1)>"]] });
|
||||
await pending;
|
||||
assert.equal(submitted.password, "");
|
||||
assert.equal(elements.get("password").value, "");
|
||||
assert.equal(elements.get("query").disabled, false);
|
||||
assert.match(elements.get("result").textContent, /<img/u);
|
||||
const html = fs.readFileSync(path.join(__dirname, "panel.html"), "utf8");
|
||||
assert.match(html, /id="password"[^>]*type="password"/u);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("../../../electron/plugins/byteStreamEndpoint.cjs");
|
||||
const { isIP } = require("node:net");
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function prepareDatabaseExample(targetEndpoint, outputPath) {
|
||||
const { endpoint, transport, host } = canonicalizeStreamEndpoint(targetEndpoint);
|
||||
// This preparation tool only targets a disposable private IPv4 fixture.
|
||||
// The generic host capability has no database restriction or protocol logic.
|
||||
if (transport !== "tcp" || isIP(host) !== 4 || isLocalControlAddress(host)
|
||||
|| !/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(host)) throw new Error("Use the isolated fixture's private IPv4 TCP endpoint");
|
||||
const temporary = await fs.mkdtemp(path.join(path.dirname(outputPath), ".database-example-"));
|
||||
try {
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
|
||||
manifest.permissions.optional[0].resources = [endpoint];
|
||||
await fs.writeFile(path.join(temporary, "netcatty.plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
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, 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"));
|
||||
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://<private-fixture-ip>:3306 <output.ncpkg>");
|
||||
const result = await prepareDatabaseExample(endpoint, path.resolve(outputPath));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
/* global __dirname */
|
||||
|
||||
// Creates only an isolated disposable fixture. Never accepts a user host,
|
||||
// existing database, database URL, or real credentials from the environment.
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { randomUUID, randomBytes } = require("node:crypto");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { setTimeout } = require("node:timers");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamBroker } = require("../../../electron/plugins/byteStreamBroker.cjs");
|
||||
const { createPluginHostService } = require("../../../electron/plugins/hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("../../../electron/plugins/fixtures/inProcessBrowserRuntime.cjs");
|
||||
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
|
||||
|
||||
function docker(args, input) {
|
||||
return execFileSync("docker", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 }).trim();
|
||||
}
|
||||
|
||||
test("minimal browser protocol queries a disposable MariaDB through the real byte broker", {
|
||||
skip: process.env.NETCATTY_BYTE_STREAM_DATABASE_TEST !== "1",
|
||||
timeout: 120_000,
|
||||
}, async (t) => {
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const network = `netcatty-stream-test-${suffix}`;
|
||||
const container = `netcatty-stream-test-${suffix}`;
|
||||
const sessionPassword = randomBytes(24).toString("hex");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-db-entry-"));
|
||||
let service;
|
||||
docker(["network", "create", "--internal", network]);
|
||||
let created = false;
|
||||
t.after(async () => {
|
||||
await service?.manager.shutdown();
|
||||
if (created) docker(["rm", "--force", container]);
|
||||
docker(["network", "rm", network]);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
|
||||
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
|
||||
created = true;
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { docker(["exec", container, "mariadb-admin", "--host=127.0.0.1", "ping", "--silent"]); ready = true; break; }
|
||||
catch { await new Promise((resolve) => setTimeout(resolve, 500)); }
|
||||
}
|
||||
assert.equal(ready, true, "isolated MariaDB fixture starts");
|
||||
docker(["exec", "--interactive", container, "mariadb", "--protocol=socket", "--user=root"], `
|
||||
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 '${sessionPassword}';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
`);
|
||||
assert.equal(docker(["port", container]), "", "the isolated fixture publishes no host ports");
|
||||
const address = docker(["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container]);
|
||||
assert.match(address, /^172\.[0-9]+\.[0-9]+\.[0-9]+$/u);
|
||||
const endpoint = `tcp://${address}:3306`;
|
||||
const broker = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_context, request) => assert.deepEqual(request.resources, [endpoint]),
|
||||
onDidRevoke: () => ({ dispose() {} }),
|
||||
},
|
||||
});
|
||||
t.after(() => broker.shutdown());
|
||||
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
|
||||
const { handleId } = await broker.connect(endpoint, context);
|
||||
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
|
||||
__dirname, "./protocol.mjs",
|
||||
)).href);
|
||||
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
|
||||
const result = await queryExample({
|
||||
read: () => broker.read(handleId, context),
|
||||
write: (bytes) => broker.write(handleId, bytes, context),
|
||||
close: () => { if (broker.connections.has(handleId)) broker.close(handleId, context); },
|
||||
}, "plugin_demo", sessionPassword);
|
||||
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
|
||||
assert.equal(broker.connections.size, 0);
|
||||
const { prepareDatabaseExample } = await import("./prepare.mjs");
|
||||
const archive = path.join(root, "database-minimal.ncpkg");
|
||||
const packed = await prepareDatabaseExample(endpoint, archive);
|
||||
assert.equal(packed.fileCount, 5);
|
||||
const decisions = [];
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const installedResult = await service.contributionService.executeCommand("com.netcatty.database-minimal.query", {
|
||||
username: "plugin_demo", password: sessionPassword,
|
||||
});
|
||||
assert.deepEqual(installedResult, result);
|
||||
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
const audits = JSON.stringify(service.database.listSecurityAudit("com.netcatty.database-minimal", 100));
|
||||
assert.equal(audits.includes(sessionPassword), false);
|
||||
assert.equal(audits.includes(READ_QUERY), false);
|
||||
assert.equal(audits.includes("synthetic stream result"), false);
|
||||
assert.equal(audits.includes("network.stream.open"), true);
|
||||
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
|
||||
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
|
||||
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
|
||||
'read -r MYSQL_PWD; export MYSQL_PWD; exec mariadb --host=127.0.0.1 --user=plugin_demo --execute="INSERT INTO plugin_demo.example_result VALUES (1)"'],
|
||||
{ input: `${sessionPassword}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
(error) => error.status === 1 && /INSERT command denied/u.test(String(error.stderr)));
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// Intentionally limited browser example, not a general database driver. Supports
|
||||
// protocol 4.1, mysql_native_password, and exactly one fixed text result column.
|
||||
// The fixture account's server-side SELECT grant enforces access control.
|
||||
export const READ_QUERY = "SELECT value FROM plugin_demo.example_result LIMIT 1";
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const decoder = new globalThis.TextDecoder("utf-8", { fatal: true });
|
||||
const MAX_PACKET = 64 * 1024 - 1;
|
||||
const MAX_TOTAL = 512 * 1024;
|
||||
const CLIENT_FLAGS = 1 | 4 | 8 | 0x200 | 0x2000 | 0x8000 | 0x80000;
|
||||
|
||||
function reject() { throw new Error("Unsupported or invalid example database response"); }
|
||||
function concat(...chunks) {
|
||||
const value = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { value.set(chunk, offset); offset += chunk.length; }
|
||||
return value;
|
||||
}
|
||||
function zeroText(text) { return concat(encoder.encode(text), Uint8Array.of(0)); }
|
||||
function uint32(value) {
|
||||
const result = new Uint8Array(4);
|
||||
new DataView(result.buffer).setUint32(0, value, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
class Cursor {
|
||||
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
|
||||
take(length) {
|
||||
if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) reject();
|
||||
const result = this.bytes.subarray(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return result;
|
||||
}
|
||||
byte() { return this.take(1)[0]; }
|
||||
short() { const bytes = this.take(2); return bytes[0] | (bytes[1] << 8); }
|
||||
text() {
|
||||
const end = this.bytes.indexOf(0, this.offset);
|
||||
if (end === -1) reject();
|
||||
const result = decoder.decode(this.take(end - this.offset));
|
||||
this.byte();
|
||||
return result;
|
||||
}
|
||||
lengthText() {
|
||||
const marker = this.byte();
|
||||
if (marker === 0xfb) return null;
|
||||
const length = marker < 0xfb ? marker : marker === 0xfc ? this.short() : reject();
|
||||
if (length > 4096) reject();
|
||||
return decoder.decode(this.take(length));
|
||||
}
|
||||
}
|
||||
|
||||
class Packets {
|
||||
constructor(stream) { this.stream = stream; this.pending = new Uint8Array(); this.offset = 0; this.total = 0; this.packets = 0; }
|
||||
async exact(length) {
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
if (this.offset === this.pending.length) {
|
||||
this.pending = await this.stream.read();
|
||||
this.offset = 0;
|
||||
if (!(this.pending instanceof Uint8Array) || !this.pending.length || this.pending.length > 64 * 1024) reject();
|
||||
this.total += this.pending.length;
|
||||
if (this.total > MAX_TOTAL) reject();
|
||||
}
|
||||
const count = Math.min(length - offset, this.pending.length - this.offset);
|
||||
result.set(this.pending.subarray(this.offset, this.offset + count), offset);
|
||||
this.offset += count;
|
||||
offset += count;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async read(sequence) {
|
||||
if (++this.packets > 32) reject();
|
||||
const header = await this.exact(4);
|
||||
const length = header[0] | (header[1] << 8) | (header[2] << 16);
|
||||
if (!length || length > MAX_PACKET || header[3] !== sequence) reject();
|
||||
const payload = await this.exact(length);
|
||||
// Never display peer-provided errors, which may echo user input or secrets.
|
||||
if (payload[0] === 0xff) throw new Error("Example database server rejected the request");
|
||||
return payload;
|
||||
}
|
||||
async write(bytes, sequence) {
|
||||
if (bytes.length > MAX_PACKET) reject();
|
||||
await this.stream.write(concat(Uint8Array.of(bytes.length & 255, (bytes.length >>> 8) & 255, 0, sequence), bytes));
|
||||
}
|
||||
}
|
||||
|
||||
async function authentication(password, nonce) {
|
||||
if (!password.length) return new Uint8Array();
|
||||
const digest = async (bytes) => new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", bytes));
|
||||
const passwordBytes = encoder.encode(password);
|
||||
let first;
|
||||
let second;
|
||||
let third;
|
||||
try {
|
||||
first = await digest(passwordBytes);
|
||||
second = await digest(first);
|
||||
third = await digest(concat(nonce, second));
|
||||
return first.map((byte, index) => byte ^ third[index]);
|
||||
} finally {
|
||||
passwordBytes.fill(0);
|
||||
first?.fill(0);
|
||||
second?.fill(0);
|
||||
third?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function greeting(bytes) {
|
||||
const cursor = new Cursor(bytes);
|
||||
if (cursor.byte() !== 10) reject();
|
||||
cursor.text();
|
||||
cursor.take(4);
|
||||
const nonceFirst = cursor.take(8);
|
||||
if (cursor.byte() !== 0) reject();
|
||||
const lower = cursor.short();
|
||||
cursor.byte();
|
||||
cursor.short();
|
||||
const upper = cursor.short();
|
||||
const capabilities = lower | (upper << 16);
|
||||
const authLength = cursor.byte();
|
||||
// MariaDB clears bit zero to identify its extended dialect. This example
|
||||
// sends CLIENT_MYSQL (bit zero) and uses only the common 4.1 protocol.
|
||||
const required = CLIENT_FLAGS & ~1;
|
||||
if ((capabilities & required) !== required || authLength !== 21) reject();
|
||||
cursor.take(10);
|
||||
const nonceSecond = cursor.take(13);
|
||||
if (nonceSecond[12] !== 0 || cursor.text() !== "mysql_native_password") reject();
|
||||
return concat(nonceFirst, nonceSecond.subarray(0, 12));
|
||||
}
|
||||
|
||||
/** stream is a plugin-owned browser adapter over the generic host byte stream. */
|
||||
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");
|
||||
}
|
||||
const packets = new Packets(stream);
|
||||
try {
|
||||
const nonce = greeting(await packets.read(0));
|
||||
const token = await authentication(sessionPassword, nonce);
|
||||
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(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(query)), 0);
|
||||
const count = await packets.read(1);
|
||||
if (count.length !== 1 || count[0] !== 1) reject();
|
||||
const column = new Cursor(await packets.read(2));
|
||||
for (let index = 0; index < 4; index++) column.lengthText();
|
||||
if (column.lengthText() !== "value") reject();
|
||||
const eof = await packets.read(3);
|
||||
if (eof[0] !== 0xfe || eof.length >= 9) reject();
|
||||
const row = new Cursor(await packets.read(4));
|
||||
const value = row.lengthText();
|
||||
if (row.offset !== row.bytes.length) reject();
|
||||
const end = await packets.read(5);
|
||||
if (end[0] !== 0xfe || end.length >= 9) reject();
|
||||
return { columns: ["value"], rows: [[value]] };
|
||||
} finally {
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { once } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createPluginModelAdapter } from './modelInvocationAdapter';
|
||||
import { createBridgeFetchForSDK } from '../sdk/providers';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { ModelInvocationBroker } = require('../../../electron/plugins/ai/modelInvocationBroker.cjs');
|
||||
const { SelectedContextAuthority } = require('../../../electron/plugins/context/selectedContextAuthority.cjs');
|
||||
|
||||
|
||||
// Local deterministic OpenAI-compatible fixture. No model inference, credentials,
|
||||
// user's Notes, configured endpoints or paid API requests are used by this test.
|
||||
test('broker -> existing model factory -> existing host HTTP transport -> loopback fixture -> stream and usage', async (t) => {
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-fixture-'));
|
||||
const priorTmp = process.env.TMPDIR;
|
||||
process.env.TMPDIR = fixtureRoot;
|
||||
const aiBridge = require('../../../electron/bridges/aiBridge.cjs');
|
||||
t.after(() => {
|
||||
if (priorTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = priorTmp;
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
assert.equal(request.url, '/v1/chat/completions');
|
||||
assert.equal(request.headers.authorization, 'Bearer __IPC_SECURED__');
|
||||
let body = '';
|
||||
for await (const chunk of request) { body += chunk; assert.ok(Buffer.byteLength(body) < 32_768); }
|
||||
const payload = JSON.parse(body);
|
||||
requests.push(payload);
|
||||
assert.equal(payload.model, 'deterministic-fixture');
|
||||
assert.equal(payload.stream, true);
|
||||
assert.equal(payload.tools, undefined);
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
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 }],
|
||||
})}\n\n`);
|
||||
send({ role: 'assistant' }); send({ content: 'Synthetic plan: ' });
|
||||
if (payload.messages.at(-1).content === 'Stop fixture') {
|
||||
const timer = setTimeout(() => { send({ content: 'late text' }); response.end('data: [DONE]\n\n'); }, 2000);
|
||||
response.once('close', () => clearTimeout(timer));
|
||||
return;
|
||||
}
|
||||
send({ content: 'add retry button.' }); send({}, 'stop');
|
||||
response.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', created: 1,
|
||||
model: 'deterministic-fixture', choices: [], usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 } })}\n\n`);
|
||||
response.end('data: [DONE]\n\n');
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
t.after(() => { server.closeAllConnections(); server.close(); });
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const baseURL = `http://127.0.0.1:${address.port}/v1`;
|
||||
const data = new Map<string, (value: string) => void>();
|
||||
const end = new Map<string, () => void>();
|
||||
const errors = new Map<string, (value: string) => void>();
|
||||
const originalWindow = globalThis.window;
|
||||
const sentBodies: string[] = [];
|
||||
const bridge = {
|
||||
aiFetch: async () => { throw Error('Nonstreaming transport is not expected'); },
|
||||
onAiStreamData: (id: string, callback: (value: string) => void) => { data.set(id, callback); return () => data.delete(id); },
|
||||
onAiStreamEnd: (id: string, callback: () => void) => { end.set(id, callback); return () => end.delete(id); },
|
||||
onAiStreamError: (id: string, callback: (value: string) => void) => { errors.set(id, callback); return () => errors.delete(id); },
|
||||
aiChatCancel: async () => { aiBridge.cleanup(); return true; },
|
||||
aiChatStream: async (id: string, url: string, headers: Record<string, string>, body: string) => {
|
||||
assert.equal(new URL(url).origin, new URL(baseURL).origin);
|
||||
sentBodies.push(body);
|
||||
const result = await aiBridge._streamRequestForTests(url, { headers, body }, { sender: {
|
||||
isDestroyed: () => false,
|
||||
send: (channel: string, payload: { data?: string; error?: string }) => {
|
||||
if (channel.endsWith(':data')) data.get(id)?.(payload.data!);
|
||||
if (channel.endsWith(':end')) end.get(id)?.();
|
||||
if (channel.endsWith(':error')) errors.get(id)?.(payload.error!);
|
||||
},
|
||||
} }, id, false);
|
||||
return { ok: result.statusCode === 200, ...result };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: { netcatty: bridge } });
|
||||
t.after(() => { Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }); aiBridge.cleanup(); });
|
||||
const config: ProviderConfig = { id: 'isolated-fixture', providerId: 'custom', enabled: true, name: 'Fixture',
|
||||
baseURL, defaultModel: 'deterministic-fixture', apiKey: 'synthetic-not-a-paid-api-key' };
|
||||
const model = createPluginModelAdapter({ config, label: 'Local deterministic fixture', isCurrent: () => true });
|
||||
const owner = {};
|
||||
const resolveDataOwner = () => owner;
|
||||
const context = { pluginId: 'example', pluginVersion: '1', runtimeId: 'qa-isolated', assertActive: async () => {} };
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner });
|
||||
const note = await contexts.share({ scope: 'note', version: '1', title: 'Synthetic Note', isCurrent: () => true,
|
||||
readText: () => 'Synthetic retry plan: add retry button.' }, context);
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, contexts, selectModel: () => model });
|
||||
t.after(() => broker.shutdown());
|
||||
const chat = await broker.open(context);
|
||||
const turn = await broker.start({ chatHandle: chat.chatHandle, prompt: 'Summarize this Note.',
|
||||
contexts: [{ handle: note.handle, version: note.version }] }, context);
|
||||
let text = ''; let usage;
|
||||
for (;;) {
|
||||
const event = await broker.read(turn.turnHandle, context);
|
||||
if (event.kind === 'end') break;
|
||||
if (event.kind === 'text') text += event.text;
|
||||
if (event.kind === 'usage') usage = event;
|
||||
}
|
||||
assert.equal(text, 'Synthetic plan: add retry button.');
|
||||
assert.deepEqual(usage, { kind: 'usage', sequence: 2, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify({ chat, turn, text, usage, sentBodies }), /synthetic-not-a-paid-api-key/);
|
||||
// Exercise the actual page handlers through its existing command-only view
|
||||
// surface. This is DOM QA, not installed-plugin or native Electron evidence.
|
||||
const dom = new JSDOM(fs.readFileSync(new URL('../../../examples/plugins/assistant/panel.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' });
|
||||
t.after(() => dom.window.close());
|
||||
const commandHandlers: Record<string, (params: Record<string, unknown>) => unknown> = {
|
||||
open: () => broker.open(context), contexts: () => contexts.summaries(context),
|
||||
send: (params) => broker.start(params, context),
|
||||
read: (params) => broker.read(params.turnHandle, context),
|
||||
stop: (params) => broker.stop(params.turnHandle, context),
|
||||
close: (params) => broker.close(params.chatHandle, context),
|
||||
};
|
||||
Object.defineProperty(dom.window, 'netcattyView', { value: {
|
||||
executeCommand: async (id: string, params: Record<string, unknown>) => {
|
||||
const operation = id.replace('com.netcatty.assistant.', '');
|
||||
assert.ok(Object.hasOwn(commandHandlers, operation));
|
||||
return structuredClone(await commandHandlers[operation](structuredClone(params)));
|
||||
},
|
||||
onDidReceiveMessage: () => () => {},
|
||||
} });
|
||||
dom.window.eval(fs.readFileSync(new URL('../../../examples/plugins/assistant/panel.js', import.meta.url), 'utf8'));
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error('Panel state timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
};
|
||||
const element = (id: string) => dom.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent === 'Ready');
|
||||
const selection = element('context') as HTMLSelectElement;
|
||||
assert.equal(selection.options.length, 2);
|
||||
assert.equal(selection.value, '');
|
||||
selection.value = note.handle; // Explicit synthetic Note selection in the UI.
|
||||
element('compose').dispatchEvent(new dom.window.Event('submit', { cancelable: true }));
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(element('response').textContent, 'Synthetic plan: add retry button.');
|
||||
assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
assert.equal(requests.length, 2);
|
||||
(element('prompt') as HTMLTextAreaElement).value = 'Stop fixture';
|
||||
element('compose').dispatchEvent(new dom.window.Event('submit', { cancelable: true }));
|
||||
await waitFor(() => element('response').textContent === 'Synthetic plan: ');
|
||||
element('stop').dispatchEvent(new dom.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped');
|
||||
assert.equal(broker.turns.size, 0);
|
||||
assert.equal((element('send') as HTMLButtonElement).disabled, false);
|
||||
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);
|
||||
|
||||
});
|
||||
|
||||
test('plugin provider adapter refuses direct-fetch fallback when the host transport disappears', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: {} });
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }));
|
||||
await assert.rejects(createBridgeFetchForSDK('fixture', { requireBridge: true })('http://127.0.0.1:1/never'), /Host AI transport is unavailable/);
|
||||
});
|
||||
|
||||
test('plugin transport byte cap cancels before unbounded SDK buffering', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
let emit: (value: string) => void; let cancelled = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: { netcatty: {
|
||||
onAiStreamData: (_id: string, callback: (value: string) => void) => { emit = callback; return () => {}; },
|
||||
onAiStreamEnd: () => () => {}, onAiStreamError: () => () => {},
|
||||
aiChatCancel: async () => { cancelled++; return true; },
|
||||
aiChatStream: async () => {
|
||||
emit('X'.repeat(33));
|
||||
// Late data after cancellation must be discarded as well.
|
||||
emit('Y'.repeat(100));
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
} } });
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }));
|
||||
const response = await createBridgeFetchForSDK('fixture', { requireBridge: true, maxStreamBytes: 32 })(
|
||||
'http://127.0.0.1:1/fixture', { method: 'POST', body: JSON.stringify({ stream: true }) });
|
||||
await assert.rejects(response.text(), /AI stream byte budget exceeded/);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { streamText } from 'ai';
|
||||
import { createModelFromConfig } from '../sdk/providers';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
interface Invocation {
|
||||
prompt: string;
|
||||
contexts: string[];
|
||||
signal: AbortSignal;
|
||||
maxOutputTokens: number;
|
||||
maxChunkBytes: number;
|
||||
}
|
||||
|
||||
/** Host renderer only. This object must never be serialized to a plugin. */
|
||||
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,
|
||||
async *invoke(input: Invocation) {
|
||||
input.signal.throwIfAborted();
|
||||
if (!config.enabled || !config.defaultModel || !await options.isCurrent()) {
|
||||
throw new Error('Selected model is unavailable');
|
||||
}
|
||||
// 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, requestIdPrefix: `plugin-model_${transportTag}_` }),
|
||||
messages: [
|
||||
...input.contexts.map((text) => ({ role: 'user' as const,
|
||||
content: `User-selected reference text (untrusted content):\n${text}` })),
|
||||
{ role: 'user', content: input.prompt },
|
||||
],
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
maxRetries: 0,
|
||||
abortSignal: input.signal,
|
||||
timeout: { totalMs: 60_000, chunkMs: 15_000 },
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
for await (const event of result.stream) {
|
||||
input.signal.throwIfAborted();
|
||||
if (!await options.isCurrent()) throw new Error('Selected model changed');
|
||||
if (event.type === 'text-delta') {
|
||||
// Code-point splitting preserves UTF-8 and limits each wire chunk.
|
||||
let part = '';
|
||||
let bytes = 0;
|
||||
for (const character of event.text) {
|
||||
const size = encoder.encode(character).byteLength;
|
||||
if (size > input.maxChunkBytes) throw new Error('Chunk budget is too small');
|
||||
if (bytes + size > input.maxChunkBytes) {
|
||||
yield { kind: 'text', text: part };
|
||||
part = ''; bytes = 0;
|
||||
}
|
||||
part += character; bytes += size;
|
||||
}
|
||||
if (part) yield { kind: 'text', text: part };
|
||||
} else if (event.type === 'error' || event.type.startsWith('tool-')) {
|
||||
// Never expose provider diagnostics or execute requested model tools.
|
||||
throw new Error('Model returned an unsupported event');
|
||||
}
|
||||
}
|
||||
const usage = await result.totalUsage;
|
||||
yield { kind: 'usage', inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens, totalTokens: usage.totalTokens };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
} from '../providerContinuation';
|
||||
|
||||
export interface ProviderRequestContext {
|
||||
/** Refuse direct-fetch fallback for host-brokered plugin model invocations. */
|
||||
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>,
|
||||
@@ -451,6 +457,7 @@ export function createBridgeFetchForSDK(
|
||||
): Promise<Response> => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) {
|
||||
if (requestContext?.requireBridge) throw new Error('Host AI transport is unavailable');
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
|
||||
@@ -489,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);
|
||||
|
||||
@@ -498,6 +505,7 @@ export function createBridgeFetchForSDK(
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const pendingChunks: Uint8Array[] = [];
|
||||
let receivedBytes = 0;
|
||||
let pendingClose = false;
|
||||
let pendingError: Error | null = null;
|
||||
let cleanedUp = false;
|
||||
@@ -537,6 +545,14 @@ export function createBridgeFetchForSDK(
|
||||
};
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
if (cleanedUp) return;
|
||||
receivedBytes += encoder.encode(data).byteLength;
|
||||
if (requestContext?.maxStreamBytes !== undefined && receivedBytes > requestContext.maxStreamBytes) {
|
||||
errorStream(new Error('AI stream byte budget exceeded'));
|
||||
cleanup();
|
||||
bridge.aiChatCancel(requestId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
|
||||
Generated
+8
@@ -132,6 +132,10 @@
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"examples/plugins/assistant": {
|
||||
"name": "@netcatty/example-assistant-plugin",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"examples/plugins/hello-netcatty": {
|
||||
"name": "@netcatty/example-hello-plugin",
|
||||
"version": "0.1.0",
|
||||
@@ -4674,6 +4678,10 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@netcatty/example-assistant-plugin": {
|
||||
"resolved": "examples/plugins/assistant",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@netcatty/example-hello-plugin": {
|
||||
"resolved": "examples/plugins/hello-netcatty",
|
||||
"link": true
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validateManifestValue } from "./manifest.js";
|
||||
import { checkPluginCompatibility } from "./compatibility.js";
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL("../../../examples/plugins/database-minimal/netcatty.plugin.json", import.meta.url), "utf8"));
|
||||
|
||||
test("byte stream packages require negotiated features and exact resources in optional grants", () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
for (const resources of [["*"], ["tcp://*:3306"], ["tcp://host:3306/"], ["udp://host:3306"], ["https://host"]]) {
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: [{ permission: "network.stream", resources }] } }).valid, false);
|
||||
}
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: ["network.stream"] } }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: [] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: ["network.byte-streams"] }).compatible, true);
|
||||
});
|
||||
@@ -369,6 +369,13 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
const hasByteStreams = [...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])]
|
||||
.some((declaration) => permissionName(declaration) === "network.stream");
|
||||
if (hasByteStreams && !requiredFeatures.has("network.byte-streams")) errors.push("Byte streams require network.byte-streams");
|
||||
const declaredCapabilityNames = new Set([...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])].map(permissionName));
|
||||
if (declaredCapabilityNames.has("ai.invoke") && !requiredFeatures.has("ai.model-invocation")) errors.push("AI invocation requires ai.model-invocation");
|
||||
if ((declaredCapabilityNames.has("context.selected.list") || declaredCapabilityNames.has("context.selected.read"))
|
||||
&& !requiredFeatures.has("context.selected-read")) errors.push("Selected context requires context.selected-read");
|
||||
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
|
||||
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -333,6 +333,7 @@ test("required resource-scoped permissions declare activation-time bounds", () =
|
||||
const validate = validator("PluginManifest");
|
||||
const resourceScoped = [
|
||||
["network", "https://example.com"],
|
||||
["network.stream", "tls://example.com:443"],
|
||||
["filesystem.read", "/tmp/plugin-read"],
|
||||
["filesystem.write", "/tmp/plugin-write"],
|
||||
["companion.execute", "com.example.contract-test.helper"],
|
||||
@@ -365,6 +366,10 @@ test("required resource-scoped permissions declare activation-time bounds", () =
|
||||
assert.equal(validate({
|
||||
...validManifest,
|
||||
permissions: { optional: [permission] },
|
||||
}), permission !== "network.stream", `${permission}: ${JSON.stringify(validate.errors)}`);
|
||||
assert.equal(validate({
|
||||
...validManifest,
|
||||
permissions: { optional: [{ permission, resources: [resource] }] },
|
||||
}), true, JSON.stringify(validate.errors));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PLUGIN_JSON_MAX_NODES = 100000 as const;
|
||||
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
|
||||
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
|
||||
export const PLUGIN_HOST_INTERACTION_LIMITS = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000} as const;
|
||||
export const PLUGIN_BYTE_STREAM_LIMITS = {"connectionsPerRuntime":4,"connectionsTotal":64,"chunkBytes":65536,"bufferBytes":65536,"connectionBytes":8388608,"connectMs":10000,"idleMs":30000,"lifetimeMs":120000} as const;
|
||||
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
|
||||
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
|
||||
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
|
||||
|
||||
@@ -73,6 +73,32 @@ export type AuthenticationResult = ({
|
||||
|
||||
export type BoundedPermissionResource = string;
|
||||
|
||||
export type ByteStreamAttachResult = {
|
||||
attached: true;
|
||||
};
|
||||
|
||||
export type ByteStreamConnectionDescriptor = {
|
||||
handleId: string;
|
||||
endpoint: ByteStreamEndpoint;
|
||||
inputStreamId: StreamId;
|
||||
outputStreamId: StreamId;
|
||||
windowBytes: 65536;
|
||||
};
|
||||
|
||||
export type ByteStreamConnectRequest = {
|
||||
endpoint: ByteStreamEndpoint;
|
||||
};
|
||||
|
||||
export type ByteStreamEndpoint = string;
|
||||
|
||||
export type ByteStreamFeature = "network.byte-streams";
|
||||
|
||||
export type ByteStreamHandleRequest = {
|
||||
handleId: string;
|
||||
};
|
||||
|
||||
export type ByteStreamLimits = {"connectionsPerRuntime":4,"connectionsTotal":64,"chunkBytes":65536,"bufferBytes":65536,"connectionBytes":8388608,"connectMs":10000,"idleMs":30000,"lifetimeMs":120000};
|
||||
|
||||
export type CommandContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
@@ -431,7 +457,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -451,7 +477,7 @@ export type PermissionDecision = ({
|
||||
decision: "deny" | "cancel";
|
||||
});
|
||||
|
||||
export type PermissionDeclaration = (PluginPermission) | (PermissionResourceDeclaration);
|
||||
export type PermissionDeclaration = Exclude<PluginPermission, "network.stream"> | PermissionResourceDeclaration;
|
||||
|
||||
export type PermissionGrantScope = "once" | "session" | "application" | "always";
|
||||
|
||||
@@ -487,6 +513,191 @@ export type PermissionSet = {
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginAIBooleanResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
export type PluginAIChatRef = {
|
||||
chatHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAIChatResult = {
|
||||
chatHandle: string;
|
||||
model: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAIContextLimits = {"contexts":4,"inputBytes":16384,"chunkBytes":4096,"outputBytes":32768,"outputTokens":2048,"turnMs":60000};
|
||||
|
||||
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 = {
|
||||
chatHandle: string;
|
||||
prompt: string;
|
||||
contexts?: Array<PluginSelectedContextRef>;
|
||||
};
|
||||
|
||||
export type PluginAIStreamEvent = ({
|
||||
kind: "text";
|
||||
sequence: number;
|
||||
text: string;
|
||||
}) | ({
|
||||
kind: "usage";
|
||||
sequence: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}) | ({
|
||||
kind: "end";
|
||||
sequence: number;
|
||||
});
|
||||
|
||||
export type PluginAITurnRef = {
|
||||
turnHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAITurnResult = {
|
||||
turnHandle: string;
|
||||
maxOutputTokens: 2048;
|
||||
reservedTokens: number;
|
||||
};
|
||||
|
||||
export type PluginContributions = {
|
||||
themes?: Array<ThemePackContribution>;
|
||||
settings?: Array<SettingContribution>;
|
||||
@@ -587,7 +798,35 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginSelectedContextContent = {
|
||||
handle: string;
|
||||
scope: "workspace" | "tab" | "session" | "host" | "note";
|
||||
title: string;
|
||||
version: string;
|
||||
expiresAt: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextFeature = "context.selected-read";
|
||||
|
||||
export type PluginSelectedContextListResult = {
|
||||
items: Array<PluginSelectedContextSummary>;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextRef = {
|
||||
handle: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextSummary = {
|
||||
handle: string;
|
||||
scope: "workspace" | "tab" | "session" | "host" | "note";
|
||||
title: string;
|
||||
version: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
@@ -670,7 +909,7 @@ export type RequiredPermissionResourceDeclaration = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ResourceScopedPermission = "network" | "filesystem.read" | "filesystem.write" | "companion.execute";
|
||||
export type ResourceScopedPermission = "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "companion.execute";
|
||||
|
||||
export type RpcCancel = {
|
||||
jsonrpc: "2.0";
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
} from "./jsonValue.js";
|
||||
export {
|
||||
PLUGIN_HOST_INTERACTION_LIMITS,
|
||||
PLUGIN_BYTE_STREAM_LIMITS,
|
||||
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
|
||||
@@ -52,6 +53,9 @@ export {
|
||||
export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MAX_BYTES, PLUGIN_BRAND_ICON_MAX_EDGE } from './brandIcon.js';
|
||||
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
|
||||
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
|
||||
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
|
||||
@@ -24,6 +24,33 @@ const testSecretRef: SecretRef = {
|
||||
key: "token",
|
||||
};
|
||||
|
||||
test("byte streams and AI/context clients expose only canonical requests", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import type { PluginContext } from "./index.ts";
|
||||
declare const context: PluginContext;
|
||||
async function useCapabilities() {
|
||||
const connection = await context.network.connect({ endpoint: "tls://example.test:443" });
|
||||
await connection.writable.write(new Uint8Array([1]));
|
||||
await connection.readable.read();
|
||||
await connection.close();
|
||||
const chat = await context.ai.openChat({});
|
||||
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic" });
|
||||
await context.ai.readTurn({ turnHandle: turn.turnHandle });
|
||||
const selected = await context.selectedContext.list({});
|
||||
await context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
|
||||
// @ts-expect-error source identity is host-owned
|
||||
await context.network.connect({ endpoint: "tcp://example.test:22", sourceOwner: "spoofed" });
|
||||
// @ts-expect-error TLS validation cannot be disabled
|
||||
await context.network.connect({ endpoint: "tls://example.test:443", rejectUnauthorized: false });
|
||||
// @ts-expect-error model selection belongs to the host
|
||||
await context.ai.openChat({ model: "plugin-chosen" });
|
||||
// @ts-expect-error selected context requires a version
|
||||
await context.selectedContext.read({ handle: "opaque" });
|
||||
}
|
||||
void useCapabilities;
|
||||
`);
|
||||
});
|
||||
|
||||
const testSecretStore: PluginSecretStore = {
|
||||
async get() {
|
||||
return testSecretRef;
|
||||
|
||||
@@ -655,6 +655,28 @@ export type { PluginNetworkAuthorization, PluginNetworkRequest, PluginNetworkRes
|
||||
|
||||
export interface PluginNetworkClient {
|
||||
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
|
||||
connect(request: import("@netcatty/plugin-contract").ByteStreamConnectRequest, cancellationToken?: CancellationToken): Promise<PluginByteConnection>;
|
||||
}
|
||||
|
||||
export interface PluginAIClient {
|
||||
openChat(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAIChatResult>;
|
||||
startTurn(params: import("@netcatty/plugin-contract").PluginAIStartTurnParams): Promise<import("@netcatty/plugin-contract").PluginAITurnResult>;
|
||||
readTurn(params: import("@netcatty/plugin-contract").PluginAITurnRef): Promise<import("@netcatty/plugin-contract").PluginAIStreamEvent>;
|
||||
stopTurn(params: import("@netcatty/plugin-contract").PluginAITurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
closeChat(params: import("@netcatty/plugin-contract").PluginAIChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
}
|
||||
|
||||
export interface PluginSelectedContextClient {
|
||||
list(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginSelectedContextListResult>;
|
||||
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
|
||||
}
|
||||
|
||||
export interface PluginByteConnection extends Disposable {
|
||||
readonly endpoint: string;
|
||||
readonly readable: PluginReadableByteStream;
|
||||
readonly writable: PluginWritableByteStream;
|
||||
/** Closes both directions. Repeated calls on this handle are harmless. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginFilesystemEntry {
|
||||
@@ -746,6 +768,8 @@ export interface PluginContext {
|
||||
readonly secrets: PluginSecretStore;
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly ai: PluginAIClient;
|
||||
readonly selectedContext: PluginSelectedContextClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
readonly streams: PluginStreams;
|
||||
@@ -928,6 +952,8 @@ export function throwIfCancellationRequested(token: CancellationToken): void {
|
||||
}
|
||||
|
||||
export { PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_AI_MODEL_INVOCATION_FEATURE, PLUGIN_SELECTED_CONTEXT_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
@@ -22,6 +22,7 @@ const electronBundlePath = path.join(
|
||||
);
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const typescriptTypeOverrides = new Map([
|
||||
["PermissionDeclaration", 'Exclude<PluginPermission, "network.stream"> | PermissionResourceDeclaration'],
|
||||
[
|
||||
"ActivationEvent",
|
||||
'"onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`',
|
||||
@@ -306,6 +307,10 @@ function schemaTypeToTs(node, level = 0) {
|
||||
}
|
||||
|
||||
const hostInteractionLimits = schema.$defs.HostInteractionLimits?.const;
|
||||
const byteStreamLimits = schema.$defs.ByteStreamLimits?.const;
|
||||
if (!byteStreamLimits || Object.values(byteStreamLimits).some((value) => !Number.isSafeInteger(value) || value < 1)) {
|
||||
throw new Error("ByteStreamLimits must contain positive bounded integers");
|
||||
}
|
||||
if (!hostInteractionLimits || Object.values(hostInteractionLimits).some((value) => !Number.isSafeInteger(value) || value < 1)) {
|
||||
throw new Error("HostInteractionLimits must contain positive bounded integers");
|
||||
}
|
||||
@@ -333,6 +338,7 @@ const generatedLimits = [
|
||||
`export const PLUGIN_WIRE_MAX_SAFE_INTEGER = ${wireIntegerLimits.maxSafeInteger} as const;`,
|
||||
`export const PLUGIN_RPC_MAX_JSON_BYTES = ${rpcLimits.maxJsonBytes} as const;`,
|
||||
`export const PLUGIN_HOST_INTERACTION_LIMITS = ${JSON.stringify(hostInteractionLimits)} as const;`,
|
||||
`export const PLUGIN_BYTE_STREAM_LIMITS = ${JSON.stringify(byteStreamLimits)} as const;`,
|
||||
`export const PLUGIN_RPC_ERROR_CODES = ${JSON.stringify(rpcErrorCodes)} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_ID_LENGTH = ${streamLimits.maxStreamIdLength} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_CHUNK_BYTES = ${streamLimits.maxChunkBytes} as const;`,
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user