Compare commits

..
44 changed files with 911 additions and 955 deletions

No files matched your search

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