Compare commits

...
63 changed files with 4863 additions and 18 deletions

No files matched your search

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