Compare commits

..
27 changed files with 1391 additions and 1064 deletions

No files matched your search

@@ -1,58 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { compile } from '@tailwindcss/node';
import postcss from 'postcss';
import HostDetailsPanel from './HostDetailsPanel';
import { Card } from './ui/card';
import { TooltipProvider } from './ui/tooltip';
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
import { I18nProvider } from '../application/i18n/I18nProvider';
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
<Card id="ordinary-card">Outside the security surface</Card>
</TooltipProvider></I18nProvider>);
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
try {
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
const css = compiler.build(classes);
const parsed = postcss.parse(css);
const rules: postcss.Rule[] = [];
parsed.walkRules(rule => { rules.push(rule); });
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
assert.ok(rule && rule.type === 'rule');
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
assert.ok(matches.includes(panel));
assert.ok(matches.includes(permissions));
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
const declaration = declarations.get(`--color-${name}`);
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
assert.equal(declaration.important, true);
}
for (const name of ['card','background','primary']) {
const utility = rules.find(node => node.selector === `.bg-${name}`);
assert.ok(utility && utility.type === 'rule');
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
}
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
} finally { dom.window.close(); }
});
+74 -46
View File
@@ -1,56 +1,84 @@
# Official registry client
# Official static distribution client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
Community starts at the fixed main-process endpoint
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
endpoint. All automatic catalog, README and potential package reads allow only
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
source links may still open GitHub in an external browser.
## Catalog contract
## Snapshot contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
SHA-256. The host checks the pointer before requesting its three documents,
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
identity. The three documents must all validate before replacing any cache.
## Selection and installation
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
plugin: this is catalog admission, not a remote installed-code kill switch.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
fixtures described there. All schema references resolve locally; schema validation
does not fetch GitHub or netcatty.com.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Bounds, cache and selection
## Verification boundary
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
entries. Existing manifest identity, license and browser-only policies remain.
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
The host prepares private entries, private delivery resources and all public
selection tokens before one synchronous cache publication. Parse, integrity,
schema, clone, tuple or network failures preserve the last complete good snapshot.
The cache remains **in memory for this app session**; a fresh offline launch lists
bundled examples. No new disk database, sync record or credential is introduced.
A host-generated selection token binds the complete entry and its delivery item.
This prevents silent selection changes; it is **not a signature**. README requests
capture the matched static reference and verify its exact byte count and digest.
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
README HTML and remote images remain inert in the existing safe renderer.
The current v1 distribution cannot install packages. A later publishing/client
schema revision must jointly introduce reviewed package mirroring, keep source
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
ledger before any install. A historical snapshot alone cannot establish availability.
The existing lower-level package downloader accepts only official static URLs and
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
## Network inventory
| Path | Automatic source | Failure behavior |
| --- | --- | --- |
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
The real PR3 bootstrap is retained as a test fixture with its original bytes and
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
remote icon renderer. The static publisher is not a proxy. Client tests do not
prove deployment, mainland reachability or successful native CDN access. Native
QA must independently verify the published endpoint and visible failure state.
-24
View File
@@ -133,27 +133,3 @@ This is source identity and a source-based ordering diagnosis, not a claim that
baseline runtime failure was reproduced locally. The updated test needs the new
exact-head CI result. That same first run passed the added Vite browser-import
smoke, resolving the earlier local temp-directory limitation for that head.
### Native security-color delta
Parent native acceptance at f868 verified zero-permission installation/enabling,
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
default, restart persistence, and Disable fallback with the unavailable notice.
The isolated schema-3 profile was not touched. These core results remain valid.
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
grant-panel text inside an otherwise fixed security container. The cause was
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
boundary does not recompute aliases already resolved on an ancestor. The same
existing security rule now fixes both raw tokens and those color aliases. The
selector and protected regions are unchanged; ordinary application cards remain
theme-controlled. No host, credential or grant operation changed.
The regression renders the actual HostDetailsPanel (including General, Address,
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
compiles the production Tailwind source, verifies which aliases those utilities
consume, checks the fixed declarations, and verifies an ordinary card is outside
the security selector. This is component/compiled-CSS coverage, not a claim of
native computed-color acceptance. The parent's native color delta remains the
final visual check. A local Chromium probe aborted because its SUID sandbox was
not configured; sandboxing was not disabled to retry.
@@ -1,263 +0,0 @@
"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 };
@@ -1,211 +0,0 @@
"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();
});
+45 -49
View File
@@ -12,23 +12,26 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value) {
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
if (url.origin !== DISTRIBUTION_ORIGIN || url.username || url.password || url.port
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
return url.href;
}
function pinnedReadmeUrl(entry) {
function assertReadmeSource(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
@@ -43,51 +46,49 @@ function validateCatalog(entries) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
assertReadmeSource(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
for (const field of ["artifact"]) {
if (!Object.hasOwn(entry, field)) continue;
const asset = entry[field];
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
throw new Error(`Invalid catalog ${field} metadata`);
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
assertDownloadUrl(asset.url);
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
}
}
return entries;
}
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
async function fetchBounded(url, limit, fetchImpl = fetch) {
const signal = AbortSignal.timeout(30_000);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
url = new URL(response.headers.get("location"), url).href;
continue;
}
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
throw new Error("Catalog redirects are not allowed");
}
throw new Error("Too many catalog redirects");
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
}
class PluginCatalogService {
constructor(options) {
@@ -103,11 +104,6 @@ class PluginCatalogService {
if (!entry) throw new Error("Plugin is not in the curated catalog");
return entry;
}
async readme(id) {
const entry = this.get(id);
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
@@ -127,4 +123,4 @@ class PluginCatalogService {
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
+4 -12
View File
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
const requested = [];
const service = new PluginCatalogService({ entries, fetch: async (url) => {
requested.push(url); return new Response("# First-party example");
} });
assert.equal(await service.readme(entries[0].id), "# First-party example");
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
await assert.rejects(service.readme(entries[0].id), /redirects/);
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
test("static downloader is bounded and never follows redirects", async () => {
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
});
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
let installs = 0;
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
assert.equal(name, "plugin-catalog-");
@@ -1,165 +0,0 @@
"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 };
@@ -1,87 +0,0 @@
"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]);
});
+16
View File
@@ -0,0 +1,16 @@
# Pinned static distribution schemas
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
(PR 3). The host compiles them with the existing local plugin-contract schema;
no schema reference causes network access.
`../fixtures/static-distribution/` contains that commit's actual
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
four referenced README/PNG assets. Raw-byte SHA-256 checks in
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
hashes; every document and asset is checked against the pointer/sidecar.
The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
@@ -0,0 +1,166 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v1 (source-only bootstrap)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 1
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"type": "null"
},
"artifact": {
"type": "null"
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
@@ -0,0 +1,194 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"plugins"
],
"properties": {
"schemaVersion": {
"const": 1
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"current",
"versions"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"current": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
{
"type": "null"
}
]
},
"versions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"entry",
"distribution",
"withdrawal"
],
"properties": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"name",
"description",
"author",
"repository",
"commit",
"license",
"readme",
"manifest"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"repository": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"commit": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-f0-9]{40}$"
},
"license": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"readme": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"manifest": {
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"url",
"sha256"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
},
"distribution": {
"enum": [
"source-only",
"release"
]
},
"tag": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"withdrawal": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"at",
"reason",
"advisory"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"advisory": {
"type": "string",
"pattern": "^https://github.com/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
"use strict";
const { createHash } = require("node:crypto");
const { isDeepStrictEqual } = require("node:util");
const Ajv2020 = require("ajv/dist/2020").default;
const addFormats = require("ajv-formats").default;
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { formatValidationErrors } = require("./contractValidator.cjs");
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
const SHA256 = /^[a-f0-9]{64}$/u;
const COMMIT = /^[a-f0-9]{40}$/u;
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
addFormats(ajv);
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const validateDelivery = ajv.compile(require("./distribution/delivery.schema.json"));
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
function exactKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function assertReference(ref, path, limit) {
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
throw new Error("Invalid static snapshot resource reference");
}
}
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
const value = JSON.parse(bytes.toString("utf8"));
assertPluginJsonValue(value, { maxBytes: limit });
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
return value;
}
function assertPointer(pointer) {
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
throw new Error("Invalid static snapshot pointer");
}
for (const name of ["catalog", "index", "delivery"]) {
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
}
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
}
function assertAsset(ref, extensions) {
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
throw new Error("Invalid static asset path or format");
}
assertReference(ref, ref.path, 256 * 1024);
}
async function fetchVerified(ref, fetchImpl) {
const bytes = await fetchBounded(`${DISTRIBUTION_ORIGIN}${ref.path}`, ref.size, fetchImpl);
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
return bytes;
}
function validateSet(pointer, entries, index, delivery) {
validateCatalog(entries);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
if (!validateDelivery(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validateDelivery.errors)}`);
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
// v1 publishing deliberately refuses packages and avatars. Do not extend this
// consumer ahead of the reviewed publishing schema or invent installable code.
if (entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
const current = new Map();
const indexIds = new Set();
for (const plugin of index.plugins) {
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
indexIds.add(plugin.id);
const versions = new Map();
for (const version of plugin.versions) {
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
versions.set(version.entry.version, version);
}
if (plugin.current !== null) {
const version = versions.get(plugin.current);
if (!version || version.withdrawal !== null || version.distribution !== "source-only") throw new Error("Current distribution version is missing or withdrawn");
current.set(plugin.id, version.entry);
}
}
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
throw new Error("Catalog does not match the current non-withdrawn index");
}
const resources = new Map();
const catalogTuples = new Set(entries.map(tuple));
for (const plugin of delivery.plugins) {
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl) {
const pointer = parseJson(await fetchBounded(REGISTRY_URL, 16 * 1024, fetchImpl), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery);
return { entries, resources, pointer };
}
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
@@ -0,0 +1,53 @@
# Hello Netcatty
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
command-palette contributions, lazy command and Provider activation,
localization, terminal completion/decoration Providers, and the runtime SDK.
From the repository root:
```bash
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
**Examples: Say Hello** from the command palette. The setting is read through
the host settings broker and the command handler is registered inside the
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch the normal browser-only plugin build using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
Enabling this lazy example does not grant permissions: required permissions are
requested before its first command/provider activation, and optional permissions
when used. Never treat Enabled as a security certification or grant.
@@ -0,0 +1,37 @@
# Plugin Starter
A minimal first-party browser plugin for learning import, settings and lifecycle.
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
network, files, secrets or advanced Node access. It works without OS key storage.
Permission declarations are not grants; follow the app's normal mediation.
From the repository root:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Start the actual app normally with a fresh test profile; no development flag is
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
The panel inserts text using `textContent`, never HTML. It does not write to
a terminal or connect to a host. Denied permissions remain denied.
Disable, restart and re-enable to verify the preference persists. Re-importing
the same package leaves it disabled for review. Uninstall removes code and local
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
The separate Hello Netcatty example demonstrates terminal providers and requests
terminal permissions. Use this Starter for the default no-sensitive-access demo.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
@@ -0,0 +1,195 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"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": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
}
]
@@ -0,0 +1,48 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"plugins": [
{
"artifact": null,
"avatar": null,
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"iconDark": {
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
"size": 873
},
"iconLight": {
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"size": 873
},
"id": "com.netcatty.starter",
"readme": {
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
"size": 2050
},
"repository": "binaricat/Netcatty",
"version": "0.1.2"
},
{
"artifact": null,
"avatar": null,
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"iconDark": null,
"iconLight": null,
"id": "com.netcatty.hello",
"readme": {
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
"size": 2951
},
"repository": "binaricat/Netcatty",
"version": "0.1.0"
}
],
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
}
@@ -0,0 +1,218 @@
{
"schemaVersion": 1,
"plugins": [
{
"id": "com.netcatty.starter",
"current": "0.1.2",
"versions": [
{
"entry": {
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"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": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
},
{
"id": "com.netcatty.hello",
"current": "0.1.0",
"versions": [
{
"entry": {
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
}
]
}
@@ -0,0 +1,20 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
}
@@ -0,0 +1,40 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
const root = path.join(__dirname, "static-distribution");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const read = (name) => fs.readFileSync(path.join(root, name));
const catalog = JSON.parse(read("catalog.json"));
const delivery = JSON.parse(read("delivery.json"));
function realFiles() {
const pointer = JSON.parse(read("snapshot.json"));
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
return files;
}
function makeFiles(entries = catalog, mutate = () => {}) {
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
mutate(index, sidecar);
const catalogBytes = Buffer.from(JSON.stringify(entries));
const indexBytes = Buffer.from(JSON.stringify(index));
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
const snapshotId = digest(deliveryBytes);
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
const files = realFiles();
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
pointer[name] = ref; files.set(ref.path, bytes);
}
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
return files;
}
function responseFor(files, url) {
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
const bytes = files.get(new URL(url).pathname);
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
}
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
@@ -1,4 +0,0 @@
"use strict";
// Include the isolated domain suites in npm run test:plugin-runtime.
require("./ai/modelInvocationBroker.test.cjs");
require("./context/selectedContextAuthority.test.cjs");
+14 -11
View File
@@ -1,20 +1,21 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value) {
function prepareSnapshot(value, resources = new Map()) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
return { entries, publicEntries };
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([entry, resources.get(tuple(entry)) ?? null]) }));
return { entries, publicEntries, resources: structuredClone(resources) };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.resources = new Map();
this.source = "builtin";
this.error = null;
this.refreshing = null;
@@ -24,11 +25,12 @@ class RegistryCatalogService extends PluginCatalogService {
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
const distribution = await fetchDistribution(this.fetch);
const next = prepareSnapshot(distribution.entries, distribution.resources);
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.resources = next.resources;
this.source = "registry";
this.error = null;
return true;
@@ -50,15 +52,16 @@ class RegistryCatalogService extends PluginCatalogService {
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== tokenFor(entry)) {
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
async readme(id, token) {
this.selected(id, token);
// Base method captures the pinned entry synchronously before its first await.
return super.readme(id);
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch)).toString("utf8");
}
async install(id, token) {
this.selected(id, token);
+54 -114
View File
@@ -1,153 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
const builtin = require("./catalog.json");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
let response = () => new Response(JSON.stringify([builtin[0]]));
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
let files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(url, REGISTRY_URL);
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return response();
return responseFor(files, url);
} });
const online = await service.list();
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
response = () => new Response(invalid);
const good = await service.list();
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, online.entries);
assert.deepEqual(cached.entries, good.entries);
}
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
assert.match((await service.list()).error, /size limit/);
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
assert.match((await service.list()).error, /redirects/);
response = () => new Response("[]");
const empty = await service.list();
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
});
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
let calls = 0;
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
assert.match(snapshot.error, /offline/);
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
assert.equal(calls, 1);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
let entries = structuredClone(builtin);
let requests = 0;
const service = new RegistryCatalogService({ fetch: async (url) => {
requests++;
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
return new Response("# Pinned README");
}, manager: { install() { assert.fail("changed selection must not install"); } } });
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
let files = realFiles(); const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
const selected = (await service.list()).entries[0];
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests;
await assert.rejects(service.install(selected.id), /changed/);
assert.equal(requests, before);
entries[0].description += " updated";
const before = requests.length;
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
const changed = structuredClone(catalog); changed[0].description += " changed";
files = makeFiles(changed);
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
entries = [];
const next = (await service.list()).entries;
assert.deepEqual(next, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one request", async () => {
let finish; let calls = 0;
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
const first = service.list(); const second = service.list();
finish(new Response(JSON.stringify(builtin)));
assert.deepEqual(await first, await second); assert.equal(calls, 1);
finish(responseFor(files, REGISTRY_URL));
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
});
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
const { createHash } = require("node:crypto");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic validated-by-manager archive");
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const requests = []; let installs = 0;
const service = new RegistryCatalogService({
getTempFilePath: (name) => path.join(root, name),
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
manager: { async install(archive, options) {
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
assert.deepEqual(options.catalog, entry); return { id: entry.id };
} },
});
const selected = (await service.list()).entries[0];
await service.install(selected.id, selected.selectionToken);
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
});
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
let entries = structuredClone(builtin);
const requested = [];
const service = new RegistryCatalogService({ fetch: async (url) => {
requested.push(url); return new Response(JSON.stringify(entries));
} });
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
let files = realFiles(); let offline = false;
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
const good = await service.list();
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
entries = [{ ...builtin[0], readme }];
const snapshot = await service.list();
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
assert.deepEqual(snapshot.entries, good.entries);
}
entries = [{ ...builtin[0], repository: "example/.." }];
assert.match((await service.list()).error, /escapes the pinned commit/);
assert.ok(requested.every((url) => url === REGISTRY_URL));
});
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
let response = JSON.stringify(builtin);
const service = new RegistryCatalogService({ fetch: async () => {
if (response === null) throw new Error("offline");
return new Response(response);
} });
const good = await service.list();
let nested = null;
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
const nonfinite = structuredClone(builtin[0]);
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
for (const invalid of [
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
JSON.stringify([{ ...builtin[0], unknownField: true }]),
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
]) {
response = invalid;
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
const bad = [
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
];
for (const entry of bad) {
files = makeFiles([entry]); offline = false;
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
response = null;
const offline = await service.list();
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
}
});
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
const good = await service.list();
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
const good = await service.list(); const resources = service.resources;
const originalClone = global.structuredClone;
try {
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
assert.equal(await service.refresh(), false);
} finally { global.structuredClone = originalClone; }
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
finally { global.structuredClone = originalClone; }
assert.equal(service.resources, resources);
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
});
@@ -0,0 +1,106 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
const requests = []; const files = realFiles();
const fetch = async (url, options) => {
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
return responseFor(files, url);
};
const service = new RegistryCatalogService({ fetch });
const snapshot = await service.list();
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
for (const entry of snapshot.entries) {
const resource = delivery.plugins.find((item) => item.id === entry.id);
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
assertAsset(icon, ["png", "webp"]);
const bytes = await fetchVerified(icon, fetch);
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
}
assert.equal(entry.artifact, undefined);
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
}
assert.ok(requests.every((url) => !/github/u.test(url)));
});
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
for (const url of [
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
]) {
assert.throws(() => assertDownloadUrl(url));
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
}
});
test("official responses never redirect to GitHub or another official path", async () => {
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
const requests = [];
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
requests.push(url); return new Response(null, { status: 302, headers: { location } });
}), /redirects/);
assert.deepEqual(requests, [REGISTRY_URL]);
}
});
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list(); const goodResources = service.resources;
const mutations = [
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
(index, sidecar) => { sidecar.plugins.pop(); },
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
(index) => { index.plugins.push(index.plugins[0]); },
(index, sidecar) => { sidecar.unknown = true; },
];
for (const mutate of mutations) {
files = makeFiles(catalog, mutate);
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
}
});
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list();
const pointer = JSON.parse(read("snapshot.json"));
for (const name of ["catalog", "index", "delivery"]) {
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
files = realFiles(); files.delete(pointer[name].path);
assert.deepEqual((await service.list()).entries, good.entries);
}
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
assert.match((await service.list()).error, /resource reference/);
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
});
-20
View File
@@ -2221,26 +2221,6 @@ html[data-theme-transition] *::after {
--border: var(--host-security-border) !important;
--input: var(--host-security-input) !important;
--ring: var(--host-security-ring) !important;
/* Tailwind theme aliases resolve where declared; pin them in this scope too. */
--color-background: hsl(var(--host-security-background)) !important;
--color-foreground: hsl(var(--host-security-foreground)) !important;
--color-card: hsl(var(--host-security-card)) !important;
--color-card-foreground: hsl(var(--host-security-card-foreground)) !important;
--color-popover: hsl(var(--host-security-popover)) !important;
--color-popover-foreground: hsl(var(--host-security-popover-foreground)) !important;
--color-primary: hsl(var(--host-security-primary)) !important;
--color-primary-foreground: hsl(var(--host-security-primary-foreground)) !important;
--color-secondary: hsl(var(--host-security-secondary)) !important;
--color-secondary-foreground: hsl(var(--host-security-secondary-foreground)) !important;
--color-muted: hsl(var(--host-security-muted)) !important;
--color-muted-foreground: hsl(var(--host-security-muted-foreground)) !important;
--color-accent: hsl(var(--host-security-accent)) !important;
--color-accent-foreground: hsl(var(--host-security-accent-foreground)) !important;
--color-destructive: hsl(var(--host-security-destructive)) !important;
--color-destructive-foreground: hsl(var(--host-security-destructive-foreground)) !important;
--color-border: hsl(var(--host-security-border)) !important;
--color-input: hsl(var(--host-security-input)) !important;
--color-ring: hsl(var(--host-security-ring)) !important;
color: hsl(var(--foreground)) !important;
background-color: hsl(var(--background)) !important;
border-color: hsl(var(--border)) !important;