mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 02:42:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c |
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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -29,7 +29,6 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
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)), '..');
|
||||
@@ -42,7 +42,6 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
@@ -52,30 +51,6 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
@@ -87,50 +62,25 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
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}`);
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
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' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_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({
|
||||
@@ -165,7 +115,6 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
@@ -179,37 +128,26 @@ test('cold plugin host reaches the local fixture through the actual main provide
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
diagnosticPhase = 'cancel-review';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
|
||||
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
|
||||
reviewAllowed = true;
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
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/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
|
||||
@@ -14,10 +14,6 @@ transport task owns the public schema, SDK, and runtime peer. Its
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
|
||||
routes and is merged into this branch. The parent assigned this task the additive
|
||||
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
|
||||
The base later advanced to `f1410f8bb`; its source/stream changes are incorporated
|
||||
by ordinary merge. Three overlaps retain this branch's permission explanations,
|
||||
bounded-stream assertions, and six-file assistant staging. The source database
|
||||
and sidecar changes come from their owning task, without local modifications.
|
||||
|
||||
The model API uses a host-selected configured model. Plugins receive a chat
|
||||
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
|
||||
@@ -59,12 +55,8 @@ This branch does not claim that workflow is complete.
|
||||
database and sync implementation are unchanged. Source origin does not imply
|
||||
that a package is officially maintained.
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner`,
|
||||
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
|
||||
budget owner from the authenticated source key and opaque private owner. It never
|
||||
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
|
||||
runtime binding objects still govern handle access; their replacement does not
|
||||
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
|
||||
`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`.
|
||||
|
||||
@@ -199,11 +191,6 @@ Failed aggregate runs remain evidence, not passing acceptance records:
|
||||
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
|
||||
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
|
||||
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
|
||||
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
|
||||
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
|
||||
|
||||
The ten Cursor failures in the second full run came from its missing SQLite
|
||||
native dependency. Rebuilding the existing dependency with the standard
|
||||
@@ -242,66 +229,9 @@ or terminal permission.
|
||||
|
||||
Independent review confirmed the malformed-reference revocation defect on
|
||||
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
|
||||
The repair is retained in #3985 head `4882e0501`. Further review was initially
|
||||
blocked. After receiving newer exact maintainer security-task evidence, the same
|
||||
worker completed its bounded read-only review of `173ef5abb` without a repeated
|
||||
restriction or alternate route/model. It confirmed the earlier repair and found
|
||||
two additional P2 defects: runtime-bound budget keys and the sample's expired
|
||||
chat recovery.
|
||||
|
||||
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
|
||||
identity. Its fake-clock regression uses the actual source-binding class,
|
||||
replaces runtime binding objects, tests request and reserved-token budgets,
|
||||
isolates another source with the same public plugin ID, and checks the minute
|
||||
boundary. Both budget subtests fail on the reviewed source and pass after repair.
|
||||
Missing or invalid host budget identities fail closed.
|
||||
|
||||
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
|
||||
**Refresh model** for changed-model or failed sessions. Refresh preserves the
|
||||
draft and does not send inference. A failed send/read is never automatically
|
||||
retried. Stop or page teardown invalidates pending opens and closes late sessions.
|
||||
Four controlled-time DOM regressions fail before the repair and pass afterward,
|
||||
including an uncertain inference outcome and Stop during reopening. They use no
|
||||
network or paid provider. Failed-before logs are
|
||||
`/tmp/netcatty-ai-budget-review-before.log` and
|
||||
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
|
||||
`-after.log` names. Correction review independently confirmed the stable budget
|
||||
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
|
||||
|
||||
The corrected broker suite passes all 17 tests, the sample's controlled-time
|
||||
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
|
||||
lint still reports zero errors and seven existing warnings; codegen drift and
|
||||
the production build pass. Two independent package builds reproduce SHA-256
|
||||
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
|
||||
for that earlier sample checkpoint.
|
||||
|
||||
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
|
||||
summary expiry locally, blocks an expired selection before Send, and never sends
|
||||
that local metadata as an authorization field. Refresh model never re-shares a
|
||||
Note. Re-selection opens the existing host chooser and resets the dropdown to
|
||||
No reference; the user selects the fresh handle and clicks Send explicitly.
|
||||
|
||||
The service's identical-selection cache now reuses only complete live summaries.
|
||||
After an explicit host re-selection, expired/revoked handles remain invalid and
|
||||
fresh eligible handles are issued. No authority lifetime or revocation rule is
|
||||
changed. Summary selection does not read text. Every new content handle still
|
||||
requires the existing per-context grant and exact-text review.
|
||||
|
||||
The new fake-clock panel regression uses the actual host service and selected
|
||||
context authority with a real synthetic Note handle. It covers expiry, model
|
||||
refresh without re-sharing, explicit re-selection, denial of the fresh content
|
||||
grant, cancellation of text review, and one successful explicit send with the
|
||||
draft preserved. It repeats the expiry/re-selection with both chat and Note
|
||||
lifetimes expired, then succeeds without a manual model refresh. A separate
|
||||
service regression reproduces the formerly empty
|
||||
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
|
||||
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
|
||||
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
|
||||
suite now passes all 10 tests; the connected installed UI/provider suite passes
|
||||
all 4. The replacement six-file artifact has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
Independent verification of the latest correction and native acceptance remain
|
||||
pending. CI is not a substitute for either.
|
||||
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
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Plugin model transport cold start
|
||||
|
||||
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
|
||||
dependency on the internal AI sidebar. It does not establish the cause or outcome
|
||||
of the original native failure; that still requires an authorized native retest.
|
||||
|
||||
## Reproduced boundary
|
||||
|
||||
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
|
||||
selector reads the saved host provider, while main's provider registry starts
|
||||
empty. The existing sidebar synchronizes that registry when visible or sending.
|
||||
The plugin startup path did not. A nondefault configured loopback port can
|
||||
therefore fail main's provider allowlist before HTTP transport starts.
|
||||
|
||||
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
|
||||
mounts the trusted host component, uses the real application preload, and routes
|
||||
model requests through `registerProviderHandlers` and its actual allowlist. It
|
||||
does not mount the internal sidebar or manually alter the allowlist. Credential
|
||||
injection uses synthetic fixture markers; the HTTP edge and deterministic local
|
||||
fixture use the existing transport. No paid service, real key, Note, or host is
|
||||
used. This remains DOM/in-process evidence, not native sandbox acceptance.
|
||||
|
||||
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
|
||||
|
||||
```text
|
||||
ok: false
|
||||
allowlistRejected: true
|
||||
statusCode: undefined
|
||||
```
|
||||
|
||||
The corrected regression passes and preserves both the selected fixture provider
|
||||
and a second host-configured synthetic provider. It checks that provider markers
|
||||
and configuration do not enter plugin-facing model/context envelopes. Expected
|
||||
SDK error output is suppressed during this diagnostic so request payloads and
|
||||
headers are not logged.
|
||||
|
||||
## Correction
|
||||
|
||||
Before publishing a selected model handle, the trusted renderer synchronizes the
|
||||
current host-owned provider settings through the existing `aiSyncProviders`
|
||||
application API. It checks that the selected model revision matches the snapshot
|
||||
being synchronized. The runtime rechecks activity, cancellation, model revision,
|
||||
and pending-selection ownership after the await. Pending selections count toward
|
||||
the model-handle cap; release and shutdown invalidate late results.
|
||||
|
||||
Plugin requests supply no URL, credential, or provider configuration. The existing
|
||||
main sender validation, endpoint allowlist, source binding, and model invocation
|
||||
budgets are unchanged. No new schema feature, plugin permission, or consent
|
||||
dialog is added. The assistant package is unchanged and retains SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
|
||||
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
|
||||
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
|
||||
overlap host-selection work; preserve both when integrating the separate policy
|
||||
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
|
||||
correction.
|
||||
|
||||
## Native and policy status
|
||||
|
||||
The native open/close sidebar comparison was incomplete. Execution auto-review
|
||||
denied the Application-scope `context.selected.list` grant; that action did not
|
||||
execute. No post-sidebar inference was performed, the prompt expired, and the
|
||||
sample was disabled while the worker cleaned up. This is not native confirmation
|
||||
of the startup cause. The original `677aac33` app remains frozen for comparison;
|
||||
this fix does not authorize retrying the denied execution action.
|
||||
|
||||
In the earlier permission engine, **Application** grants are memory-only for the
|
||||
application lifetime; **Always** grants are persisted. An explicitly authorized
|
||||
Application grant can fit bounded temporary QA. A one-use prompt on every stream
|
||||
read is not an unavoidable target UX. The separate policy task implements the
|
||||
new direction: default Allow for declared supported capabilities, per-plugin
|
||||
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
|
||||
separate from execution-tool authorization and must not be used to evade a tool
|
||||
denial. This transport correction does not redesign that policy.
|
||||
|
||||
Native outcome, independent verification, and the sample's light-theme appearance
|
||||
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
|
||||
model invocation, main merge, or release was performed for this correction.
|
||||
|
||||
## Node 22 CI observer correction
|
||||
|
||||
The new cold-start test failed on exact head `9ca70f329` in test run
|
||||
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
|
||||
The sole failure was the empty SDK/renderer-error assertion receiving one
|
||||
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
|
||||
not an unrelated baseline failure.
|
||||
|
||||
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
|
||||
Reproduction on the exact CI version identified the safe category
|
||||
`setup:node-sqlite-experimental`, before any model operation. The existing
|
||||
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
|
||||
`console.error`. The newly added observer was installed before that warning
|
||||
drained. The same test passed on Node 24, which did not emit that warning.
|
||||
|
||||
The test now lets pending module warnings reach normal stderr before installing
|
||||
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
|
||||
assertion is unchanged. Safe phase/error categories make future failures
|
||||
diagnosable without request payloads or headers. No production behavior, timeout,
|
||||
permission, or cancellation assertion changed. Both Node versions pass the
|
||||
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
|
||||
16 tests with concurrency 4.
|
||||
|
||||
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
|
||||
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
|
||||
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
|
||||
records the parallel check. The appearance-only sample change is independently
|
||||
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
@@ -29,9 +29,7 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
|
||||
update. The host provider cold-start correction remains a separate app change.
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -107,32 +105,6 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
11. Keep the fixture request counter and aborted-connection counter in the
|
||||
evidence. Do not include prompt bodies, headers, credentials, or Note content
|
||||
other than the synthetic text above in logs.
|
||||
12. Type a synthetic draft and click **Refresh model** while idle. The model
|
||||
session must reopen, the draft must remain, and the fixture request count
|
||||
must not increase. Change the selected configured model in host Settings,
|
||||
refresh again, and check the displayed model before explicitly sending.
|
||||
Refresh and failed requests must never automatically resend inference.
|
||||
13. Keep a selected synthetic Note until its five-minute handle expires. Send
|
||||
must explain that the reference expired without reading text or calling the
|
||||
fixture. Refresh model must not share the Note again. Click **Choose
|
||||
reference**, confirm the Note in the host chooser, select its fresh reference
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
14. With the sample idle, switch the host between light and dark and change its
|
||||
accent. The page, controls, muted surfaces, text, focus ring, and Send button
|
||||
must follow the existing view environment tokens without model requests or
|
||||
new permission prompts. Capture both appearances on the same host window.
|
||||
This check does not change or bypass the separately owned permission policy.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
300,000 ms using a real selected Note handle, without network calls. It checks
|
||||
fresh host selection, old-handle rejection, independent content permission and
|
||||
exact-text review, and a successful explicit send. The broker budget
|
||||
regression controls its clock across runtime replacement and the minute boundary.
|
||||
Do not change the host clock or production limits to reproduce those cases in
|
||||
native QA.
|
||||
|
||||
The automated suites cover source substitution, runtime identity, two-handle
|
||||
revocation after malformed input, late replies, byte/token/time bounds, and
|
||||
@@ -142,14 +114,7 @@ installed runtime, configured provider transport, and UI lifecycle.
|
||||
## Record remaining limits
|
||||
|
||||
Independent review found the malformed-context revocation bug. The regression
|
||||
reproduced and the repair passed focused tests. Further review was initially
|
||||
blocked; after newer exact maintainer security-task evidence, the same worker
|
||||
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
|
||||
or alternate route/model. Two additional P2 findings were reproduced and repaired
|
||||
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
|
||||
Correction review independently confirmed the stable source-budget fix, then
|
||||
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
|
||||
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
|
||||
handles after host confirmation. The real-Note panel and service regressions
|
||||
failed before repair and pass afterward. Independent verification of this latest
|
||||
correction remains pending. CI and native evidence do not replace it.
|
||||
reproduced and the repair passed focused tests. Further independent review was
|
||||
blocked by the review tool/model restriction. That review remains incomplete and
|
||||
has not been retried through another route. CI and native evidence do not replace
|
||||
that missing review.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Byte stream acceptance evidence
|
||||
|
||||
This is the historical report published by the transport owner at #3988
|
||||
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
|
||||
and missing AI wiring describe that branch and environment. The current owner
|
||||
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
|
||||
[the integration handoff](byte-stream-native-qa.md) for current checks, the
|
||||
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
|
||||
recipe is not applicable to the occupied GUI machine without Docker.
|
||||
|
||||
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
|
||||
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
|
||||
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
|
||||
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
No source database or sync implementation is changed by the transport delta.
|
||||
The original transport checkpoint is #3988
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
|
||||
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
|
||||
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
|
||||
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
|
||||
The original transport branch remains untouched. Follow the
|
||||
[current native handoff](byte-stream-native-qa.md) for the integrated package.
|
||||
The late transport checkpoint `f1410f8` is subsequently preserved through an
|
||||
ordinary merge. [The comparison record](late-transport-integration.md) lists its
|
||||
remaining changes and the conflict resolutions that preserve the two P2 fixes
|
||||
and newer connected AI files.
|
||||
|
||||
## Registered transport
|
||||
|
||||
@@ -51,7 +58,7 @@ Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
|
||||
implemented. Generic audit records contain endpoint, runtime, handle, and byte
|
||||
counts. The host does not inspect SQL or record payloads or credentials.
|
||||
|
||||
## Additive AI interface for the parallel owner
|
||||
## Integrated AI interface
|
||||
|
||||
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
|
||||
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
|
||||
@@ -59,13 +66,12 @@ and `context.selectedContext.list/read`. Host registration advertises
|
||||
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
|
||||
plugin disable, revocation through the service, and shutdown.
|
||||
|
||||
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
|
||||
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
|
||||
and optional lifecycle callbacks. These callbacks are never exposed to plugin
|
||||
views. The default adapter rejects model invocation, returns no selected
|
||||
resources, and refuses text review. The AI owner still owns real renderer
|
||||
wiring and model/context UX. A registered synthetic adapter test is not proof
|
||||
of that remaining production wiring.
|
||||
The connected host renderer and shared permission descriptions from #3986 are
|
||||
preserved. Its owner documents the adapter and existing configured model path in
|
||||
[the AI checkpoint](ai-context-wip.md), with a
|
||||
[synthetic native assistant recipe](assistant-native-qa.md). No further
|
||||
independent review of AI internals was performed. The prior review denial remains
|
||||
unresolved and has not been retried through another route.
|
||||
|
||||
## Database example
|
||||
|
||||
@@ -106,6 +112,18 @@ connection-management subsystem. See the example README for native setup.
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
|
||||
sample hash, and remaining native/exact-head review work. Do not disable the
|
||||
Chromium sandbox to obtain that evidence.
|
||||
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
|
||||
Its regression mocks interface lookup and uses a fake dialer. It does not retry
|
||||
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
|
||||
|
||||
Cancellation now discards buffered browser bytes, settles producer-cancelled
|
||||
credit once, and retains terminal producer state only until outstanding credit
|
||||
arrives. Existing sequence, schema, and negotiated-window checks still reject
|
||||
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
|
||||
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
|
||||
and runtime shutdown, without another scheduler.
|
||||
|
||||
Normal native BrowserWindow and screenshot/video acceptance remain pending.
|
||||
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
|
||||
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
|
||||
relax endpoint restrictions to obtain that evidence.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Test integrated byte streams on the native desktop
|
||||
|
||||
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
|
||||
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
|
||||
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
|
||||
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
|
||||
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
|
||||
|
||||
## Prepare the desktop and fixture
|
||||
|
||||
Wait until the approved native GUI machine is free from SSH acceptance work.
|
||||
Use a fresh disposable app profile, a working display, and the platform's normal
|
||||
Electron sandbox. Keep the existing endpoint policy and system security settings.
|
||||
|
||||
The database pass requires a disposable MariaDB fixture at a reachable private
|
||||
IPv4 address. The address must not belong to the GUI machine. The current native
|
||||
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
|
||||
already approved separate Docker host or disposable database machine with an
|
||||
existing permitted network path. Do not install new software or create local
|
||||
forwarding exceptions as part of this recipe. If no fixture is available, record
|
||||
the database pass as pending.
|
||||
|
||||
There is also a separately labeled
|
||||
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
|
||||
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
|
||||
It has only been prepared and checked offline. The parent must obtain exact
|
||||
package and endpoint authorization before any install, permission activation,
|
||||
or connection. Its authentication and TLS requirements remain unverified. This
|
||||
candidate does not replace the owned fixture regression or prove that the GUI
|
||||
can reach the coding container's private database.
|
||||
|
||||
The registered automated fixture uses
|
||||
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
|
||||
It runs on an internal Docker network, publishes no host ports, generates a
|
||||
disposable password, and removes its container and network. On a prepared Linux
|
||||
executor with that recognized image, run:
|
||||
|
||||
```sh
|
||||
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
|
||||
```
|
||||
|
||||
That test exercises the protocol, broker, and installed package's SDK/RPC path
|
||||
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
|
||||
have its operator create only
|
||||
synthetic data and a dedicated SELECT-only account. Run this SQL using the
|
||||
fixture's administration channel, replacing the password placeholder with a
|
||||
fresh disposable password that is never included in screenshots or logs:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE plugin_demo;
|
||||
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
|
||||
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
|
||||
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
```
|
||||
|
||||
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
|
||||
within the approved isolated fixture network. Verify INSERT denial independently
|
||||
through the fixture's client. The plugin intentionally offers only its fixed read.
|
||||
|
||||
## Build and identify the packages
|
||||
|
||||
Use Node 22 or later and the lockfile. Run these commands in sequence:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-contract
|
||||
npm run test:plugin-examples
|
||||
npm run generate:plugin-contract
|
||||
npm run check:plugin-contract
|
||||
npm run generate:capability-tools
|
||||
npm run check:codex-app-server-schema
|
||||
npm run lint
|
||||
npm run build
|
||||
git diff --exit-code
|
||||
```
|
||||
|
||||
For the reproducible packaging-only example, use the fixed synthetic endpoint:
|
||||
|
||||
```sh
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
|
||||
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
|
||||
sha256sum /tmp/database-example.ncpkg
|
||||
```
|
||||
|
||||
The two five-file packages built on Node 24.19.0 with the locked dependencies
|
||||
match byte for byte. The current owned-fixture package SHA-256 is
|
||||
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
|
||||
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
|
||||
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
|
||||
fixture database, query, and opt-in live regression are retained.
|
||||
The six-file assistant package retains SHA-256
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
|
||||
The preparation tool removes temporary build paths from its minified browser
|
||||
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
|
||||
and record that package's new hash. Preparation does not dial the endpoint.
|
||||
|
||||
Build the assistant and follow its existing
|
||||
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
|
||||
synthetic credential marker, and synthetic Note. No paid model call is required.
|
||||
The AI internals were integrated without another independent review. The earlier
|
||||
review denial remains in effect and is not replaced by these tests.
|
||||
|
||||
## Record the installed browser path
|
||||
|
||||
1. Launch the integrated app on the approved desktop with a new disposable
|
||||
`--user-data-dir`. Capture the full app window and record the exact commit.
|
||||
2. In Settings > Plugins, import the database package. Capture its ID, version,
|
||||
package hash, source identity, browser entry, and exact endpoint permission.
|
||||
The import must leave the plugin disabled and optional access ungranted.
|
||||
3. Enable it and open **Disposable database example**. Deny the first endpoint
|
||||
request. Capture the failure and verify the runtime remains usable.
|
||||
4. Retry with only the disposable fixture credentials and allow the exact
|
||||
endpoint. Capture the one-row result and the source-aware permission prompt.
|
||||
The password field must clear when the request starts.
|
||||
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
|
||||
delay offered by the fixture operator. Do not add a host protocol scheduler
|
||||
or change the query. Confirm the request stops and a fresh read still works.
|
||||
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
|
||||
failure and a new request prompts again. Disable, re-enable, close the view,
|
||||
and restart the app. No old connection or result may resume.
|
||||
7. Record the assistant's denial, exact-text review, streamed result, usage,
|
||||
Stop, and post-revocation recovery through its linked recipe. Use the same
|
||||
integrated head and preserve its separate permission descriptions.
|
||||
8. Remove only the synthetic plugins and profile. Have the fixture operator
|
||||
remove the disposable account, data, container, and network. Stop the synthetic
|
||||
model provider.
|
||||
|
||||
## Record the remaining evidence
|
||||
|
||||
Save the exact head, app platform/version, package hashes, ordered commands and
|
||||
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
|
||||
synthetic Node runtime integration, and actual native sandbox acceptance.
|
||||
|
||||
The address regression uses a mocked `os.networkInterfaces()` result and a fake
|
||||
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
|
||||
regression uses the registered package, host service, SDK, RPC, and real loopback
|
||||
echo fixture under the existing test-only override. This override is unavailable
|
||||
through the production service constructor. Neither test certifies the native
|
||||
BrowserWindow sandbox or a live database pass on the GUI machine.
|
||||
|
||||
The integration executor completed these checks in order on 2026-10-26:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
|
||||
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
|
||||
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
|
||||
| Plugin contract generate and drift check | Passed, no generated changes |
|
||||
| Capability tool generation and drift check | Passed, no generated changes |
|
||||
| Codex App Server schema check | Passed |
|
||||
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
|
||||
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
|
||||
| Database repeat packaging and both package validations | Passed |
|
||||
|
||||
The late transport merge moves the opt-in database test into the example directory.
|
||||
Run its panel and protocol tests explicitly with
|
||||
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
|
||||
still requires explicit opt-in and the native Electron suite remains separate.
|
||||
This executor has Docker, but the pinned MariaDB image
|
||||
is not cached. No image was installed and no new live database run is claimed.
|
||||
Native GUI acceptance remains pending on the occupied desktop. Full repository
|
||||
tests and platform packages run in the integration PR's exact-head CI. Record
|
||||
their final workflow conclusions separately from these local results.
|
||||
|
||||
After the late transport merge and Rfam candidate preparation, the registered
|
||||
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
|
||||
and `npm run test:plugin-examples` has four passed and one Docker skip. The
|
||||
example suite is now registered in CI. Public protocol checks use only synthetic
|
||||
packet bytes and package preparation; no Rfam connection or permission was
|
||||
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
|
||||
head whose two workflows already succeeded.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Reconcile the late transport checkpoint
|
||||
|
||||
The integration owner is #3993, branch
|
||||
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
|
||||
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
|
||||
|
||||
The original transport owner later published #3988 at
|
||||
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
|
||||
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
|
||||
The source checkpoint is already an ancestor of #3993.
|
||||
|
||||
## Keep the remaining transport changes
|
||||
|
||||
The late delta is not entirely superseded. An ordinary merge retains its
|
||||
published history and these useful changes:
|
||||
|
||||
- Attach cleanup cancels a producer returned after runtime release exactly
|
||||
once. The new regression fails against the old #3993 service and passes
|
||||
with the late service change. The test uses a loopback fixture and a fake
|
||||
stream producer. It does not enumerate real interfaces.
|
||||
- Service tests cover source replacement before permission publication and
|
||||
during a pending read. A router test checks one close notification for idle
|
||||
peer cancellation. The installed entrypoint also checks consumer cancellation.
|
||||
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
|
||||
manifest checks require the corresponding AI/context feature declarations.
|
||||
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
|
||||
- Database-specific testing moves out of `electron/plugins` into the minimal
|
||||
example. The opt-in test also installs its real package and checks the
|
||||
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
|
||||
evidence, not a new live pass on the current integration head.
|
||||
- The example panel test checks password clearing and text-only result output.
|
||||
|
||||
## Resolve overlap in favor of the integrated behavior
|
||||
|
||||
| Overlap | Resolution |
|
||||
| --- | --- |
|
||||
| Marketplace source files and lockfile | Already identical to the late checkpoint |
|
||||
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
|
||||
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
|
||||
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
|
||||
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
|
||||
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
|
||||
| Assistant preparation | Retain the newer six-file staging build |
|
||||
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
|
||||
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
|
||||
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
|
||||
|
||||
The original reports are retained in
|
||||
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
|
||||
AI wiring status nor their environment-specific broad-test failures replace the
|
||||
connected implementation and exact-head CI evidence on #3993. No main merge,
|
||||
force-push, endpoint-policy relaxation, AI internal review retry, or release is
|
||||
part of this reconciliation.
|
||||
|
||||
## Check the reconciled source
|
||||
|
||||
The merged transport source passed 627 registered runtime tests: 626 passed and
|
||||
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
|
||||
example panel passed; its opt-in database test remained skipped. The late attach
|
||||
regression failed against the old #3993 service with two cancel calls instead
|
||||
of one, then passed after the merge.
|
||||
|
||||
A subsequent example-only Rfam candidate reuses the limited browser protocol
|
||||
through two fixed entrypoints. Its offline tests check the original owned
|
||||
fixture database/query and the fixed public database/query, plus rejection of
|
||||
an unsupported auth plugin. No general query API is exported and no host
|
||||
database code is introduced. The owned MariaDB live regression remains opt-in.
|
||||
@@ -30,7 +30,6 @@ class PluginAIContextService {
|
||||
this.runtimeContexts = new Map();
|
||||
this.registrations = [];
|
||||
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
|
||||
resolveBudgetOwner: (binding) => JSON.stringify([binding.sourceKey, binding.privateOwner]),
|
||||
selectModel: async (context) => {
|
||||
await this.#guard(context);
|
||||
const model = await this.host.selectModel(context);
|
||||
@@ -118,10 +117,7 @@ class PluginAIContextService {
|
||||
}
|
||||
const previous = this.runtimeContexts.get(context.runtimeId);
|
||||
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
|
||||
const items = await this.selected.summaries(context);
|
||||
if (items.length === selections.length) return { items };
|
||||
// The host has just explicitly selected these resources again. Expired
|
||||
// or revoked handles stay invalid; issue fresh handles for this selection.
|
||||
return { items: await this.selected.summaries(context) };
|
||||
}
|
||||
this.selected.releaseRuntime(context.runtimeId);
|
||||
const created = [];
|
||||
|
||||
@@ -64,16 +64,3 @@ test("cancelling one host request does not revoke another chat in the same runti
|
||||
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
|
||||
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
|
||||
});
|
||||
|
||||
test("an explicit host re-selection issues fresh handles after identical selected Note summaries expire", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
let now = 0; f.service.selected.clock = () => now;
|
||||
const { items: [old] } = await f.service.list({}, f.context);
|
||||
now = 300_001;
|
||||
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
|
||||
const { items } = await f.service.list({}, f.context);
|
||||
assert.equal(items.length, 1);
|
||||
assert.notEqual(items[0].handle, old.handle); assert.equal(items[0].expiresAt, 600_001);
|
||||
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
|
||||
assert.equal(f.reads(), 0); assert.equal(f.authorizations.length, 0); assert.equal(f.invoked(), 0);
|
||||
});
|
||||
@@ -28,10 +28,9 @@ function abortable(promise, signal) {
|
||||
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
|
||||
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
|
||||
class ModelInvocationBroker {
|
||||
constructor({ resolveDataOwner, resolveBudgetOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function" || typeof resolveBudgetOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
|
||||
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.resolveBudgetOwner = resolveBudgetOwner;
|
||||
this.selectModel = selectModel;
|
||||
this.contexts = contexts;
|
||||
this.clock = clock;
|
||||
@@ -55,21 +54,13 @@ class ModelInvocationBroker {
|
||||
const owner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
|
||||
// Handle identity remains runtime-specific. Budget identity is separately
|
||||
// supplied by the host and must survive runtime replacement/regrant.
|
||||
const budgetOwner = await this.resolveBudgetOwner(owner, context);
|
||||
await context.assertActive();
|
||||
if (typeof budgetOwner !== "string" || !budgetOwner || Buffer.byteLength(budgetOwner) > 8_192) {
|
||||
throw fail("permissionDenied", "Host budget owner is unavailable");
|
||||
}
|
||||
return { owner, budgetOwner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
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.budgetOwner !== binding.budgetOwner
|
||||
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
@@ -158,14 +149,14 @@ class ModelInvocationBroker {
|
||||
}
|
||||
await this.#guard(turn, context);
|
||||
this.#prune();
|
||||
const rate = this.rates.get(chat.budgetOwner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
|
||||
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.budgetOwner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
|
||||
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.budgetOwner, rate);
|
||||
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);
|
||||
|
||||
@@ -3,12 +3,11 @@ const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
const { RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
|
||||
function fixture(options = {}) {
|
||||
const owner = { budgetOwner: "synthetic-source-a" }; let currentOwner = owner; let current = true; let active = true; let now = 0;
|
||||
const 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"); } };
|
||||
@@ -18,8 +17,7 @@ function fixture(options = {}) {
|
||||
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
|
||||
const resolveDataOwner = async () => currentOwner;
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: (binding) => binding.budgetOwner,
|
||||
selectModel: async () => model, contexts,
|
||||
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; } };
|
||||
@@ -222,65 +220,3 @@ test("pending model selections reserve capacity and cannot publish after revocat
|
||||
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
|
||||
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("same-source runtime replacement preserves minute budgets while distinct sources stay isolated", async (t) => {
|
||||
for (const limits of [{ requestsPerMinute: 1 }, { reservedTokensPerMinute: 3_074 }]) {
|
||||
await t.test(Object.keys(limits)[0], async (t) => {
|
||||
let now = 1_000; let sourceId = "source-a"; let runtimeId = "runtime-1"; let invocations = 0;
|
||||
const pluginId = "com.example.assistant";
|
||||
const context = () => ({ pluginId, pluginVersion: "1", runtimeId, assertActive: async () => {} });
|
||||
const source = () => ({ id: sourceId, kind: "third-party", origin: `https://${sourceId}.example` });
|
||||
const binding = new PluginAISourceBinding({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, activeVersion: "1", runtime: { status: "running" }, source: source() }),
|
||||
getRetainedPluginSource: source,
|
||||
privateOwner: () => `opaque:${sourceId}:assistant`,
|
||||
publicPluginId: (owner) => owner === `opaque:${sourceId}:assistant` ? pluginId : null,
|
||||
},
|
||||
getRuntimeIdentity: () => ({ ...context(), runtimeKind: "browser" }),
|
||||
});
|
||||
const broker = new ModelInvocationBroker({
|
||||
resolveDataOwner: (caller) => binding.resolve(caller),
|
||||
resolveBudgetOwner: (owner) => JSON.stringify([owner.sourceKey, owner.privateOwner]),
|
||||
selectModel: () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
|
||||
invocations++; return (async function* () {})();
|
||||
} }), clock: () => now, limits,
|
||||
});
|
||||
t.after(() => { broker.shutdown(); binding.shutdown(); });
|
||||
const start = (chat) => broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, context());
|
||||
const replace = (nextSource) => {
|
||||
broker.releaseRuntime(runtimeId); binding.releaseRuntime(runtimeId);
|
||||
sourceId = nextSource; runtimeId = `${runtimeId}-next`;
|
||||
};
|
||||
const firstOwner = await binding.resolve(context());
|
||||
const first = await broker.open(context()); const firstTurn = await start(first);
|
||||
await broker.stop(firstTurn.turnHandle, context());
|
||||
replace("source-a");
|
||||
assert.notEqual(await binding.resolve(context()), firstOwner);
|
||||
const restarted = await broker.open(context());
|
||||
await assert.rejects(start(restarted), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
assert.equal(invocations, 1);
|
||||
assert.equal(await broker.close(first.chatHandle, context()), false);
|
||||
replace("source-b");
|
||||
const other = await broker.open(context()); const otherTurn = await start(other);
|
||||
await broker.stop(otherTurn.turnHandle, context()); assert.equal(invocations, 2);
|
||||
replace("source-a");
|
||||
const returned = await broker.open(context());
|
||||
now = 60_999;
|
||||
await assert.rejects(start(returned), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
now = 61_000;
|
||||
const afterWindow = await start(returned); assert.equal(invocations, 3);
|
||||
await broker.stop(afterWindow.turnHandle, context());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("budget ownership requires an explicit host adapter and never falls back to the plugin ID", async (t) => {
|
||||
assert.throws(() => new ModelInvocationBroker({ resolveDataOwner: () => ({}), selectModel: () => ({}) }), /Host adapters required/);
|
||||
for (const budgetOwner of [undefined, "", {}, "x".repeat(8_193)]) {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
f.broker.resolveBudgetOwner = () => budgetOwner;
|
||||
await assert.rejects(f.broker.open(f.context), denied);
|
||||
assert.equal(f.calls.length, 0); assert.equal(f.broker.chats.size, 0);
|
||||
}
|
||||
});
|
||||
@@ -32,22 +32,28 @@ function canonicalizeStreamEndpoint(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStreamAddress(address) {
|
||||
if (net.isIP(address) === 4) return address;
|
||||
if (net.isIP(address) !== 6) return null;
|
||||
const canonical = new URL(`http://[${address.split("%")[0]}]`).hostname.slice(1, -1);
|
||||
if (!canonical.startsWith("::ffff:")) return canonical;
|
||||
const words = canonical.slice(7).split(":");
|
||||
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
|
||||
return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
|
||||
}
|
||||
|
||||
function isLocalControlAddress(address) {
|
||||
address = normalizeStreamAddress(address);
|
||||
if (net.isIP(address) === 4) {
|
||||
const [a, b] = address.split(".").map(Number);
|
||||
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
|
||||
}
|
||||
if (net.isIP(address) !== 6) return true;
|
||||
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
||||
if (canonical.startsWith("::ffff:")) {
|
||||
const words = canonical.slice(7).split(":");
|
||||
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
|
||||
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
|
||||
}
|
||||
const canonical = address;
|
||||
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
|
||||
return canonical.startsWith("::") || canonical.startsWith("ff")
|
||||
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|
||||
|| canonical.startsWith("2001:0:");
|
||||
}
|
||||
|
||||
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress, normalizeStreamAddress };
|
||||
@@ -38,13 +38,14 @@ test("installed browser entrypoint uses registered SDK/RPC streams, denial and r
|
||||
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
|
||||
activationEvents: [`onCommand:${id}.run`],
|
||||
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
|
||||
contributes: { commands: ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
contributes: { commands: ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
};
|
||||
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
|
||||
await fs.writeFile(path.join(packageRoot, "browser.js"), `
|
||||
export default { activate(context) {
|
||||
let connection;
|
||||
for (const name of ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
for (const name of ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
if (name === "readCancelled") return connection.readable.read();
|
||||
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
|
||||
if (name === "selected") {
|
||||
const selected = await context.selectedContext.list({});
|
||||
@@ -67,6 +68,10 @@ export default { activate(context) {
|
||||
connection.readable.cancel();
|
||||
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
|
||||
}
|
||||
if (name === "retain") {
|
||||
await connection.writable.write(Uint8Array.of(42));
|
||||
return Array.from(await connection.readable.read());
|
||||
}
|
||||
try {
|
||||
await connection.writable.write(Uint8Array.of(0, 255, 42));
|
||||
return Array.from(await connection.readable.read());
|
||||
@@ -120,10 +125,11 @@ export default { activate(context) {
|
||||
]);
|
||||
assert.equal((await run("selected")).text, "synthetic selected text");
|
||||
await assert.rejects(run("aiSpoof"), /invalid/u);
|
||||
assert.deepEqual(await run("pending"), { connected: true });
|
||||
assert.deepEqual(await run("retain"), [42]);
|
||||
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
await assert.rejects(run("readCancelled"), /cancel/u);
|
||||
await run("cancel");
|
||||
await run("pending");
|
||||
await service.manager.setEnabled(id, false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { normalizeStreamAddress } = require("./byteStreamEndpoint.cjs");
|
||||
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
@@ -27,7 +28,11 @@ class PluginByteStreamService {
|
||||
permissionEngine: options.permissionEngine,
|
||||
quotaManager: options.quotaManager,
|
||||
// The public host service never forwards transport fixture overrides.
|
||||
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
|
||||
isControlEndpoint: (address) => {
|
||||
const normalized = normalizeStreamAddress(address);
|
||||
return normalized !== null && Object.values(os.networkInterfaces()).flat()
|
||||
.some((item) => item?.address && normalizeStreamAddress(item.address) === normalized);
|
||||
},
|
||||
audit: (pluginId, operation, fields, state) => {
|
||||
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
|
||||
},
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamService } = require("./byteStreamService.cjs");
|
||||
|
||||
test("own-machine denial normalizes both interface and destination addresses without blocking private peers", async (t) => {
|
||||
let interfaces;
|
||||
t.mock.method(os, "networkInterfaces", () => interfaces);
|
||||
const service = new PluginByteStreamService({
|
||||
permissionEngine: { authorize: async () => {}, onDidRevoke: () => ({ dispose() {} }) },
|
||||
});
|
||||
t.after(() => service.shutdown());
|
||||
const dialed = [];
|
||||
service.broker.connectSocket = ({ host }) => {
|
||||
dialed.push(host);
|
||||
throw new Error("synthetic dialer reached");
|
||||
};
|
||||
const context = { pluginId: "synthetic", runtimeId: "synthetic", assertActive: async () => {} };
|
||||
for (const ownAddress of ["10.0.0.8", "::ffff:10.0.0.8", "::ffff:a00:8"]) {
|
||||
interfaces = { synthetic: [{ address: ownAddress }] };
|
||||
for (const endpoint of ["tcp://10.0.0.8:43210", "tcp://[::ffff:10.0.0.8]:43210", "tcp://[::ffff:a00:8]:43210"]) {
|
||||
await assert.rejects(service.broker.connect(endpoint, context), /control endpoints/u);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(dialed, []);
|
||||
interfaces = { synthetic: [{ address: "2001:0db8:0:0:0:0:0:8" }] };
|
||||
await assert.rejects(service.broker.connect("tcp://[2001:db8::8]:43210", context), /control endpoints/u);
|
||||
interfaces = { synthetic: [{ address: "10.0.0.8" }] };
|
||||
for (const endpoint of ["tcp://10.0.0.9:43210", "tcp://[::ffff:10.0.0.9]:43210", "tcp://192.168.1.9:43210"]) {
|
||||
await assert.rejects(service.broker.connect(endpoint, context), /synthetic dialer reached/u);
|
||||
}
|
||||
assert.deepEqual(dialed, ["10.0.0.9", "::ffff:a00:9", "192.168.1.9"]);
|
||||
assert.equal(service.broker.connections.size, 0);
|
||||
});
|
||||
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { PluginByteStreamService } = require("./byteStreamService.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
|
||||
@@ -102,3 +102,70 @@ test("runtime byte streams reject concurrent reads and bound pending accept rese
|
||||
endpoint.rejectReadable("reserved");
|
||||
await assert.rejects(reserved, /cancel/i);
|
||||
});
|
||||
|
||||
test("host cancellation discards buffered bytes and settles retained credit before the next read", async (t) => {
|
||||
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
|
||||
const { PluginStreamRouter } = require("./streamRouter.cjs");
|
||||
const toHost = [];
|
||||
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } });
|
||||
const host = new PluginStreamRouter({ send(message) { endpoint.accept(message); } });
|
||||
t.after(() => { endpoint.close(); host.close(); });
|
||||
const pending = endpoint.acceptReadable("host-cancel");
|
||||
const output = await host.openOutgoing("host-cancel", 1024);
|
||||
const readable = await pending;
|
||||
await output.write(Uint8Array.of(1));
|
||||
assert.deepEqual([...await readable.read()], [1]);
|
||||
await output.write(Uint8Array.of(2));
|
||||
output.cancel();
|
||||
const count = toHost.length;
|
||||
await assert.rejects(readable.read(), /cancel/u);
|
||||
await assert.rejects(readable.read(), /cancel/u);
|
||||
assert.equal(toHost.length, count, "reads after cancellation must not return credit");
|
||||
assert.equal(toHost.reduce((sum, message) => sum + message.frame.creditBytes, 0), 2);
|
||||
for (const message of toHost) await host.accept(message);
|
||||
assert.equal(host.outgoing.size, 0);
|
||||
const otherPending = endpoint.acceptReadable("unrelated");
|
||||
const other = await host.openOutgoing("unrelated", 1024);
|
||||
await otherPending;
|
||||
other.cancel();
|
||||
});
|
||||
|
||||
test("runtime producer cancellation drains discarded credit and rejects buffered reads", async () => {
|
||||
const { left, right } = await createPair();
|
||||
const pending = right.acceptReadable("producer-cancel");
|
||||
const writable = await left.openWritable("producer-cancel", 1024);
|
||||
const readable = await pending;
|
||||
await writable.write(Uint8Array.of(1));
|
||||
assert.deepEqual([...await readable.read()], [1]);
|
||||
await writable.write(Uint8Array.of(2));
|
||||
writable.cancel();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(readable.read(), /cancel/u);
|
||||
left.close();
|
||||
right.close();
|
||||
});
|
||||
|
||||
test("browser producer cancellation settles host receive credit without resuming writes", async (t) => {
|
||||
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
|
||||
const { PluginStreamRouter } = require("./streamRouter.cjs");
|
||||
const toHost = [];
|
||||
let release;
|
||||
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } }, { maxStreams: 1 });
|
||||
const host = new PluginStreamRouter({
|
||||
send(message) { endpoint.accept(message); },
|
||||
onIncomingStream({ bind }) { bind({ onChunk(_chunk, callback) { release = callback; } }); return true; },
|
||||
});
|
||||
t.after(() => { endpoint.close(); host.close(); });
|
||||
const output = await endpoint.openWritable("browser-cancel", 1024);
|
||||
await host.accept(toHost.shift());
|
||||
await output.write(new Uint8Array(1024));
|
||||
await host.accept(toHost.shift());
|
||||
const queued = output.write(Uint8Array.of(1));
|
||||
output.cancel();
|
||||
await assert.rejects(queued, /cancel/u);
|
||||
await host.accept(toHost.shift());
|
||||
release();
|
||||
assert.equal(toHost.length, 0);
|
||||
assert.equal(host.incoming.size, 0);
|
||||
await endpoint.openWritable("after-cancel", 1024);
|
||||
});
|
||||
@@ -86,16 +86,28 @@ export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeIncoming(state, error, notify = false) {
|
||||
function closeIncoming(state, error, notify = false, refundCancelledCredit = false) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
state.error = error ?? null;
|
||||
incoming.delete(state.streamId);
|
||||
if (error) {
|
||||
// Discard unread bytes on cancellation/failure and settle their credit
|
||||
// now. A later read must surface the error without sending more credit.
|
||||
state.currentCreditBytes += state.queue.reduce((sum, chunk) => sum + chunk.creditBytes, 0);
|
||||
state.queue.length = 0;
|
||||
// A consumer-initiated cancel rejects the producer's queued writes.
|
||||
// Returning credit first could let that producer send another chunk.
|
||||
if (refundCancelledCredit) {
|
||||
try { releaseCurrent(state); } catch {}
|
||||
}
|
||||
state.currentCreditBytes = 0;
|
||||
}
|
||||
if (notify) {
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence,
|
||||
sequence: Math.max(1, state.updateSequence + 1),
|
||||
kind: "cancel",
|
||||
});
|
||||
} catch {}
|
||||
@@ -221,16 +233,21 @@ export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
cancel() {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
outgoing.delete(state.streamId);
|
||||
state.terminal = "cancel";
|
||||
const error = new PluginError("cancelled", "Plugin stream was cancelled");
|
||||
for (const pending of state.queue.splice(0)) pending.reject(error);
|
||||
state.queuedBytes = 0;
|
||||
state.endReject?.(error);
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence + 1,
|
||||
kind: "cancel",
|
||||
});
|
||||
try {
|
||||
sendEnvelope({
|
||||
streamId: state.streamId,
|
||||
sequence: state.nextSequence + 1,
|
||||
kind: "cancel",
|
||||
});
|
||||
state.terminalSent = true;
|
||||
} finally {
|
||||
if (!state.terminalSent || state.availableBytes === state.windowBytes) outgoing.delete(state.streamId);
|
||||
}
|
||||
},
|
||||
dispose() { this.cancel(); },
|
||||
});
|
||||
@@ -314,6 +331,8 @@ export function createPluginStreamEndpoint(transport, options = {}) {
|
||||
: frame.kind === "cancel"
|
||||
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
|
||||
: null,
|
||||
false,
|
||||
frame.kind === "cancel",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class PluginStreamRouter {
|
||||
streamId: frame.streamId,
|
||||
nextSequence: 1,
|
||||
availableBytes: frame.windowBytes,
|
||||
windowBytes: frame.windowBytes,
|
||||
updateSequence: -1,
|
||||
openController: new AbortController(),
|
||||
closed: false,
|
||||
@@ -139,7 +140,7 @@ class PluginStreamRouter {
|
||||
if (outgoing.availableBytes > outgoing.maxCreditBytes) {
|
||||
throw new Error(`Plugin stream credit exceeds its negotiated window: ${frame.streamId}`);
|
||||
}
|
||||
if (outgoing.terminalKind === "end") {
|
||||
if (outgoing.terminalKind === "end" || outgoing.terminalKind === "cancel") {
|
||||
if (outgoing.availableBytes === outgoing.maxCreditBytes) {
|
||||
this.outgoing.delete(frame.streamId);
|
||||
}
|
||||
@@ -216,7 +217,17 @@ class PluginStreamRouter {
|
||||
? frame.error
|
||||
: new Error(`Plugin stream ${frame.kind}: ${frame.streamId}`);
|
||||
state.openController.abort(abortReason);
|
||||
await state.onClose?.(closeReason);
|
||||
try {
|
||||
if (frame.kind === "cancel" && state.availableBytes < state.windowBytes) {
|
||||
// The producer has stopped. Return discarded credit once so it can
|
||||
// retire its terminal state, without releasing more queued writes.
|
||||
state.updateSequence += 1;
|
||||
this.send({ frame: {
|
||||
streamId: state.streamId, sequence: state.updateSequence,
|
||||
kind: "windowUpdate", creditBytes: state.windowBytes - state.availableBytes,
|
||||
} });
|
||||
}
|
||||
} finally { await state.onClose?.(closeReason); }
|
||||
}
|
||||
|
||||
bindIncoming(streamId, handlers) {
|
||||
@@ -379,7 +390,6 @@ class PluginStreamRouter {
|
||||
state.queuedBytes = 0;
|
||||
if (
|
||||
!terminalSent
|
||||
|| kind !== "end"
|
||||
|| state.availableBytes === state.maxCreditBytes
|
||||
) {
|
||||
this.outgoing.delete(state.streamId);
|
||||
|
||||
@@ -155,6 +155,64 @@ test("ended outgoing streams accept final receive credit before retiring", async
|
||||
assert.equal(router.outgoing.has("ended-output"), false);
|
||||
});
|
||||
|
||||
test("cancelled outgoing streams accept only ordered outstanding credit and release their slot", async () => {
|
||||
const router = new PluginStreamRouter({ send() {}, maxStreams: 1 });
|
||||
const stream = await router.openOutgoing("cancel-in-flight", 1024);
|
||||
await stream.write(new Uint8Array(1024));
|
||||
const queued = stream.write(Uint8Array.of(1));
|
||||
stream.cancel();
|
||||
await assert.rejects(queued, /cancel/u);
|
||||
await assert.rejects(stream.write(Uint8Array.of(1)), /closed/u);
|
||||
const credit = (streamId, sequence, creditBytes) => ({ frame: { streamId, sequence, kind: "windowUpdate", creditBytes } });
|
||||
await assert.rejects(router.accept(credit("unknown", 0, 1)), /Unknown outgoing/u);
|
||||
await assert.rejects(router.accept(credit("cancel-in-flight", 1, 1)), /Out-of-order/u);
|
||||
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 0)), /contract|creditBytes/u);
|
||||
await router.accept(credit("cancel-in-flight", 0, 512));
|
||||
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 1)), /Out-of-order/u);
|
||||
await router.accept(credit("cancel-in-flight", 1, 512));
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
await assert.rejects(router.accept(credit("cancel-in-flight", 2, 1)), /Unknown outgoing/u);
|
||||
const other = await router.openOutgoing("next", 1024);
|
||||
other.cancel();
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("cancelled outgoing streams reject excess credit and clear retained state at runtime close", async () => {
|
||||
const router = new PluginStreamRouter({ send() {} });
|
||||
const stream = await router.openOutgoing("cancel-excess", 1024);
|
||||
await stream.write(Uint8Array.of(1));
|
||||
stream.cancel();
|
||||
await assert.rejects(router.accept({ frame: {
|
||||
streamId: "cancel-excess", sequence: 0, kind: "windowUpdate", creditBytes: 2,
|
||||
} }), /exceeds/u);
|
||||
router.close();
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("failed cancellation credit refund still aborts and cleans up its incoming owner", async () => {
|
||||
let ownerSignal;
|
||||
let closeReason;
|
||||
const router = new PluginStreamRouter({
|
||||
send() { throw new Error("refund transport failed"); },
|
||||
onIncomingStream({ signal, bind }) {
|
||||
ownerSignal = signal;
|
||||
bind({ onChunk() {}, onClose(reason) { closeReason = reason; } });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await router.accept({ frame: { streamId: "cancel-refund", sequence: 0, kind: "open", windowBytes: 1024 } });
|
||||
await router.accept({ frame: {
|
||||
streamId: "cancel-refund", sequence: 1, kind: "chunk",
|
||||
data: { encoding: "base64", value: "AQ==", byteLength: 1 },
|
||||
} });
|
||||
await assert.rejects(router.accept({ frame: {
|
||||
streamId: "cancel-refund", sequence: 2, kind: "cancel",
|
||||
} }), /refund transport failed/u);
|
||||
assert.equal(ownerSignal.aborted, true);
|
||||
assert.equal(closeReason, "cancel");
|
||||
assert.equal(router.incoming.size, 0);
|
||||
});
|
||||
|
||||
test("unhandled incoming streams are cancelled immediately", async () => {
|
||||
const sent = [];
|
||||
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
|
||||
|
||||
@@ -4,23 +4,6 @@ A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
The page follows light/dark mode and accent updates through the existing
|
||||
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
|
||||
Missing tokens fall back to the standard sample palettes. Its theme subscription
|
||||
is disposed on page hide and restored on page show.
|
||||
|
||||
An expired chat is reopened before the next explicit Send. If the configured
|
||||
model changes or a request fails, use **Refresh model** to reopen the session,
|
||||
then click **Send** yourself. Refresh keeps your draft and never retries model
|
||||
inference. If a request failed after reaching the provider, check its outcome
|
||||
before choosing to send it again.
|
||||
|
||||
Selected references expire too. If a selected Note expires or changes, click
|
||||
**Choose reference**, confirm the Note in Netcatty, and select its fresh reference
|
||||
in the dropdown before clicking **Send**. Refresh model does not share a Note
|
||||
again. Re-selection keeps your draft and does not read content or run inference;
|
||||
the new reference still requires its own content permission and text review.
|
||||
|
||||
The host must support `ai.model-invocation` and `context.selected-read`, with the
|
||||
trusted renderer adapter connected. AI invocation, selected-summary enumeration,
|
||||
and selected-content reading have separate optional permissions. The host shows
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
|
||||
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
|
||||
: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:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
.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:hsl(var(--muted-foreground)); margin:4px 0; }
|
||||
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
|
||||
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
|
||||
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:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
|
||||
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; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
|
||||
.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; }
|
||||
#choose-reference { margin-top:10px; }
|
||||
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
|
||||
#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:hsl(var(--muted-foreground)); margin-top:8px; }
|
||||
.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; } }
|
||||
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
|
||||
@@ -11,8 +11,8 @@
|
||||
<main>
|
||||
<header><span class="badge">PLUGIN EXAMPLE</span><h1>Summarize a selected Note</h1><p>Use the model and reference shared by Netcatty.</p></header>
|
||||
<section class="card"><label>Model selected in Netcatty</label><div id="model">Connecting...</div></section>
|
||||
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p><button id="choose-reference" type="button" disabled>Choose reference</button></section>
|
||||
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><button id="refresh" type="button" disabled>Refresh model</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
|
||||
<section class="card"><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>
|
||||
|
||||
@@ -2,124 +2,46 @@
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
// Follow the same seven public semantic tokens as the other browser samples.
|
||||
// Only this page's own DOM is updated; no host settings or DOM access is used.
|
||||
(() => {
|
||||
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
|
||||
const root = document.documentElement;
|
||||
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener('pageshow', subscribe);
|
||||
})();
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
let closed = false;
|
||||
const references = new Map();
|
||||
function busy(value) {
|
||||
element('send').disabled = value || !chat;
|
||||
element('stop').disabled = !value;
|
||||
element('refresh').disabled = value;
|
||||
element('choose-reference').disabled = value;
|
||||
element('context').disabled = value;
|
||||
element('prompt').disabled = value;
|
||||
}
|
||||
async function replaceChat(run) {
|
||||
const previous = chat; chat = undefined;
|
||||
if (previous) await invoke('close', { chatHandle: previous.chatHandle }).catch(() => {});
|
||||
if (closed || run !== generation) return false;
|
||||
const opened = await invoke('open');
|
||||
if (closed || run !== generation) {
|
||||
await invoke('close', { chatHandle: opened.chatHandle }).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
chat = opened;
|
||||
element('model').textContent = chat.model;
|
||||
return true;
|
||||
}
|
||||
async function selectReferences(run) {
|
||||
const summaries = await invoke('contexts');
|
||||
if (closed || run !== generation) return false;
|
||||
references.clear();
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = ''; placeholder.textContent = 'No reference';
|
||||
element('context').replaceChildren(placeholder);
|
||||
for (const reference of summaries) {
|
||||
if (reference.scope !== 'note') continue;
|
||||
references.set(reference.handle, { handle: reference.handle, version: reference.version, expiresAt: reference.expiresAt });
|
||||
const option = document.createElement('option');
|
||||
option.value = reference.handle; option.textContent = reference.title;
|
||||
element('context').append(option);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function initialize() {
|
||||
const run = ++generation;
|
||||
busy(true);
|
||||
try {
|
||||
if (!await replaceChat(run)) return;
|
||||
if (!await selectReferences(run)) return;
|
||||
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 {
|
||||
if (run !== generation) return;
|
||||
const failedChat = chat; chat = undefined;
|
||||
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
|
||||
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
element('status').textContent = 'Choose a model and grant access in Netcatty.';
|
||||
}
|
||||
}
|
||||
element('refresh').addEventListener('click', async () => {
|
||||
if (closed || element('refresh').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Refreshing model...';
|
||||
try {
|
||||
if (await replaceChat(run)) element('status').textContent = 'Ready';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
});
|
||||
element('choose-reference').addEventListener('click', async () => {
|
||||
if (closed || element('choose-reference').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Choosing reference...';
|
||||
try {
|
||||
if (await selectReferences(run)) element('status').textContent = 'References updated. Select a reference, then Send.';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Reference selection ended. Choose reference again when access is available.';
|
||||
} finally { if (run === generation) busy(false); }
|
||||
});
|
||||
element('compose').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!chat || element('send').disabled) return;
|
||||
const reference = references.get(element('context').value);
|
||||
if (reference && reference.expiresAt <= Date.now()) {
|
||||
element('status').textContent = 'Selected reference expired. Choose reference again, then Send.';
|
||||
return;
|
||||
}
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Streaming...';
|
||||
element('response').textContent = ''; element('usage').textContent = '';
|
||||
try {
|
||||
// No inference has been requested yet, so an expired session can be
|
||||
// replaced before this explicit Send. Never retry a failed send/read.
|
||||
if (chat.expiresAt <= Date.now() && !await replaceChat(run)) return;
|
||||
const reference = references.get(element('context').value);
|
||||
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
|
||||
contexts: reference ? [{ handle: reference.handle, version: reference.version }] : [] });
|
||||
contexts: reference ? [reference] : [] });
|
||||
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
|
||||
turn = started.turnHandle;
|
||||
for (;;) {
|
||||
@@ -131,31 +53,29 @@ element('compose').addEventListener('submit', async (event) => {
|
||||
}
|
||||
element('status').textContent = 'Complete';
|
||||
} catch {
|
||||
if (run === generation) element('status').textContent = 'Request ended. Check access, choose reference again, or refresh the model before sending again.';
|
||||
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 () => {
|
||||
if (element('stop').disabled) return;
|
||||
const run = ++generation;
|
||||
++generation;
|
||||
const stoppedTurn = turn; turn = undefined;
|
||||
element('status').textContent = 'Stopping...'; element('stop').disabled = true;
|
||||
element('status').textContent = 'Stopping...';
|
||||
try {
|
||||
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
|
||||
else if (chat) {
|
||||
const stoppedChat = chat; chat = undefined;
|
||||
await invoke('close', { chatHandle: stoppedChat.chatHandle });
|
||||
await invoke('close', { chatHandle: chat.chatHandle });
|
||||
chat = await invoke('open');
|
||||
}
|
||||
} catch { /* Closing a revoked session may already have stopped the turn. */ }
|
||||
finally { if (run === generation) { element('status').textContent = 'Stopped'; busy(false); } }
|
||||
}
|
||||
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', () => {
|
||||
closed = true; ++generation;
|
||||
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
|
||||
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -1,111 +1,40 @@
|
||||
# Run the disposable database example
|
||||
# Read one disposable database row
|
||||
|
||||
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
|
||||
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
|
||||
example does not use saved SSH or AI credentials.
|
||||
This browser plugin tests the generic `network.byte-streams` feature and exact
|
||||
`network.stream` permission. The database protocol and fixed SELECT query live
|
||||
only in this example. The host transports bounded bytes.
|
||||
|
||||
## Prepare the fixture and package
|
||||
|
||||
1. Check out the exact transport PR head and run `npm ci` followed by
|
||||
`npm run build:plugin-runtime-deps`.
|
||||
2. Create a dedicated internal Docker network. Do not alter an existing
|
||||
network if this subnet is already in use.
|
||||
|
||||
```sh
|
||||
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
|
||||
docker run --detach --rm --name netcatty-plugin-db-qa \
|
||||
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
|
||||
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
|
||||
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
|
||||
```
|
||||
|
||||
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
|
||||
to succeed. The container publishes no host ports and has no external route.
|
||||
The empty administrative password exists only in this disposable container.
|
||||
4. Open its local client with
|
||||
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
|
||||
Create the fixture below. Replace the password placeholder with a new
|
||||
synthetic session password. Do not put a real password in this example.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE plugin_demo;
|
||||
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
|
||||
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
|
||||
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<new-synthetic-session-password>';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
```
|
||||
|
||||
5. Build the normal browser package for this exact endpoint:
|
||||
|
||||
```sh
|
||||
node examples/plugins/database-minimal/prepare.mjs \
|
||||
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
|
||||
```
|
||||
|
||||
The tool prints the package hash, not credentials. Its temporary staging
|
||||
directory is removed. Only the manifest, browser bundle, and three view
|
||||
assets enter the five-file package. No Node preparation tool, server setup,
|
||||
dependency tree, or credential file enters the package.
|
||||
|
||||
If a different private subnet is needed, prepare a new package for its exact
|
||||
fixture IP. Record the new package hash and review its endpoint permission.
|
||||
The example page does not accept arbitrary hosts or SQL.
|
||||
|
||||
## Run automated entrypoint acceptance
|
||||
|
||||
From the repository root, run:
|
||||
Prepare a package for one private IPv4 fixture endpoint:
|
||||
|
||||
```sh
|
||||
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
|
||||
npm run build:plugin-runtime-deps
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
|
||||
sha256sum /tmp/database-example.ncpkg
|
||||
```
|
||||
|
||||
The test creates its own isolated server, random session password, and temporary
|
||||
package. It exercises both the transport core and the installed example entry
|
||||
through the registered SDK/RPC path. It removes the container, network, package
|
||||
and profile afterward. The in-process runtime harness does not prove native
|
||||
browser sandbox behavior.
|
||||
The address above is a packaging example. Preparation does not connect to it.
|
||||
Use the actual disposable fixture address for native testing. A different
|
||||
endpoint produces a different manifest, bundle, and package hash.
|
||||
|
||||
## Record native acceptance
|
||||
Import the package through Settings > Plugins, enable it, and open **Disposable
|
||||
database example**. Enter only the fixture's session credentials. Deny the first
|
||||
endpoint prompt, then retry and grant that exact endpoint. A successful read
|
||||
shows `synthetic stream result`. **Cancel** stops the active operation.
|
||||
|
||||
1. Start the exact build with the existing sandbox settings and isolated
|
||||
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
|
||||
is a local import, not a claim of official maintenance.
|
||||
2. Enable the plugin and open **Disposable database example** from its view
|
||||
action. Record the plugin detail screen, source, version, and exact endpoint
|
||||
resource. The plugin has a browser entry only.
|
||||
3. Enter `plugin_demo` and the synthetic session password. The password input
|
||||
is masked and clears before dispatch. Click **Connect and read** and deny
|
||||
the endpoint prompt. The page must show a bounded failure and remain usable.
|
||||
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
|
||||
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
|
||||
Do not record the password or administrative client session.
|
||||
5. Revoke the stream grant in the manager, retry, and deny. Check that no
|
||||
connection remains. Test **Cancel**, close/reopen the view, and disable the
|
||||
plugin during a pending request. Restart with the same QA profile: the
|
||||
plugin must require the session password again.
|
||||
6. Inspect generic connection audit records. Record endpoint, open/close and
|
||||
byte counts; verify that neither the password, SQL nor result body appears.
|
||||
Verify the account's server-side write denial in the disposable server.
|
||||
The fixture must be reachable at a private address belonging to a different
|
||||
machine or container, rather than an address assigned to the app's machine.
|
||||
Loopback, the app machine's interface addresses, and mapped aliases remain
|
||||
blocked. Do not replace the endpoint with a local SSH forwarding address.
|
||||
|
||||
Save screenshots of install/details, denial, endpoint approval and the result,
|
||||
plus a short recording of denial/retry/query/revoke/disable. This recipe does
|
||||
not claim that native QA has run. The automated Node entrypoint harness is not
|
||||
a BrowserWindow sandbox test.
|
||||
The example supports protocol 4.1 and `mysql_native_password` over plain TCP.
|
||||
Its server account needs only SELECT on `plugin_demo.example_result`. The query
|
||||
is `SELECT value FROM plugin_demo.example_result LIMIT 1`. Server grants enforce
|
||||
read-only access. The example does not support database TLS or STARTTLS.
|
||||
|
||||
The example database connection is plaintext only inside the disposable
|
||||
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
|
||||
real-socket certificate and hostname validation tests. Do not use this example
|
||||
with real data or an existing database.
|
||||
Credentials are session-only and are not written to plugin settings, logs, or
|
||||
secrets. JavaScript does not guarantee memory zeroization. The plugin has no
|
||||
query editor, schema browser, or connection manager.
|
||||
|
||||
## Remove the fixture
|
||||
|
||||
Disable and uninstall the example, then remove the temporary container and
|
||||
network:
|
||||
|
||||
```sh
|
||||
docker rm --force netcatty-plugin-db-qa
|
||||
docker network rm netcatty-plugin-db-qa
|
||||
```
|
||||
|
||||
The container, its synthetic credential hashes, and data disappear. Remove the
|
||||
prepared package and isolated QA profile after retaining the required evidence.
|
||||
Follow [the native handoff](../../../docs/plugin-platform/byte-stream-native-qa.md)
|
||||
for fixture prerequisites, screenshots, video, cancellation, and cleanup.
|
||||
@@ -25,8 +25,7 @@ export async function prepareDatabaseExample(targetEndpoint, outputPath) {
|
||||
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
|
||||
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
|
||||
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
|
||||
bundle: true, platform: "browser", format: "esm", minify: true,
|
||||
nodePaths: [path.resolve(root, "../../../node_modules")] });
|
||||
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
|
||||
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
|
||||
await fs.rm(path.join(temporary, "protocol.mjs"));
|
||||
await fs.rm(path.join(temporary, "fixture-config.mjs"));
|
||||
|
||||
@@ -128,7 +128,7 @@ function greeting(bytes) {
|
||||
}
|
||||
|
||||
/** stream is a plugin-owned browser adapter over the generic host byte stream. */
|
||||
export async function queryExample(stream, username, sessionPassword) {
|
||||
async function queryFixedExample(stream, username, sessionPassword, database, query) {
|
||||
if (!/^[A-Za-z0-9_]{1,32}$/u.test(username) || typeof sessionPassword !== "string" || sessionPassword.length > 256) {
|
||||
throw new Error("Enter the disposable fixture username and session password");
|
||||
}
|
||||
@@ -139,10 +139,10 @@ export async function queryExample(stream, username, sessionPassword) {
|
||||
sessionPassword = "";
|
||||
try {
|
||||
await packets.write(concat(uint32(CLIENT_FLAGS), uint32(MAX_PACKET), Uint8Array.of(45), new Uint8Array(23),
|
||||
zeroText(username), Uint8Array.of(token.length), token, zeroText("plugin_demo"), zeroText("mysql_native_password")), 1);
|
||||
zeroText(username), Uint8Array.of(token.length), token, zeroText(database), zeroText("mysql_native_password")), 1);
|
||||
} finally { token.fill(0); nonce.fill(0); }
|
||||
if ((await packets.read(2))[0] !== 0) reject();
|
||||
await packets.write(concat(Uint8Array.of(3), encoder.encode(READ_QUERY)), 0);
|
||||
await packets.write(concat(Uint8Array.of(3), encoder.encode(query)), 0);
|
||||
const count = await packets.read(1);
|
||||
if (count.length !== 1 || count[0] !== 1) reject();
|
||||
const column = new Cursor(await packets.read(2));
|
||||
@@ -161,3 +161,13 @@ export async function queryExample(stream, username, sessionPassword) {
|
||||
await stream.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function queryExample(stream, username, sessionPassword) {
|
||||
return queryFixedExample(stream, username, sessionPassword, "plugin_demo", READ_QUERY);
|
||||
}
|
||||
|
||||
// This second fixed profile is a public third-party acceptance candidate.
|
||||
// No caller can supply a database, query, username, or password to this entry.
|
||||
export function queryRfamReadOnly(stream) {
|
||||
return queryFixedExample(stream, "rfamro", "", "Rfam", "SELECT 1 AS value");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { Buffer } = require("node:buffer");
|
||||
const test = require("node:test");
|
||||
|
||||
function bytes(...parts) { return Buffer.concat(parts.map((part) => Buffer.from(part))); }
|
||||
function text(value) { return bytes(Buffer.from(value), [0]); }
|
||||
function packet(sequence, payload) { return bytes([payload.length & 255, payload.length >>> 8, 0, sequence], payload); }
|
||||
function greeting(plugin = "mysql_native_password") {
|
||||
const flags = 1 | 4 | 8 | 0x200 | 0x2000 | 0x8000 | 0x80000;
|
||||
return bytes([10], text("synthetic-offline-server"), [1, 0, 0, 0], new Uint8Array(8).fill(1),
|
||||
[0, flags & 255, (flags >>> 8) & 255, 45, 0, 0, (flags >>> 16) & 255, (flags >>> 24) & 255, 21],
|
||||
new Uint8Array(10), new Uint8Array(12).fill(2), [0], text(plugin));
|
||||
}
|
||||
function streamFixture(plugin) {
|
||||
const column = bytes(...["def", "synthetic", "", "", "value"].map((value) => bytes([value.length], Buffer.from(value))));
|
||||
const eof = [0xfe, 0, 0, 2, 0];
|
||||
let pending = bytes(packet(0, greeting(plugin)), packet(2, [0]), packet(1, [1]),
|
||||
packet(2, column), packet(3, eof), packet(4, [1, 49]), packet(5, eof));
|
||||
const writes = [];
|
||||
let closes = 0;
|
||||
return {
|
||||
writes, get closes() { return closes; },
|
||||
stream: {
|
||||
async read() { const value = pending; pending = null; return value; },
|
||||
async write(value) { writes.push(Buffer.from(value)); },
|
||||
async close() { closes++; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("owned fixture and public Rfam entries each send only their fixed database and query offline", async () => {
|
||||
const { queryExample, queryRfamReadOnly, READ_QUERY } = await import("./protocol.mjs");
|
||||
for (const profile of [
|
||||
{ run: (stream) => queryExample(stream, "plugin_demo", ""), user: "plugin_demo", database: "plugin_demo", query: READ_QUERY },
|
||||
{ run: queryRfamReadOnly, user: "rfamro", database: "Rfam", query: "SELECT 1 AS value" },
|
||||
]) {
|
||||
const f = streamFixture();
|
||||
assert.deepEqual(await profile.run(f.stream), { columns: ["value"], rows: [["1"]] });
|
||||
assert.equal(f.writes.length, 2);
|
||||
const response = f.writes[0].subarray(4);
|
||||
const expectedTail = bytes(text(profile.user), [0], text(profile.database), text("mysql_native_password"));
|
||||
assert.deepEqual(response.subarray(32), expectedTail);
|
||||
assert.equal(f.writes[1][4], 3);
|
||||
assert.equal(f.writes[1].subarray(5).toString(), profile.query);
|
||||
assert.equal(f.closes, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("public profile rejects unsupported authentication offline without a fallback", async () => {
|
||||
const { queryRfamReadOnly } = await import("./protocol.mjs");
|
||||
const f = streamFixture("caching_sha2_password");
|
||||
await assert.rejects(queryRfamReadOnly(f.stream), /Unsupported or invalid/u);
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.closes, 1);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
# Prepare the public Rfam read-only acceptance sample
|
||||
|
||||
This is a public third-party acceptance candidate, separate from the owned
|
||||
MariaDB fixture. It uses the existing generic byte-stream capability. No host
|
||||
database business, server-specific production API, or endpoint-policy exception
|
||||
is added.
|
||||
|
||||
[Rfam's official database documentation](https://docs.rfam.org/en/latest/database.html)
|
||||
lists `mysql-rfam-public.ebi.ac.uk:4497`, database `Rfam`, public user `rfamro`,
|
||||
and no password. The sample sends only `SELECT 1 AS value`. It does not read Rfam
|
||||
tables and offers no query, host, database, or credential input. The documented
|
||||
public account name is compiled into the example. No private credential is used
|
||||
or persisted, and the sample has no storage or secret permission.
|
||||
|
||||
## Build the candidate without connecting
|
||||
|
||||
Use the integration head and locked dependencies:
|
||||
|
||||
```sh
|
||||
npm run build:plugin-runtime-deps
|
||||
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/database-rfam-readonly.ncpkg
|
||||
sha256sum /tmp/database-rfam-readonly.ncpkg
|
||||
```
|
||||
|
||||
The separate preparation command accepts only that exact endpoint. It performs
|
||||
no DNS lookup, connection, query, plugin installation, or permission activation.
|
||||
It creates five files: a manifest, browser bundle, and three panel assets.
|
||||
The owned fixture's preparation command still rejects public hostnames.
|
||||
|
||||
Package identity and permission scope:
|
||||
|
||||
- ID: `com.netcatty.database-rfam-readonly`, version `0.1.0`.
|
||||
- Runtime: browser only. Required feature: `network.byte-streams`.
|
||||
- Required permissions: `commands` and `views` for its own callbacks and panel.
|
||||
- Optional permission: `network.stream`, resource exactly
|
||||
`tcp://mysql-rfam-public.ebi.ac.uk:4497`.
|
||||
- Five-file package: 11,003 bytes. Two builds matched byte for byte with SHA-256
|
||||
`a9d56b69a4d2c587a4712d1b2fe395c381bcf8b15e34d476c4ba8b5ca967c35a`.
|
||||
- Expected result after a separately authorized compatible run:
|
||||
`{"columns":["value"],"rows":[["1"]]}`.
|
||||
|
||||
## Obtain authorization before native acceptance
|
||||
|
||||
Record the exact integration commit, package hash, ID, version, and permissions.
|
||||
The parent must obtain authorization for that specific package and public
|
||||
endpoint before installation, enabling permissions, or querying the service.
|
||||
No connection or query has been performed as part of preparing this candidate.
|
||||
|
||||
After authorization, use the available native desktop and a disposable profile.
|
||||
Record the local-import source, exact endpoint prompt, denial, retry, fixed
|
||||
result, cancellation, and post-revocation recovery. Opening the panel only reads
|
||||
its endpoint metadata. A connection starts only from **Run fixed public query**.
|
||||
Do not treat this public service as an isolated synthetic fixture or evidence of
|
||||
database TLS or native sandbox certification.
|
||||
|
||||
## Stop if the protocol is unsupported
|
||||
|
||||
The reused example protocol supports only protocol 4.1 and
|
||||
`mysql_native_password`. It uses plain TCP and does not implement MySQL STARTTLS,
|
||||
auth-plugin switching, or other authentication methods. The service's current
|
||||
authentication and TLS requirements remain unverified. An unsupported greeting
|
||||
or server rejection fails closed and closes the byte stream. Record a sanitized
|
||||
failure and stop. Do not add an authentication fallback or TLS bypass for QA.
|
||||
|
||||
Offline protocol tests inspect synthetic packet bytes for the two fixed
|
||||
profiles and reject an unsupported auth plugin. They do not contact Rfam or
|
||||
activate plugin permissions:
|
||||
|
||||
```sh
|
||||
npm run test:plugin-examples
|
||||
```
|
||||
|
||||
The owned MariaDB regression remains at
|
||||
`examples/plugins/database-minimal/protocol.live.test.cjs`. Its explicit Docker
|
||||
opt-in, isolated network, temporary account, SELECT result, and server-side write
|
||||
denial remain separate from this public-service candidate.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
|
||||
import { queryRfamReadOnly } from "../database-minimal/protocol.mjs";
|
||||
|
||||
const endpoint = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
|
||||
export default {
|
||||
activate(context) {
|
||||
let pending;
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.database-rfam-readonly.${suffix}`, handler,
|
||||
));
|
||||
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
|
||||
register("cancel", cancel);
|
||||
register("endpoint", () => ({ endpoint }));
|
||||
context.subscriptions.add({ dispose: cancel });
|
||||
register("query", async (args) => {
|
||||
if (args !== undefined && args !== null) throw new Error("The public sample accepts no query or credential parameters");
|
||||
if (pending) throw new Error("A public sample request is already active");
|
||||
const cancellation = new CancellationTokenSource();
|
||||
pending = cancellation;
|
||||
let connection;
|
||||
try {
|
||||
connection = await context.network.connect({ endpoint }, cancellation.token);
|
||||
return await queryRfamReadOnly({
|
||||
read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
|
||||
close: () => connection.close(),
|
||||
});
|
||||
} finally {
|
||||
await connection?.close().catch(() => {});
|
||||
cancellation.dispose();
|
||||
pending = undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"name": "database-rfam-readonly",
|
||||
"displayName": "Rfam public read-only acceptance sample",
|
||||
"description": "Public third-party sample: one fixed SELECT 1 through an exact approved byte-stream endpoint. Compatibility is unverified.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["network.byte-streams"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onCommand:com.netcatty.database-rfam-readonly.query", "onView:com.netcatty.database-rfam-readonly.panel"],
|
||||
"permissions": {
|
||||
"required": ["commands", "views"],
|
||||
"optional": [{ "permission": "network.stream", "resources": ["tcp://mysql-rfam-public.ebi.ac.uk:4497"], "reason": "Run the fixed public Rfam acceptance query only after explicit authorization." }]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.database-rfam-readonly.query", "title": "Run public Rfam SELECT 1" },
|
||||
{ "id": "com.netcatty.database-rfam-readonly.cancel", "title": "Cancel public Rfam sample" },
|
||||
{ "id": "com.netcatty.database-rfam-readonly.endpoint", "title": "Show public Rfam endpoint" }
|
||||
],
|
||||
"views": [{ "id": "com.netcatty.database-rfam-readonly.panel", "title": "Rfam public read-only sample", "location": "modal", "entry": "panel.html" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark light;font:14px system-ui,sans-serif;color:hsl(var(--foreground,210 20% 90%));background:hsl(var(--background,220 15% 12%))}*{box-sizing:border-box}body{margin:0}main{max-width:640px;padding:24px;margin:auto}h1{font-size:24px}.eyebrow{font-size:11px;letter-spacing:.1em;opacity:.7}p{line-height:1.5}#endpoint,pre{overflow-wrap:anywhere;white-space:pre-wrap}label{display:block;margin:16px 0 6px}input{width:100%;padding:10px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;background:hsl(var(--background,220 15% 12%));color:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}button{padding:10px 16px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;color:inherit;background:hsl(var(--secondary,220 12% 22%));cursor:pointer}button:disabled{opacity:.5;cursor:default}input:focus-visible,button:focus-visible{outline:2px solid hsl(var(--primary,210 90% 60%));outline-offset:2px}pre{padding:14px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;min-height:80px}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rfam public read-only sample</title><link rel="stylesheet" href="panel.css"></head>
|
||||
<body><main><p class="eyebrow">PUBLIC THIRD-PARTY ACCEPTANCE SAMPLE</p><h1>Rfam: SELECT 1</h1>
|
||||
<p>This contacts Rfam's public read-only MySQL service. It is not an isolated fixture. The fixed query is SELECT 1 AS value in Rfam, using the documented public account with no password.</p>
|
||||
<p>This sample uses plain TCP. Authentication and TLS compatibility remain unverified. Run only after the package and exact endpoint are authorized.</p>
|
||||
<p id="endpoint">Loading exact endpoint…</p>
|
||||
<div class="actions"><button id="query" type="button">Run fixed public query</button><button id="cancel" type="button" disabled>Cancel</button></div>
|
||||
<pre id="result" role="status" aria-live="polite">Prepared only. No connection starts until you click Run.</pre>
|
||||
</main><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,28 @@
|
||||
/* global window, document */
|
||||
"use strict";
|
||||
const query = document.getElementById("query");
|
||||
const cancel = document.getElementById("cancel");
|
||||
const result = document.getElementById("result");
|
||||
const command = (suffix) => window.netcattyView.executeCommand(`com.netcatty.database-rfam-readonly.${suffix}`);
|
||||
window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
document.documentElement.style.colorScheme = environment.theme === "light" ? "light" : "dark";
|
||||
for (const name of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === "string" && value.length <= 96) document.documentElement.style.setProperty(name, value);
|
||||
else document.documentElement.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
void command("endpoint").then((value) => { document.getElementById("endpoint").textContent = value.endpoint; });
|
||||
query.addEventListener("click", async () => {
|
||||
query.disabled = true;
|
||||
cancel.disabled = false;
|
||||
result.textContent = "Requesting the exact public endpoint…";
|
||||
try { result.textContent = JSON.stringify(await command("query"), null, 2); }
|
||||
catch { result.textContent = "Public sample failed. Authentication, TLS requirements, and service reachability may be unsupported. No fallback is attempted."; }
|
||||
finally { query.disabled = false; cancel.disabled = true; }
|
||||
});
|
||||
cancel.addEventListener("click", async () => {
|
||||
cancel.disabled = true;
|
||||
try { await command("cancel"); result.textContent = "Public sample cancelled."; }
|
||||
catch { result.textContent = "Could not cancel the public sample."; }
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildPluginPackage } from "@netcatty/plugin-cli";
|
||||
import { build } from "esbuild";
|
||||
|
||||
export const RFAM_ENDPOINT = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function prepareRfamExample(targetEndpoint, outputPath) {
|
||||
// This separate sample accepts one documented hostname. It does not change
|
||||
// the private-fixture preparer, host endpoint policy, or runtime permissions.
|
||||
if (targetEndpoint !== RFAM_ENDPOINT) throw new Error("Use only the documented exact Rfam sample endpoint");
|
||||
const temporary = await fs.mkdtemp(path.join(path.dirname(outputPath), ".rfam-example-"));
|
||||
try {
|
||||
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js"]) {
|
||||
await fs.copyFile(path.join(root, file), path.join(temporary, file));
|
||||
}
|
||||
const bundle = await build({ entryPoints: [path.join(root, "browser.js")], write: false,
|
||||
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
|
||||
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
|
||||
return await buildPluginPackage(temporary, outputPath);
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const [endpoint, outputPath] = process.argv.slice(2);
|
||||
if (!endpoint || !outputPath) throw new Error("Usage: node prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 <output.ncpkg>");
|
||||
process.stdout.write(`${JSON.stringify(await prepareRfamExample(endpoint, path.resolve(outputPath)))}\n`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
test("public preparation accepts only its exact documented endpoint and private preparation stays private", async () => {
|
||||
const { prepareRfamExample } = await import("./prepare.mjs");
|
||||
const { prepareDatabaseExample } = await import("../database-minimal/prepare.mjs");
|
||||
for (const endpoint of ["tcp://example.org:4497", "tcp://mysql-rfam-public.ebi.ac.uk:3306",
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497/", "tls://mysql-rfam-public.ebi.ac.uk:4497",
|
||||
"tcp://127.0.0.1:4497", "tcp://10.0.0.9:4497"]) {
|
||||
await assert.rejects(prepareRfamExample(endpoint, "/unused/output.ncpkg"), /exact Rfam/u);
|
||||
}
|
||||
await assert.rejects(prepareDatabaseExample("tcp://mysql-rfam-public.ebi.ac.uk:4497", "/unused/output.ncpkg"), /private IPv4/u);
|
||||
});
|
||||
@@ -1,16 +1,11 @@
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import { netcattyBridge } from '../../services/netcattyBridge';
|
||||
|
||||
// Host renderer only. Read the existing configured provider/model without
|
||||
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
|
||||
export function getConfiguredPluginModel() {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
return selectedModel(providers);
|
||||
}
|
||||
|
||||
function selectedModel(providers: ProviderConfig[]) {
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
|
||||
@@ -19,15 +14,3 @@ function selectedModel(providers: ProviderConfig[]) {
|
||||
if (!config.defaultModel) return null;
|
||||
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
|
||||
}
|
||||
|
||||
// The existing main provider transport needs its host-owned registry for both
|
||||
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
|
||||
// or replace the registry with a plugin-supplied URL / just one selected entry.
|
||||
export async function prepareConfiguredPluginModel(revision: string) {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
|
||||
throw Error('Host provider configuration is unavailable');
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
|
||||
const contexts = new SelectedContextAuthority({ resolveDataOwner });
|
||||
const note = await contexts.share({ scope: 'note', version: '1', title: 'Synthetic Note', isCurrent: () => true,
|
||||
readText: () => 'Synthetic retry plan: add retry button.' }, context);
|
||||
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: () => 'synthetic-source-owner', contexts, selectModel: () => model });
|
||||
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.',
|
||||
|
||||
@@ -7,7 +7,7 @@ const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerI
|
||||
|
||||
test('renderer-owned model handles never serialize provider config and cannot cross runtimes', async () => {
|
||||
let active = true;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
|
||||
const 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'));
|
||||
@@ -36,7 +36,7 @@ test('selected metadata uses explicit safe projections and current resource iden
|
||||
|
||||
test('selected summaries never read content; late resource changes and runtime release deny content', async () => {
|
||||
let current = true; let reads = 0; let resolve: (text: string) => void;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, isActive: () => true,
|
||||
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 }> };
|
||||
@@ -46,22 +46,3 @@ test('selected summaries never read content; late resource changes and runtime r
|
||||
await new Promise(setImmediate); current = false; resolve!('Late synthetic text'); await assert.rejects(reading);
|
||||
runtime.releaseRuntime('runtime-a'); await assert.rejects(runtime.handle('context.current', reference, 'runtime-a')); runtime.shutdown();
|
||||
});
|
||||
|
||||
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
|
||||
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
|
||||
let resume!: () => void; let current = syntheticModel;
|
||||
const waiting = new Promise<void>((resolve) => { resume = resolve; });
|
||||
const controller = new AbortController();
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
|
||||
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
|
||||
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
|
||||
const rejection = assert.rejects(selecting);
|
||||
if (action === 'release') runtime.releaseRuntime('runtime-a');
|
||||
if (action === 'cancel') controller.abort();
|
||||
if (action === 'change') current = { ...syntheticModel, revision: '2' };
|
||||
if (action === 'shutdown') runtime.shutdown();
|
||||
resume(); await rejection;
|
||||
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
|
||||
runtime.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -17,12 +17,10 @@ type Owned<T> = T & { runtimeId: string };
|
||||
|
||||
export function createTrustedPluginAIHostRuntime(options: {
|
||||
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
|
||||
prepareModel: (revision: string) => Promise<void>;
|
||||
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
|
||||
isActive: () => boolean;
|
||||
}) {
|
||||
const models = new Map<string, Owned<{ model: Model }>>();
|
||||
const selections = new Set<{ runtimeId: string; active: boolean }>();
|
||||
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
|
||||
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
|
||||
let closed = false;
|
||||
@@ -39,7 +37,6 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
void turn.iterator.return(undefined).catch(() => {});
|
||||
};
|
||||
const releaseRuntime = (runtimeId: string) => {
|
||||
for (const selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
|
||||
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
|
||||
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
|
||||
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
|
||||
@@ -53,19 +50,12 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
if (kind === 'model.select') {
|
||||
const selected = options.getModel();
|
||||
if (!selected || !selected.config.enabled || !selected.config.defaultModel) throw Error('Choose a configured model first');
|
||||
if (models.size + selections.size >= 32) throw Error('Model handle limit reached');
|
||||
const selection = { runtimeId, active: true };
|
||||
selections.add(selection);
|
||||
try {
|
||||
await options.prepareModel(selected.revision);
|
||||
signal.throwIfAborted(); assertActive();
|
||||
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
} finally { selections.delete(selection); }
|
||||
if (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() };
|
||||
@@ -148,6 +138,6 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
throw Error('Unsupported host operation');
|
||||
},
|
||||
releaseRuntime,
|
||||
shutdown() { closed = true; for (const selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
shutdown() { closed = true; for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const root = path.resolve(__dirname, "../examples/plugins/assistant");
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
|
||||
t.after(() => dom.window.close());
|
||||
const stylesheet = dom.window.document.createElement("style");
|
||||
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
|
||||
dom.window.document.head.append(stylesheet);
|
||||
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
|
||||
const calls = [];
|
||||
const environmentListeners = new Set(); let environment;
|
||||
const element = (id) => dom.window.document.getElementById(id);
|
||||
dom.window.Date.now = options.clock ?? (() => now);
|
||||
Object.defineProperty(dom.window, "netcattyView", { value: {
|
||||
onDidReceiveMessage: () => () => {},
|
||||
onDidChangeEnvironment: (listener) => {
|
||||
environmentListeners.add(listener);
|
||||
if (environment) {
|
||||
const replay = environment;
|
||||
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
|
||||
}
|
||||
return { dispose: () => environmentListeners.delete(listener) };
|
||||
},
|
||||
executeCommand: async (id, params) => {
|
||||
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
|
||||
if (options.commands) return options.commands(operation, structuredClone(params));
|
||||
if (operation === "open") {
|
||||
const count = ++opens;
|
||||
if (count > 1 && options.opening) await options.opening;
|
||||
return { chatHandle: `chat-${count}`, model: `Synthetic model ${count}`, expiresAt: now + 300_000 };
|
||||
}
|
||||
if (operation === "contexts") return [];
|
||||
if (operation === "close" || operation === "stop") return { ok: true };
|
||||
if (operation === "send") {
|
||||
if (params.chatHandle === "chat-1" && (changed || now >= 300_000)) throw Error("Session unavailable");
|
||||
modelCalls++;
|
||||
if (options.uncertain) throw Error("Reply lost after invocation");
|
||||
return { turnHandle: "synthetic-turn" };
|
||||
}
|
||||
if (operation === "read") return { kind: "end", sequence: 0 };
|
||||
throw Error(`Unexpected operation ${operation}`);
|
||||
},
|
||||
} });
|
||||
const waitFor = async (condition) => {
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error(`Panel state timed out: ${element("status").textContent}`);
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
};
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
|
||||
await waitFor(() => element("status").textContent === "Ready");
|
||||
element("prompt").value = "Synthetic draft must survive session recovery";
|
||||
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
|
||||
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
|
||||
environmentListeners: () => environmentListeners.size,
|
||||
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
|
||||
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
|
||||
}
|
||||
|
||||
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
|
||||
const f = await fixture(t); const root = f.window.document.documentElement;
|
||||
const computed = () => f.window.getComputedStyle(root);
|
||||
const calls = f.calls.length;
|
||||
assert.equal(computed().colorScheme, "light dark");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
assert.equal(f.environmentListeners(), 1);
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
|
||||
assert.equal(root.style.getPropertyValue("--private-token"), "");
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
|
||||
assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
|
||||
assert.equal(root.style.getPropertyValue("--muted"), "");
|
||||
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
|
||||
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
|
||||
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
|
||||
assert.equal(root.style.colorScheme, "light dark");
|
||||
f.window.dispatchEvent(new f.window.Event("pageshow"));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
|
||||
});
|
||||
|
||||
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
|
||||
const f = await fixture(t); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.opens(), 2);
|
||||
assert.deepEqual(f.calls.filter((call) => call.operation === "send").map((call) => call.params.chatHandle), ["chat-2"]);
|
||||
assert.equal(f.modelCalls(), 1);
|
||||
assert.equal(f.element("status").textContent, "Complete");
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("changed-model rejection offers explicit reopen without automatically resending", async (t) => {
|
||||
const f = await fixture(t); f.changeModel(); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.opens(), 1); assert.equal(f.modelCalls(), 0);
|
||||
assert.ok(f.element("refresh"), "A changed model needs an explicit session refresh control");
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 0);
|
||||
assert.match(f.element("model").textContent, /model 2/);
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(f.modelCalls(), 1);
|
||||
});
|
||||
|
||||
test("uncertain inference outcome is never automatically retried by session refresh", async (t) => {
|
||||
const f = await fixture(t, { uncertain: true }); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.equal(f.modelCalls(), 1); assert.equal(f.opens(), 1);
|
||||
assert.ok(f.element("refresh"), "Recovery must reopen only, never retry inference");
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 1);
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("Stop during expired-session reopening discards the late session without sending", async (t) => {
|
||||
let resume; const opening = new Promise((resolve) => { resume = resolve; });
|
||||
t.after(() => resume());
|
||||
const f = await fixture(t, { opening }); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => f.opens() === 2);
|
||||
f.element("stop").click(); resume();
|
||||
await f.waitFor(() => f.calls.some((call) => call.operation === "close" && call.params.chatHandle === "chat-2"));
|
||||
assert.equal(f.modelCalls(), 0); assert.equal(f.element("status").textContent, "Stopped");
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
|
||||
test("expired real selected Note requires explicit re-selection, then fresh content permission and review", async (t) => {
|
||||
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
|
||||
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
|
||||
let contentAllowed = false; let reviewAllowed = false;
|
||||
const grants = []; const reviews = [];
|
||||
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
|
||||
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
|
||||
const service = new PluginAIContextService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ source, enabled: true, activeVersion: caller.pluginVersion, runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: () => source, privateOwner: () => "synthetic-private-owner", publicPluginId: () => caller.pluginId,
|
||||
},
|
||||
getRuntimeIdentity: () => ({ ...caller, runtimeKind: "browser" }),
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: async (_caller, grant) => {
|
||||
grants.push(grant); if (!contentAllowed) throw Error("Synthetic content grant denied");
|
||||
} },
|
||||
hostAdapter: {
|
||||
assertActive: async () => {},
|
||||
selectModel: async () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
|
||||
modelCalls++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; })();
|
||||
} }),
|
||||
listSelected: async () => {
|
||||
selections++;
|
||||
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
|
||||
readText: () => { reads++; return "Synthetic selected Note body"; } }];
|
||||
},
|
||||
reviewText: async (text) => { reviews.push(text); return reviewAllowed; },
|
||||
},
|
||||
});
|
||||
service.selected.clock = () => now; service.broker.clock = () => now;
|
||||
t.after(() => service.shutdown());
|
||||
const commands = {
|
||||
open: () => service.open({}, caller), contexts: async () => (await service.list({}, caller)).items,
|
||||
send: (params) => service.start(params, caller), read: (params) => service.read(params, caller),
|
||||
stop: (params) => service.stop(params, caller), close: (params) => service.close(params, caller),
|
||||
};
|
||||
const f = await fixture(t, { clock: () => now, commands: (operation, params) => commands[operation](params) });
|
||||
const selector = f.element("context"); const oldHandle = selector.options[1].value;
|
||||
selector.value = oldHandle;
|
||||
now = 300_001;
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.equal(f.calls.filter((call) => call.operation === "send").length, 0);
|
||||
assert.equal(selections, 1); assert.equal(modelCalls, 0); assert.equal(reads, 0);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
|
||||
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
|
||||
assert.equal(selections, 1); // Refresh model must not silently share the Note again.
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.ok(f.element("choose-reference"));
|
||||
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
|
||||
assert.equal(selections, 2); assert.equal(selector.value, "");
|
||||
const newHandle = selector.options[1].value;
|
||||
assert.notEqual(newHandle, oldHandle);
|
||||
const [fresh] = await service.selected.summaries(caller);
|
||||
assert.equal(fresh.handle, newHandle); assert.equal(fresh.expiresAt, 600_001);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
|
||||
assert.equal(reads, 0); assert.equal(grants.length, 0); assert.equal(modelCalls, 0);
|
||||
selector.value = newHandle;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
|
||||
contentAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
|
||||
assert.equal(reviews.length, 1); assert.equal(reviews[0].text, "Synthetic selected Note body"); assert.equal(modelCalls, 0);
|
||||
reviewAllowed = true;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(modelCalls, 1); assert.equal(reviews.length, 2);
|
||||
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
|
||||
&& grant.resources[0] === `selected:${newHandle}:v1`));
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
// Repeat with both lifetimes expired, without manually refreshing the model.
|
||||
now = 600_002;
|
||||
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
|
||||
assert.match(f.element("status").textContent, /reference expired/i);
|
||||
assert.equal(modelCalls, 1); assert.equal(selections, 2);
|
||||
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
|
||||
const repeatedHandle = selector.options[1].value;
|
||||
assert.notEqual(repeatedHandle, newHandle); assert.equal(selector.value, "");
|
||||
selector.value = repeatedHandle;
|
||||
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
|
||||
assert.equal(modelCalls, 2); assert.equal(grants.at(-1).resources[0], `selected:${repeatedHandle}:v1`);
|
||||
await assert.rejects(service.selected.assertCurrent({ handle: newHandle, version: "v1" }, caller));
|
||||
assert.match(f.element("prompt").value, /must survive/);
|
||||
});
|
||||
Reference in new issue
Block a user