mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5e77fb9c | ||
|
|
d0e2c905cb | ||
|
|
c11cde9bb2 |
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(); }
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
+53
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
+37
@@ -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).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user