mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cdc97782e |
No files matched your search
@@ -1,84 +1,56 @@
|
||||
# Official static distribution client
|
||||
# Official registry client
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Snapshot contract
|
||||
## Catalog contract
|
||||
|
||||
This client implements the version-1 contract from
|
||||
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
|
||||
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
|
||||
`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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Selection and installation
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Bounds, cache and selection
|
||||
`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.
|
||||
|
||||
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.
|
||||
## Verification boundary
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Selected terminal sharing — implementation WIP
|
||||
|
||||
Base: ThemePack `f868221b5f11810802f929369ac8eee46166079a`.
|
||||
Branch: `feat/plugin-terminal-sharing`. This is saved unfinished source, not an
|
||||
accepted API or an installable/native-tested workflow. Do not merge or release.
|
||||
|
||||
Implemented source so far:
|
||||
|
||||
- Canonical optional action-only `terminal.selection.read`, feature
|
||||
`terminal.selection-sharing`, bounded result and host-target schemas; SDK
|
||||
`context.terminals.shareSelection()` and runtime forwarding through the
|
||||
existing `ui.interact` broker. No independent parallel RPC stack.
|
||||
- Main-only worker/session generation and window-owner binding accessor; no
|
||||
endpoint or Host DTO returned to plugins.
|
||||
- Host target/handle authority, opaque signed short-lived receipts, capacity,
|
||||
expiry and lifecycle hooks; no selected text stored in the authority.
|
||||
- Broker admission binding to the selected resource before a once-only grant,
|
||||
signed Preview/Share phases, fixed audit metadata and teardown integration.
|
||||
- Trusted app-only selected-target setter, not a plugin API. No database,
|
||||
installation/source/cache identity or private-storage schema changes.
|
||||
|
||||
Still missing, and required before this counts as working:
|
||||
|
||||
1. Trusted renderer target publication for the real active terminal/workspace;
|
||||
actual bounded selection reader registered by the terminal surface. Read
|
||||
only on Preview, from a visible connected terminal, never through Agent/MCP
|
||||
history/context readers; refuse sensitive input, empty or oversized selection.
|
||||
2. Host dialog Preview response and explicit Share rendering, failure states,
|
||||
exact resource/selection consistency and global lifecycle verification.
|
||||
3. Canonical/SDK/broker/authority/real-worker/renderer normal and boundary tests,
|
||||
generated-contract and compatibility review (including optional-only
|
||||
declaration semantics), quotas, owner/reconnect/close/revoke/late-result cases.
|
||||
4. Useful ordinary-browser synthetic example, fixture ID/hash/precise permissions,
|
||||
build/CI, independent review/fix/review and parent-owned native acceptance.
|
||||
5. Separate input-draft layer: reuse the actual compose control without inheriting
|
||||
broadcast targets, overwriting an existing draft silently, writing PTY bytes
|
||||
or sending Enter. Require a separately approved target-bound draft action and
|
||||
final active identity check. No input-proposal method/permission is delivered
|
||||
by this WIP.
|
||||
|
||||
Known integration constraints:
|
||||
|
||||
- The market source-binding task owns installation, cache, authorization/private
|
||||
storage identity and associated database schema. Coordinate before touching
|
||||
those locations; runtime/assertActive identity must remain host-owned.
|
||||
- Keep the ThemePack security-color delta in its own branch/head. Incorporate
|
||||
the eventually accepted head with a normal integration merge, not by rewriting
|
||||
frozen evidence.
|
||||
- The user allows simplification of unreleased plugin historical data/protocols;
|
||||
do not add legacy migration layers. Do not remove unrelated host/input data
|
||||
migrations or weaken the sandbox.
|
||||
@@ -13,6 +13,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
|
||||
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.selection.read": [true, "Share terminal selection", "Read only text explicitly previewed and shared from the selected live terminal. It may contain secrets. Each action requires approval.", "分享终端选区", "仅读取用户从当前活跃终端明确预览并分享的文本,可能包含秘密;每次操作均需确认。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
|
||||
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
|
||||
|
||||
@@ -390,6 +390,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
onRendererEvent = null,
|
||||
} = options;
|
||||
let child = null;
|
||||
let pluginSessionAuthorityEpoch = 0;
|
||||
const pending = new Map();
|
||||
const pendingOutput = new Map();
|
||||
const pendingOutputBytes = new Map();
|
||||
@@ -1857,6 +1858,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
},
|
||||
});
|
||||
child = worker;
|
||||
pluginSessionAuthorityEpoch++;
|
||||
worker.on?.("message", (message) => {
|
||||
if (child === worker) handleMessage(message);
|
||||
});
|
||||
@@ -2246,6 +2248,14 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionId) return null;
|
||||
return sessionWebContentsIds.get(sessionId) ?? null;
|
||||
},
|
||||
// Main-process resource authority only; never expose worker objects or endpoints.
|
||||
getPluginSessionBinding(sessionId, webContentsId) {
|
||||
const generation = sessionGenerations.get(sessionId);
|
||||
if (!child || !Number.isSafeInteger(generation) || closedSessions.has(sessionId)
|
||||
|| pendingSessionStartSequences.has(sessionId) || outputRoutePending.has(sessionId)
|
||||
|| sessionWebContentsIds.get(sessionId) !== webContentsId || !isLiveWebContentsId(webContentsId)) return null;
|
||||
return Object.freeze({ epoch: pluginSessionAuthorityEpoch, generation });
|
||||
},
|
||||
getSessionHostId(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
return sessionHostIds.get(sessionId) ?? null;
|
||||
|
||||
@@ -166,6 +166,7 @@ function createBridgeRegistrar(context) {
|
||||
electron: electronModule,
|
||||
safeStorage,
|
||||
getInteractionWindow: () => win,
|
||||
getTerminalWorkerManager: () => terminalWorkerManager,
|
||||
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
|
||||
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
|
||||
requestPermissionDecision: createNativePermissionDecisionProvider({
|
||||
|
||||
@@ -12,26 +12,23 @@ 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 DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
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.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");
|
||||
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");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function assertReadmeSource(entry) {
|
||||
function pinnedReadmeUrl(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");
|
||||
}
|
||||
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("README source escapes the pinned commit");
|
||||
}
|
||||
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;
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
@@ -46,49 +43,51 @@ 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");
|
||||
}
|
||||
assertReadmeSource(entry);
|
||||
pinnedReadmeUrl(entry);
|
||||
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
|
||||
assertCatalogManifest(entry.manifest, entry);
|
||||
ids.add(entry.id);
|
||||
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`);
|
||||
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");
|
||||
}
|
||||
assertDownloadUrl(asset.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch) {
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
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");
|
||||
}
|
||||
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));
|
||||
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;
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
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(); }
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
@@ -104,6 +103,11 @@ 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");
|
||||
@@ -123,4 +127,4 @@ class PluginCatalogService {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
@@ -24,17 +24,25 @@ test("curated catalog uses pinned public first-party source and rejects advanced
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
|
||||
});
|
||||
|
||||
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/);
|
||||
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/);
|
||||
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /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/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
});
|
||||
|
||||
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://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.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,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"$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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
{
|
||||
"$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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"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
@@ -1,53 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 873 B |
-37
@@ -1,37 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 873 B |
@@ -1,195 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"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 };
|
||||
@@ -390,6 +390,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -426,6 +429,9 @@
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SharedTerminalSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -536,6 +542,11 @@
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"terminalSessionId": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -731,7 +742,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"NonResourceScopedPermission": {
|
||||
@@ -765,7 +777,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"ResourceScopedPermission": {
|
||||
@@ -872,7 +885,10 @@
|
||||
{
|
||||
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
|
||||
}
|
||||
]
|
||||
],
|
||||
"not": {
|
||||
"const": "terminal.selection.read"
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"type": "object",
|
||||
@@ -5163,6 +5179,77 @@
|
||||
"entries",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
"TerminalSelectionSharingFeature": {
|
||||
"const": "terminal.selection-sharing"
|
||||
},
|
||||
"TerminalSelectionSharingLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"handleTtlMs": 120000,
|
||||
"handlesPerRuntime": 4,
|
||||
"handlesTotal": 64
|
||||
}
|
||||
},
|
||||
"HostTerminalTarget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostTerminalSelectionReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "terminal.selection.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SharedTerminalSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 64,
|
||||
"maxLength": 256
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"handle",
|
||||
"expiresAt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { createHmac, randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { TerminalSelectionAuthority } = require("./terminalSelectionAuthority.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const schema = require("./generated/plugin-contract.schema.json");
|
||||
const LIMITS = schema.$defs.HostInteractionLimits.const;
|
||||
@@ -31,7 +32,12 @@ function assertInteractionRequest(params) {
|
||||
}
|
||||
return Object.freeze(structuredClone(params));
|
||||
}
|
||||
function interactionAuthorization(params) {
|
||||
function interactionAuthorization(params, terminalTarget) {
|
||||
if (params.kind === "terminal.selection.read") {
|
||||
if (!terminalTarget) throw fail("failedPrecondition", "A host-selected terminal is required");
|
||||
return { permission: "terminal.selection.read", resources: [terminalTarget.resource],
|
||||
reason: "Preview and explicitly share the selected terminal text", operationId: "terminal:share-selection", allowedScopes: ["once"] };
|
||||
}
|
||||
const permission = params.kind === "clipboard.read" ? "clipboard.read"
|
||||
: params.kind === "clipboard.write" ? "clipboard.write" : "ui.interact";
|
||||
return {
|
||||
@@ -55,6 +61,12 @@ class PluginHostInteractionService {
|
||||
this.timeoutMs = Math.min(options.timeoutMs ?? LIMITS.timeoutMs, LIMITS.timeoutMs);
|
||||
this.key = randomBytes(32);
|
||||
this.pending = new Map();
|
||||
this.terminalSelection = new TerminalSelectionAuthority({getWindow: this.getWindow, getManager: options.getTerminalWorkerManager, clock: this.clock});
|
||||
this.terminalInvalidation = this.terminalSelection.onDidInvalidate(() => {
|
||||
for (const entry of [...this.pending.values()]) if (entry.terminalTarget && !this.terminalSelection.isCurrent(entry.terminalTarget)) {
|
||||
this.#finish(entry, null, fail("cancelled", "Selected terminal connection changed"));
|
||||
}
|
||||
});
|
||||
this.rates = new Map();
|
||||
this.admissions = new WeakMap();
|
||||
this.admissionGuards = new Map();
|
||||
@@ -75,8 +87,9 @@ class PluginHostInteractionService {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
return interactionAuthorization(params);
|
||||
const terminalTarget = params.kind === "terminal.selection.read" ? this.terminalSelection.capture(sender, context) : undefined;
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard, terminalTarget });
|
||||
return interactionAuthorization(params, terminalTarget);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -132,7 +145,7 @@ class PluginHostInteractionService {
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const binding = JSON.stringify([
|
||||
nonce, entry.id, entry.context.pluginId, entry.context.pluginVersion,
|
||||
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt,
|
||||
entry.context.runtimeId, entry.sender.id, entry.params.kind, entry.phase, entry.expiresAt, entry.terminalTarget?.resource ?? null,
|
||||
]);
|
||||
return `${nonce}.${createHmac("sha256", this.key).update(binding).digest("base64url")}`;
|
||||
}
|
||||
@@ -143,6 +156,7 @@ class PluginHostInteractionService {
|
||||
requestId: entry.id, actionToken: entry.actionToken,
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.terminalTarget ? { terminalSessionId: entry.terminalTarget.sessionId } : {}),
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
});
|
||||
}
|
||||
@@ -222,10 +236,13 @@ class PluginHostInteractionService {
|
||||
if (this.pending.size >= LIMITS.pendingTotal || [...this.pending.values()].some((entry) => entry.context.runtimeId === context.runtimeId || entry.sender === sender)) {
|
||||
throw fail("resourceExhausted", "Host interaction limit reached");
|
||||
}
|
||||
const terminalTarget = params.kind === "terminal.selection.read"
|
||||
? admission?.terminalTarget ?? this.terminalSelection.capture(sender, context) : undefined;
|
||||
if (terminalTarget) this.terminalSelection.assertCurrent(terminalTarget);
|
||||
const now = this.clock();
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
id: randomUUID(), context, params, sender, resolve, reject,
|
||||
id: randomUUID(), context, params, sender, resolve, reject, terminalTarget,
|
||||
expiresAt: now + this.timeoutMs, phase: "request", actionToken: null,
|
||||
};
|
||||
entry.onAbort = () => this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
@@ -254,10 +271,19 @@ class PluginHostInteractionService {
|
||||
await entry.context.assertActive();
|
||||
this.#assertWindow(sender);
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
if (entry.terminalTarget) this.terminalSelection.assertCurrent(entry.terminalTarget);
|
||||
if (response.action === "cancel") {
|
||||
this.#audit(entry, "cancelled");
|
||||
return this.#finish(entry, { cancelled: true });
|
||||
}
|
||||
if (entry.params.kind === "terminal.selection.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Terminal sharing requires a preview action");
|
||||
const text = assertTextBytes(response.value);
|
||||
if (!text.length) throw fail("failedPrecondition", "Select terminal text before requesting a share");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
this.quotaManager.chargeBytes(entry.context.runtimeId, "terminal", Buffer.byteLength(text));
|
||||
entry.previewText = text; entry.phase = "preview"; this.#present(entry); return true;
|
||||
}
|
||||
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
@@ -281,6 +307,9 @@ class PluginHostInteractionService {
|
||||
else if (entry.params.kind === "quickPick") {
|
||||
if (!entry.params.items.some((item) => item.id === response.value)) throw fail("invalidArgument", "Host interaction choice is invalid");
|
||||
value = response.value;
|
||||
} else if (entry.params.kind === "terminal.selection.read") {
|
||||
if (entry.phase !== "preview" || typeof entry.previewText !== "string") throw fail("failedPrecondition", "Terminal selection was not previewed");
|
||||
value = {text:entry.previewText, ...this.terminalSelection.issue(entry.terminalTarget, entry.context)};
|
||||
} else if (entry.params.kind === "clipboard.read") value = entry.previewText;
|
||||
else value = true;
|
||||
this.#audit(entry, entry.params.kind === "clipboard.write" ? "copy-confirmed" : "share-confirmed");
|
||||
@@ -295,8 +324,11 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
|
||||
setTerminalTarget(sender, target) { return this.terminalSelection.setTarget(sender, target); }
|
||||
|
||||
releaseWindow(sender) {
|
||||
if (this.owner !== sender) return;
|
||||
this.terminalSelection.releaseWindow(sender);
|
||||
this.admissionRevision++;
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
@@ -305,12 +337,14 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
this.terminalSelection.releasePlugin(pluginId);
|
||||
this.admissionRevision++;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
this.terminalSelection.releaseRuntime(runtimeId);
|
||||
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
|
||||
this.rates.delete(runtimeId);
|
||||
}
|
||||
@@ -323,7 +357,7 @@ class PluginHostInteractionService {
|
||||
}
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) this.admissionRevision++;
|
||||
if (invalidateAdmission) { this.admissionRevision++; this.terminalSelection.clearHandles(); }
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
@@ -332,6 +366,7 @@ class PluginHostInteractionService {
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.terminalInvalidation.dispose(); this.terminalSelection.shutdown();
|
||||
this.key.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -190,6 +190,7 @@ function createPluginHostService(options) {
|
||||
getWindow: options.getInteractionWindow,
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
getTerminalWorkerManager: options.getTerminalWorkerManager,
|
||||
});
|
||||
interactionService.registerRpcCapabilities(rpcRegistry);
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
@@ -239,7 +240,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
|
||||
@@ -20,6 +20,7 @@ const CHANNELS = Object.freeze({
|
||||
themeApply: "netcatty:plugins:theme-apply",
|
||||
themesChanged: "netcatty:plugins:themes-changed",
|
||||
status: "netcatty:plugins:status",
|
||||
terminalTarget: "netcatty:plugins:terminal-target",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
list: "netcatty:plugins:list",
|
||||
@@ -726,6 +727,10 @@ function registerPluginBridge(ipcMain, options) {
|
||||
));
|
||||
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
|
||||
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
|
||||
handle(CHANNELS.terminalTarget, async (_manager, target, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions unavailable");
|
||||
return options.interactionService.setTerminalTarget(event.sender, target);
|
||||
});
|
||||
handle(CHANNELS.themeCatalog, async () => {
|
||||
if (!options.themeService) throw new Error("Plugin themes are unavailable");
|
||||
return options.themeService.catalog();
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
|
||||
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
|
||||
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
|
||||
|
||||
function prepareSnapshot(value, resources = new Map()) {
|
||||
function prepareSnapshot(value) {
|
||||
const entries = structuredClone(validateCatalog(value));
|
||||
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([entry, resources.get(tuple(entry)) ?? null]) }));
|
||||
return { entries, publicEntries, resources: structuredClone(resources) };
|
||||
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
|
||||
return { entries, publicEntries };
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -25,12 +24,11 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
this.refreshing = (async () => {
|
||||
try {
|
||||
// No redirects: only this exact registry endpoint can replace the catalog.
|
||||
const distribution = await fetchDistribution(this.fetch);
|
||||
const next = prepareSnapshot(distribution.entries, distribution.resources);
|
||||
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
|
||||
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
|
||||
// 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;
|
||||
@@ -52,16 +50,15 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
}
|
||||
selected(id, token) {
|
||||
const entry = this.get(id);
|
||||
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
|
||||
if (typeof token !== "string" || token !== tokenFor(entry)) {
|
||||
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
async readme(id, token) {
|
||||
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");
|
||||
this.selected(id, token);
|
||||
// Base method captures the pinned entry synchronously before its first await.
|
||||
return super.readme(id);
|
||||
}
|
||||
async install(id, token) {
|
||||
this.selected(id, token);
|
||||
|
||||
@@ -1,93 +1,153 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
|
||||
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
|
||||
const builtin = require("./catalog.json");
|
||||
|
||||
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
|
||||
let files = realFiles();
|
||||
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
|
||||
let response = () => new Response(JSON.stringify([builtin[0]]));
|
||||
const service = new RegistryCatalogService({ fetch: async (url, options) => {
|
||||
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
|
||||
assert.equal(url, REGISTRY_URL);
|
||||
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
|
||||
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
|
||||
return responseFor(files, url);
|
||||
return response();
|
||||
} });
|
||||
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 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 cached = await service.list();
|
||||
assert.equal(cached.source, "cached"); assert.ok(cached.error);
|
||||
assert.deepEqual(cached.entries, good.entries);
|
||||
assert.deepEqual(cached.entries, online.entries);
|
||||
}
|
||||
files = makeFiles([]);
|
||||
assert.deepEqual((await service.list()).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, []);
|
||||
});
|
||||
|
||||
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: {
|
||||
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: {
|
||||
install() { assert.fail("offline must not install"); },
|
||||
} });
|
||||
const snapshot = await service.list();
|
||||
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
|
||||
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
|
||||
assert.equal(calls, 1);
|
||||
assert.match(snapshot.error, /offline/);
|
||||
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
|
||||
});
|
||||
|
||||
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"); } } });
|
||||
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"); } } });
|
||||
const selected = (await service.list()).entries[0];
|
||||
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
|
||||
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
|
||||
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
|
||||
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);
|
||||
const before = requests;
|
||||
await assert.rejects(service.install(selected.id), /changed/);
|
||||
assert.equal(requests, before);
|
||||
entries[0].description += " updated";
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
|
||||
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
|
||||
files = makeFiles([]);
|
||||
assert.deepEqual((await service.list()).entries, []);
|
||||
entries = [];
|
||||
const next = (await service.list()).entries;
|
||||
assert.deepEqual(next, []);
|
||||
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
|
||||
});
|
||||
|
||||
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); } });
|
||||
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; }); } });
|
||||
const first = service.list(); const second = service.list();
|
||||
finish(responseFor(files, REGISTRY_URL));
|
||||
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
|
||||
finish(new Response(JSON.stringify(builtin)));
|
||||
assert.deepEqual(await first, await second); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
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); } });
|
||||
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));
|
||||
} });
|
||||
const good = await service.list();
|
||||
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);
|
||||
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);
|
||||
assert.deepEqual(rejected.entries, good.entries);
|
||||
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
|
||||
response = null;
|
||||
const offline = await service.list();
|
||||
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
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();
|
||||
const originalClone = global.structuredClone;
|
||||
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
|
||||
finally { global.structuredClone = originalClone; }
|
||||
assert.equal(service.resources, resources);
|
||||
try {
|
||||
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
|
||||
assert.equal(await service.refresh(), false);
|
||||
} finally { global.structuredClone = originalClone; }
|
||||
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);
|
||||
});
|
||||
@@ -518,6 +518,10 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
},
|
||||
};
|
||||
const terminals = {
|
||||
shareSelection: (title) => {
|
||||
if (!config.enabledFeatures?.includes("terminal.selection-sharing")) return Promise.reject(new Error("Terminal selection sharing is unsupported"));
|
||||
return interact({ kind: "terminal.selection.read", title });
|
||||
},
|
||||
onDidChange: runtimeApi.terminalEvents.event,
|
||||
};
|
||||
const environment = {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"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/);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
const {randomBytes,createHmac}=require('node:crypto');
|
||||
const {createDefinitionValidator}=require('./contractValidator.cjs');
|
||||
const {PluginRpcError,RPC_ERRORS}=require('./rpcRouter.cjs');
|
||||
const schema=require('./generated/plugin-contract.schema.json');
|
||||
const LIMITS=schema.$defs.TerminalSelectionSharingLimits.const;
|
||||
const FEATURE=schema.$defs.TerminalSelectionSharingFeature.const;
|
||||
const validateTarget=createDefinitionValidator('HostTerminalTarget');
|
||||
const fail=(code,message)=>new PluginRpcError(RPC_ERRORS[code],message);
|
||||
/** Host-only resource authority. It never stores selected text or an endpoint DTO. */
|
||||
class TerminalSelectionAuthority {
|
||||
constructor(options){
|
||||
this.getWindow=options.getWindow??(()=>null);this.getManager=options.getManager??(()=>null);this.clock=options.clock??Date.now;
|
||||
this.key=randomBytes(32);this.target=null;this.handles=new Map();this.listeners=new Set();this.observed=null;this.disposables=[];
|
||||
this.timer=setInterval(()=>this.prune(),1000);this.timer.unref?.();
|
||||
}
|
||||
onDidInvalidate(listener){this.listeners.add(listener);return{dispose:()=>this.listeners.delete(listener)};}
|
||||
invalidate(){this.prune();for(const listener of this.listeners){try{listener();}catch{}}}
|
||||
observe(manager){
|
||||
if(this.observed===manager)return;
|
||||
for(const item of this.disposables)item?.dispose?.();this.disposables=[];this.observed=manager;
|
||||
for(const method of ['onSessionOwned','onSessionClosed','onWorkerExit'])if(typeof manager?.[method]==='function')this.disposables.push(manager[method](()=>this.invalidate()));
|
||||
}
|
||||
setTarget(sender,payload){
|
||||
if(!validateTarget(payload))throw fail('invalidArgument','Invalid selected terminal target');
|
||||
if(!sender || this.getWindow()?.webContents!==sender || sender.isDestroyed?.())throw fail('permissionDenied','Selected terminal belongs to another window');
|
||||
if(this.target?.sender===sender && this.target.sessionId===payload.sessionId)return true;
|
||||
this.target=payload.sessionId?{sender,sessionId:payload.sessionId}:null;this.invalidate();return true;
|
||||
}
|
||||
capture(sender,context){
|
||||
const features=[...(context.manifest?.features?.required??[]),...(context.manifest?.features?.optional??[])];
|
||||
if(!features.includes(FEATURE))throw fail('unsupported','Terminal selection sharing feature was not declared');
|
||||
const manager=this.getManager();this.observe(manager);
|
||||
if(!this.target || this.target.sender!==sender)throw fail('failedPrecondition','Select a live terminal in the main window first');
|
||||
const {sessionId}=this.target;const binding=manager?.getPluginSessionBinding?.(sessionId,sender.id);
|
||||
if(!binding)throw fail('unavailable','Selected terminal has no active owned worker connection');
|
||||
return Object.freeze({sender,manager,sessionId,epoch:binding.epoch,generation:binding.generation,
|
||||
resource:`terminal-session:${sessionId}:window:${sender.id}:worker:${binding.epoch}:generation:${binding.generation}`});
|
||||
}
|
||||
isCurrent(target){
|
||||
if(!target || this.target?.sender!==target.sender || this.target?.sessionId!==target.sessionId || target.sender.isDestroyed?.() || this.getManager()!==target.manager)return false;
|
||||
const current=target.manager.getPluginSessionBinding(target.sessionId,target.sender.id);
|
||||
return !!current && current.epoch===target.epoch && current.generation===target.generation;
|
||||
}
|
||||
assertCurrent(target){if(!this.isCurrent(target))throw fail('cancelled','Selected terminal connection or owner changed');}
|
||||
prune(){for(const [token,entry]of this.handles)if(entry.expiresAt<=this.clock()||!this.isCurrent(entry.target))this.handles.delete(token);}
|
||||
issue(target,context){
|
||||
this.assertCurrent(target);this.prune();
|
||||
if(this.handles.size>=LIMITS.handlesTotal || [...this.handles.values()].filter(e=>e.runtimeId===context.runtimeId).length>=LIMITS.handlesPerRuntime)throw fail('resourceExhausted','Terminal selection handle limit reached');
|
||||
const expiresAt=this.clock()+LIMITS.handleTtlMs,nonce=randomBytes(24).toString('base64url');
|
||||
const signature=createHmac('sha256',this.key).update(JSON.stringify([nonce,target.resource,context.pluginId,context.pluginVersion,context.runtimeId,context.securityPrincipal??null,expiresAt])).digest('base64url');
|
||||
const handle=`${nonce}.${signature}`;
|
||||
this.handles.set(handle,{target,expiresAt,pluginId:context.pluginId,pluginVersion:context.pluginVersion,runtimeId:context.runtimeId,securityPrincipal:context.securityPrincipal});
|
||||
return {handle,expiresAt};
|
||||
}
|
||||
resolve(handle,context){
|
||||
this.prune();const entry=this.handles.get(handle);
|
||||
if(!entry || entry.pluginId!==context.pluginId || entry.pluginVersion!==context.pluginVersion || entry.runtimeId!==context.runtimeId || entry.securityPrincipal!==context.securityPrincipal)throw fail('permissionDenied','Terminal selection handle is unavailable');
|
||||
this.assertCurrent(entry.target);return entry.target;
|
||||
}
|
||||
releaseRuntime(runtimeId){for(const [key,entry]of this.handles)if(entry.runtimeId===runtimeId)this.handles.delete(key);}
|
||||
releasePlugin(pluginId){for(const [key,entry]of this.handles)if(entry.pluginId===pluginId)this.handles.delete(key);}
|
||||
releaseWindow(sender){if(this.target?.sender===sender)this.target=null;this.invalidate();}
|
||||
clearHandles(){this.handles.clear();}
|
||||
shutdown(){clearInterval(this.timer);for(const d of this.disposables)d?.dispose?.();this.handles.clear();this.listeners.clear();this.target=null;this.key.fill(0);}
|
||||
}
|
||||
module.exports={TerminalSelectionAuthority,FEATURE,LIMITS};
|
||||
@@ -122,6 +122,7 @@ function createPreloadApi(ctx) {
|
||||
}),
|
||||
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
|
||||
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
|
||||
setPluginTerminalTarget: (target) => ipcRenderer.invoke("netcatty:plugins:terminal-target", target),
|
||||
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
|
||||
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
|
||||
onPluginThemesChanged: (callback) => {
|
||||
|
||||
Vendored
+1
@@ -454,6 +454,7 @@ declare global {
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
uninstallPlugin?(pluginId: string): Promise<boolean>;
|
||||
setPluginTerminalTarget?(target: import("@netcatty/plugin-contract").HostTerminalTarget): Promise<boolean>;
|
||||
getPluginThemes?(): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
applyPluginTheme?(request: import("@netcatty/plugin-contract").ThemePackApplyRequest): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
onPluginThemesChanged?(callback: () => void): () => void;
|
||||
|
||||
@@ -369,6 +369,8 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
const allFeatures = new Set([...requiredFeatures, ...(manifest.features?.optional ?? [])]);
|
||||
if ((manifest.permissions?.optional ?? []).some((p) => p === 'terminal.selection.read') && !allFeatures.has('terminal.selection-sharing')) errors.push('Terminal selection sharing requires terminal.selection-sharing');
|
||||
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
|
||||
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
|
||||
@@ -333,6 +333,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostTerminalSelectionReadRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -369,6 +372,9 @@
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SharedTerminalSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -479,6 +485,11 @@
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"terminalSessionId": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -664,7 +675,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"NonResourceScopedPermission": {
|
||||
@@ -698,7 +710,8 @@
|
||||
"provider.connection",
|
||||
"provider.authentication",
|
||||
"provider.sync",
|
||||
"provider.importer"
|
||||
"provider.importer",
|
||||
"terminal.selection.read"
|
||||
]
|
||||
},
|
||||
"ResourceScopedPermission": {
|
||||
@@ -770,9 +783,16 @@
|
||||
"RequiredPermissionDeclaration": {
|
||||
"description": "Required resource-scoped permissions must name their complete activation-time approval bounds.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/NonResourceScopedPermission" },
|
||||
{ "$ref": "#/$defs/RequiredPermissionResourceDeclaration" }
|
||||
]
|
||||
{
|
||||
"$ref": "#/$defs/NonResourceScopedPermission"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RequiredPermissionResourceDeclaration"
|
||||
}
|
||||
],
|
||||
"not": {
|
||||
"const": "terminal.selection.read"
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"type": "object",
|
||||
@@ -3058,6 +3078,77 @@
|
||||
"entries",
|
||||
"diagnostics"
|
||||
]
|
||||
},
|
||||
"TerminalSelectionSharingFeature": {
|
||||
"const": "terminal.selection-sharing"
|
||||
},
|
||||
"TerminalSelectionSharingLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"handleTtlMs": 120000,
|
||||
"handlesPerRuntime": 4,
|
||||
"handlesTotal": 64
|
||||
}
|
||||
},
|
||||
"HostTerminalTarget": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9_-]+$"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostTerminalSelectionReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "terminal.selection.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SharedTerminalSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 64,
|
||||
"maxLength": 256
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"handle",
|
||||
"expiresAt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,9 +205,10 @@ export type HostInteractionPrompt = {
|
||||
phase: "request" | "preview";
|
||||
request: HostInteractionRequest;
|
||||
previewText?: string;
|
||||
terminalSessionId?: string;
|
||||
};
|
||||
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest) | (HostTerminalSelectionReadRequest);
|
||||
|
||||
export type HostInteractionResponse = {
|
||||
requestId: string;
|
||||
@@ -220,7 +221,7 @@ export type HostInteractionResult = ({
|
||||
cancelled: true;
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true);
|
||||
value: (string) | (true) | (SharedTerminalSelection);
|
||||
});
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
@@ -237,6 +238,15 @@ export type HostQuickPickRequest = {
|
||||
|
||||
export type HostSingleLineInputText = string;
|
||||
|
||||
export type HostTerminalSelectionReadRequest = {
|
||||
kind: "terminal.selection.read";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type HostTerminalTarget = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -431,7 +441,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -587,7 +597,7 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer" | "terminal.selection.read";
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
@@ -811,6 +821,12 @@ export type SettingOption = {
|
||||
|
||||
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
|
||||
|
||||
export type SharedTerminalSelection = {
|
||||
text: string;
|
||||
handle: string;
|
||||
expiresAt: SafePositiveInteger;
|
||||
};
|
||||
|
||||
export type StreamChunkByteLength = number;
|
||||
|
||||
export type StreamChunkData = ({
|
||||
@@ -1029,6 +1045,10 @@ export type TerminalInterceptorReadyFrame = {
|
||||
|
||||
export type TerminalInterceptorWindowBytes = number;
|
||||
|
||||
export type TerminalSelectionSharingFeature = "terminal.selection-sharing";
|
||||
|
||||
export type TerminalSelectionSharingLimits = {"textBytes":4096,"handleTtlMs":120000,"handlesPerRuntime":4,"handlesTotal":64};
|
||||
|
||||
export type TerminalSessionSnapshot = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
|
||||
@@ -60,3 +60,5 @@ export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const
|
||||
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
|
||||
|
||||
export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satisfies import("./generated/plugin-contract.js").ThemePackFeature;
|
||||
|
||||
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
SyncWriteObjectPayload,
|
||||
SyncWriteObjectResult,
|
||||
TerminalSessionSnapshot,
|
||||
SharedTerminalSelection,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
export type * from "@netcatty/plugin-contract";
|
||||
@@ -626,6 +627,8 @@ export type SyncProviderHandler = Readonly<{
|
||||
}>;
|
||||
|
||||
export interface PluginTerminalSessions {
|
||||
/** Explicit host Preview then Share; no screen/history query or automatic read. */
|
||||
shareSelection(title: string): Promise<SharedTerminalSelection | undefined>;
|
||||
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
@@ -935,3 +938,5 @@ export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
|
||||
Reference in new issue
Block a user