[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,617 @@
# Netcatty plugin contract and SDK
Status: internal preview (`0.1.0-internal`)
Tracking issue: [#2269](https://github.com/binaricat/Netcatty/issues/2269)
Phases 2 and 3 consume this contract in the isolated host runtime and secure
capability boundary. See
[isolated-runtime.md](./isolated-runtime.md) for installation transactions,
runtime placement, RPC routing, lifecycle and crash quarantine, and
[security-and-permissions.md](./security-and-permissions.md) for grants,
credentials and host-mediated capabilities.
This document describes the canonical contract first delivered by phase 1 and
extended before public release. Runtime loading and capability enforcement live
in the host rather than the schema package. UI contributions, terminal
Providers, terminal interceptors, and connection/authentication/importer
Providers have now extended the same internal contract before public release.
Synchronization Providers and signed distribution remain later phases.
## Contract ownership
`packages/plugin-contract/schema/plugin-contract.schema.json` is the canonical
public protocol. It uses JSON Schema 2020-12 and defines:
- package manifests and entrypoints;
- permission declarations;
- setting, command, menu, view, and provider contributions;
- JSON-RPC requests, notifications, results, cancellation, and errors;
- runtime initialization, feature negotiation, and progress notifications;
- JSON and binary stream frames with flow-control windows;
- companion-process Content-Length framing;
- permission requests and decisions;
- provider requests and results.
`npm run generate:plugin-contract` derives two committed artifacts from that
file:
1. TypeScript types exported by `@netcatty/plugin-contract`;
2. a self-contained schema bundle under `electron/plugins/generated/` for the
future host runtime.
`npm run check:plugin-contract` compares both outputs byte-for-byte. CI can
therefore reject a schema edit whose SDK or Electron representation was not
regenerated.
The contract is intentionally marked internal. Compatibility is not promised
until the final rollout PR freezes API 1.0. Review revisions made before this
first contract is merged remain `0.1.0-internal`; after a contract revision is
merged, every breaking change must update the schema identifier, workspace
package versions, and generated artifacts in the same commit.
## Package layout
A plugin is a directory with `netcatty.plugin.json` at its root. The manifest
declares one or both execution entrypoints:
```json
{
"manifestVersion": 1,
"id": "com.example.my-plugin",
"name": "my-plugin",
"version": "0.1.0",
"publisher": "example",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"features": {
"required": ["netcatty.rpc.progress"],
"optional": ["netcatty.stream.binary"]
},
"main": {
"browser": "dist/browser.js",
"node": "dist/node.js"
}
}
```
Manifest bytes must be valid UTF-8. Directory validation and archive validation
share one fatal UTF-8 parser; invalid byte sequences are rejected instead of
being replaced with `U+FFFD` before JSON and semantic validation.
Paths use relative POSIX syntax and are limited to 128 Unicode code points and
512 UTF-8 bytes. The schema and package validator reject absolute paths,
drive-letter paths, repeated separators, backslashes, `.` and `..` segments,
Windows reserved names, control or platform-special characters, and
platform-specific trailing dots or spaces. The semantic package validator also
requires NFC-normalized text and uses conservative Unicode compatibility/case
folding when detecting path aliases. It applies the same syntax and portability
checks after Unicode compatibility normalization, so compatibility characters
cannot introduce separators, traversal segments, drive prefixes, reserved
names, or trailing dots. Every official host consumer must run it after schema
validation because JSON Schema cannot express these filesystem rules.
Every entrypoint, view document, package icon, and companion variant must exist
in the package.
Browser and Node entrypoints express placement. A Node entrypoint additionally
requires the explicit high-risk `runtime.advanced` declaration and grant; the
runtime still evaluates the remaining manifest permissions, trust level, and user grants
before activating either entrypoint.
Each advanced companion has a stable contribution ID and one or more platform
variants. A variant binds one package path and SHA-256 digest to one or more
compatible OS/architecture targets, allowing a universal script to be shared
while macOS, Linux, and Windows native binaries remain distinct. A companion
cannot declare the same target platform twice, and no two companion variants
may claim the same package path. A manifest with companions must also provide a
Node utility entrypoint and declare both `runtime.advanced` and a resource-bound
`companion.execute` permission. The first-party placement resolver selects the
utility entrypoint for companion manifests even when a browser entrypoint is
also present. An ordinary browser placement cannot authorize or launch a
companion.
## Contribution identity
Every setting, command, view, provider, and companion executable has a globally
unique contribution ID. Its exact prefix is the owning plugin ID followed by a
dot:
```text
<pluginId>.<localContributionName>
com.netcatty.hello.sayHello
com.netcatty.hello.settings.greeting
```
The schema requires a namespaced contribution shape. Semantic validation then
checks the dynamic relationship to `manifest.id`; a contribution declared by
`com.netcatty.hello` cannot use `com.other.plugin.sayHello`. Menu command
references and `onCommand:` activation events must resolve to commands declared
by the same manifest. This prevents two independently installed plugins from
claiming the same command, setting, provider, view, or companion identifier.
The same ID also cannot be reused across registry kinds within one plugin, so a
command and a view never compete for one routing key.
Activation uses one canonical top-level registry. The supported events are
`onStartupFinished`, `onCommand:<id>`, `onView:<id>`, and `onProvider:<id>`;
every targeted ID must resolve to a contribution in the same manifest. Provider
entries do not carry a second, potentially conflicting activation field.
Commands own their canonical title, description, icon, and enablement state.
Menus can override the title or icon for a particular placement and declare an
alternate command, visibility, enablement, checked state, ordering, and whether
the resolved keybinding is displayed. Keybindings are separate contributions:
each binding names its command, portable fallback key, optional macOS/Linux/
Windows overrides, Context Key condition, and JSON command arguments. This
avoids treating one command-level shortcut as the only binding and permits the
host to resolve platform conflicts centrally. Menu and keybinding command
references must resolve to commands in the same manifest.
Icons are discriminated references. A `theme` icon names a host-owned icon; a
`package` icon names a required light asset and optional dark asset. Package icon
paths receive the same normalization, traversal, archive-presence, and integrity
checks as code and view entrypoints. Views also declare placement, order,
visibility, icon, and whether their isolated context remains alive while hidden.
All `when`, `enablement`, and `checked` values are opaque host-parsed Context Key
expressions; plugin code never evaluates or injects them into native UI.
## Compatibility and feature negotiation
Both `engines.netcatty` and `engines.api` are node-semver ranges. Schema
validation rejects unsafe range characters, while the semantic validator uses
the complete node-semver grammar. An exact API version is a valid range, but
plugins should normally declare the compatible internal API interval so the
intent is explicit. Prerelease versions are not globally enabled: a range must
name a compatible prerelease baseline explicitly, so `<0.2.0` does not silently
accept `0.2.0-alpha`.
The optional manifest `features` object separates required and optional feature
IDs. Required and optional sets cannot overlap. During runtime initialization,
the host sends the `plugin.initialize` JSON-RPC method using
`RuntimeInitializeRequest` and `RuntimeInitializeParams` with its exact Netcatty
version, API version, and supported features. The plugin answers with
`RuntimeInitializeSuccess` and `RuntimeInitializeResult`, including only the
features enabled for that runtime.
Initialization must fail before activation when either engine range is not
satisfied or a required feature is unavailable. Optional features are enabled
only when both sides support them.
The CLI exposes the same algorithm before installation or packaging:
```bash
netcatty-plugin compatibility ./my-plugin \
--netcatty 1.4.0 \
--api 0.1.0-internal \
--features netcatty.rpc.progress,netcatty.stream.binary
```
`checkPluginCompatibility()` is also exported for the phase-2 package manager
and runtime. It returns the enabled optional features, missing required features,
and deterministic incompatibility reasons rather than a single boolean.
Before applying a version-specific full schema, a host reads
`PluginManifestHeader`. This bootstrap shape deliberately permits unknown fields
and future positive `manifestVersion` values while validating identity, plugin
version, and engine ranges. The host can therefore select a supported schema or
report a precise API/schema incompatibility before the strict full manifest
validator rejects unknown fields. The full `PluginManifest` remains closed with
`additionalProperties: false` so misspelled security declarations never become
silent no-ops.
## RPC, progress, streams, and companion stdio
Control messages follow JSON-RPC 2.0. `RpcFailure.id` is nullable for parse and
invalid-request errors where the request ID cannot be recovered. Long-running
operations use `$/progress` notifications with `begin`, `report`, and `end`
values and stable string or integer progress tokens. A report may use an
absolute percentage or an incremental percentage, never both.
Numeric RPC IDs and progress tokens are restricted to non-negative JavaScript
safe integers. Peers that need a larger opaque identifier use the string form;
this prevents distinct JSON numbers from collapsing onto one correlation key
when Electron or Node parses them.
One JSON-RPC control message is limited to 1 MiB. The generated
`PLUGIN_RPC_MAX_JSON_BYTES` constant exposes that boundary; payloads above it
must use the stream protocol. Stream frames use the separate generated
`PLUGIN_STREAM_MAX_FRAME_JSON_BYTES` limit (24 MiB), which is large enough for
one maximum-size base64 chunk without turning the control plane into an
unbounded data channel.
Stream control envelopes remain schema-valid JSON, but chunk data has three
explicit encodings:
- `json` carries a normal JSON value;
- `base64` carries binary bytes over JSON-only transports such as companion
stdio;
- `transfer` declares that the `MessagePortStreamEnvelope` carries an
`ArrayBuffer` in its `transfer` property. The sender passes that same buffer
in the structured-clone transfer list.
The declared `byteLength` is the UTF-8 JSON or unencoded binary byte count. A
receiver validates JSON serialization, base64 decoding, or transferred buffer
length before accepting credit. The contract exports `createJsonStreamChunk()`,
`createBase64StreamChunk()`, `materializeStreamChunk()`, and
`createMessagePortStreamEnvelope()` so every host path applies the same checks.
`assertStreamChunkData()`, `assertStreamFrame()`, and the envelope helper accept
untyped boundary values. Inline JSON and base64 assertions verify the encoded
bytes against the declared length before a consumer advances sequence or credit
state; transfer chunks defer that comparison until the envelope supplies the
actual `ArrayBuffer`. The frame helpers also reject unknown frame kinds, missing
or additional properties, malformed chunk/error payloads, and stream IDs
outside the Schema-owned 128-character limit. The envelope helper returns a
normalized frame assembled only from validated own data properties rather than
returning the caller's object unchecked.
The open frame is sequence 0 and grants the initial `windowBytes`; data and
terminal frames begin at sequence 1. Sequence numbers increase independently in
each sending direction and cannot exceed `Number.MAX_SAFE_INTEGER`. A producer
must open a replacement stream before exhausting that range. It subtracts every
chunk's declared byte length from its credit and must stop at zero.
The initial receive window is 1 KiB through 16 MiB, and each
`windowUpdate.creditBytes` grant is 1 byte through 16 MiB. The public
MessagePort envelope helper enforces the same Schema-owned ranges before
returning a frame. The generated runtime constants, including the stream ID,
chunk, frame-byte, window, credit, safe-integer, RPC-byte, and error-code limits,
are derived from the same Schema and checked for drift. `windowUpdate.creditBytes` grants an
additional amount rather than replacing the window, so retries and duplicate
control frames cannot be interpreted as an absolute reset. A stdio peer must
never emit the `transfer` encoding because stdio has no structured-clone
transfer list; the framing encoder rejects it.
Public encoders validate runtime values instead of trusting TypeScript casts.
They reject non-finite numbers, `undefined`, sparse arrays, accessors, symbols,
cycles, and non-plain objects before serialization. Serialization reads only
validated own data properties and does not invoke inherited `toJSON()` hooks,
so prototype mutation cannot change the bytes after validation. Base64 must use
canonical RFC 4648 padding bits, and every stream chunk remains bounded to 16
MiB even when a caller bypasses JSON Schema validation. This prevents a browser
plugin and a native companion from computing different bytes for the same
apparent message.
All public JSON validators also enforce a maximum nesting depth of 128 and a
maximum of 100,000 values per message. Manifest validation applies the same
structural budget before invoking the recursive JSON Schema validator. Deep or
pathologically wide payloads are therefore rejected as ordinary validation
failures instead of exhausting the JavaScript call stack or monopolizing the
runtime.
Advanced companion processes exchange UTF-8 JSON using this exact framing:
```text
Content-Length: <decimal UTF-8 byte length>\r\n
Content-Type: application/json; charset=utf-8\r\n
\r\n
<JSON bytes>
```
`Content-Length` is required exactly once. `Content-Type` is optional when
decoding but, when present, must be `application/json` with an optional UTF-8
charset. Header names are case-insensitive and the default header limit is
8 KiB; unknown or duplicate headers,
non-ASCII header bytes, invalid UTF-8, malformed JSON, and frames above 16 MiB
are rejected. Syntactically valid numbers that overflow JavaScript to a
non-finite value are also rejected before a decoded message is returned.
Decoder options may lower but never raise the 16 MiB absolute content limit.
`encodeContentLengthFrame()` and the incremental
`ContentLengthFrameDecoder` implement this contract without shell or line-based
parsing. The decoder uses an amortized queue instead of removing array heads,
and coalesces small inputs into bounded slabs, so adversarial one-byte
fragmentation remains linear without retaining one object per byte. Incoming
Node.js `Buffer` data is copied before `push()` returns and cannot mutate a
partially buffered frame later.
`finish()` detects truncated frames when a process exits.
JSON-RPC standard failures retain their standard integer codes. SDK
`PluginError` values use stable implementation-defined codes in JSON-RPC's
reserved server-error range:
| SDK code | Wire code |
| --- | ---: |
| `cancelled` | -32001 |
| `unknown` | -32002 |
| `invalid_argument` | -32003 |
| `deadline_exceeded` | -32004 |
| `not_found` | -32005 |
| `already_exists` | -32006 |
| `permission_denied` | -32007 |
| `resource_exhausted` | -32008 |
| `failed_precondition` | -32009 |
| `aborted` | -32010 |
| `out_of_range` | -32011 |
| `unsupported` | -32012 |
| `internal` | -32013 |
| `unavailable` | -32014 |
| `data_loss` | -32015 |
| `unauthenticated` | -32016 |
`pluginErrorToRpcError()` performs the mapping and includes the stable SDK code
in `error.data.pluginCode` so clients can preserve meaning without parsing text.
Permission decisions and Provider results are also discriminated unions: an
`allow` decision requires a grant scope, denied/cancelled decisions cannot
smuggle one, successful Provider results require `result`, and failed results
require a stable RPC error.
Reserved RPC methods cannot fall back to the generic request or notification
shape. `plugin.initialize`, `$/progress`, and `$/cancelRequest` must validate
against their dedicated schemas at the aggregate `RpcMessage` boundary. RPC
responses are validated against the method recorded for their pending request;
the generic success envelope alone is not sufficient to validate a
method-specific result.
## TypeScript SDK
`@netcatty/plugin-sdk` exports the generated contract types and a small set of
lifecycle primitives:
- `definePlugin` keeps exact plugin types while checking the activation shape;
- `DisposableStore` gives activation code one cleanup owner;
- `CancellationTokenSource` provides cooperative cancellation without exposing
host abort controllers;
- `PluginError` carries a stable machine-readable error code and JSON details;
- `PluginContext` exposes the exact Netcatty/API versions, negotiated feature
set, storage, opaque secret references, credential leases, mediated network
and filesystem access, companion handles, contribution settings, command
registration/execution, Context Keys, view messaging/state, locale/theme/
application theme tokens/accessibility environment, logging, and
subscriptions.
The terminal Provider registry, bounded result shapes, lifecycle snapshots,
host adapters, and the explicit PR-6 raw-interceptor boundary are documented in
[`terminal-providers.md`](./terminal-providers.md).
Phase-4 contribution methods stay on the same validated control plane. The
runtime registers command handlers only after activation; the host routes
`plugin.command.execute` back to the owning runtime. Setting reads and writes
are scoped by the declaration, view state is namespaced by plugin/view/window,
and environment changes arrive as notifications. Custom-view preload APIs are
separate from `PluginContext` and cannot acquire the runtime's capability
objects.
Phase-5 Provider handlers are activation-owned SDK registrations. The host
performs immutable enumeration without activation, authorizes the exact
Provider permission set at first use, invokes through `RuntimeSupervisor`, and
validates the canonical Provider result plus the terminal operation's bounded
result shape before application use. Phase 6 reuses the same declarations and
registration ownership but moves raw terminal bytes onto a dedicated
worker-to-utility MessagePort; they never traverse the JSON-RPC control plane.
The ready, chunk, successful-result, and failed-result frame metadata is owned
by `TerminalInterceptorFrame` in the canonical Schema. Both peers validate the
generated Schema shape and the shared transfer envelope verifies that only
chunk and successful-result frames carry a real attached `ArrayBuffer` whose
length exactly matches the declared bounded `byteLength`.
The PR 7 implementation adds connection, authentication, and importer Providers without changing
the Provider ownership model. Connection Provider result types are operation
specific. The SDK registers connection Providers as an operation-keyed handler
map so TypeScript binds each invocation to its exact result: `validateConfiguration`,
`probe`, `open`, and `getStatus` each return their named result object, while
`resize`, `signal`, `reconnect`, and `close` acknowledge completion with JSON
`null`. Objects on those control operations are rejected by the generated
schema, SDK type map, runtime dispatch shape, and runtime validator.
`ConnectionStatusResult` may carry bounded `ProviderValidationIssue`
diagnostics; when a later status poll reports `closed` or `error`, those
diagnostics are forwarded with the terminal-session exit event rather than
being available only during the initial `open`.
The application validates configuration and runs `probe` before opening a
session, routes an explicit terminal interrupt through `signal`, and gives a
retryable runtime failure one host-owned `reconnect` attempt before closing the
session. Resize, close, status polling, and reconnect remain bound to the same
host-owned session identity.
Importer draft records are also exact public shapes, not arbitrary JSON bags.
Host, identity, key, snippet, and group drafts each declare required fields,
allowed enums, byte/array limits, and closed object properties. Host drafts may
either name a built-in host with a hostname or a plugin protocol plus an opaque
`pluginConnection` object whose provider ID must match the `plugin:<id>`
protocol during host-owned semantic normalization. Executable startup commands,
hidden built-in plaintext credentials, and unknown host properties are not part
of the importer contract; plugin-owned credentials must flow through identity
or key drafts and then through the existing host-owned encrypted persistence
path. Safe preview output is redacted and bounded before UI display.
Importer Providers use their own operation-keyed handler map, so `detect` must
return a detection result and `parse` must return completion counters. Streamed
records use the public `ImporterLimits` byte and count bounds. A plugin
connection draft may reference an identity or key draft from the same import by
its source ID; Netcatty maps that reference to the new host-owned credential ID
before encrypted persistence and rejects unresolved or ambiguous references.
PR 8 adds sync Providers as another operation-keyed map under permission
`provider.sync`. Plugins implement only encrypted object storage: `connect`,
`disconnect`, `getAccount`, `getCapabilities`, `readObject`, `writeObject`, and
`deleteObject`. Capability reporting covers revisions, conditional writes,
atomic replacement, and size limits (`SyncLimits`). Large objects leave the
JSON control plane on the existing stream seam; plugins never receive the cloud
master key or plaintext vault. Netcatty continues to own encryption, CRDT merge,
migrations, protection snapshots, conflict handling, and read-merge-write-verify.
WebDAV is adapted through the shared encrypted-object storage interface so the
same path can exercise configuration, secret handles, upload/download,
verification, and recovery.
`PluginSecretStore.get()` never returns plaintext. It returns a host-issued
`SecretRef`. Its random ID stays opaque; its non-secret `key` binds later lease
authorization to the same manifest resource used by `get()`/`set()`. `set()`
immediately transfers a value already known to the plugin into host storage
before returning the same kind of reference. Network,
authentication, and companion brokers can consume a one-use lease for the
reference while the main process revalidates plugin ownership and operation
scope. PR 7 also supplies a host-issued `CredentialRef` for Netcatty-owned
Vault credentials through the same SDK method; its injected resolver does not
materialize plaintext until lease consumption. Neither reference kind is a
bearer capability, and neither may bypass permission, ownership, runtime, and
operation checks.
Host-rendered password settings likewise expose only references to plugin code.
The isolated host and phase-3 capability brokers provide these implementations.
No renderer decision provider means requests fail closed; a manifest declaration
never grants authority by itself.
`PluginFilesystemClient.writeFile()` currently requires `{ overwrite: true }`
and an existing regular file. This preserves one stable SDK method while the
cross-platform host denies unsafe arbitrary-path creation until it can bind a
new child to an opened parent directory without a path race.
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
unless the main process supplies a native adapter whose inode checks and entry
enumeration are bound to the same directory handle.
Permission names already use the phase-3 enforcement boundaries: clipboard
read/write, terminal metadata/output/input and input/output interception, Vault
metadata/write/credentials, SFTP read/write, filesystem read/write, network
origins, companion execution, and each Provider registration class are separate
grants. A broad permission such as `terminal.read` or `filesystem` is not part
of the contract. Setting controls likewise include the complete planned native
set, including radio, slider, font, file/directory, sortable list, and structured
table controls. Secret settings cannot opt into sync; list and table controls
must declare a host-validated `valueSchema`. The accepted schema subset has
bounded depth/nodes, explicit types and closed object properties; executable or
backtracking features such as `$ref`, `pattern`, formats and conditionals are
rejected. Defaults are checked against the control's value type, declared
options, numeric range, step, and structured schema; duplicate option values
and unsafe text patterns fail package validation. File and
directory paths are device-local values and cannot opt into cloud sync.
Semantic validation also requires every contribution class to declare its
capability: commands, menus, views,
settings, companion executables, and each Provider kind cannot appear without
the matching required or optional permission. Companion-specific permission
lists reuse the same canonical permission catalog and must be a subset of the
manifest declarations. Provider capability IDs use the same lowercase,
namespaced feature-ID grammar as runtime negotiation.
Provider `configurationSchema` values are declarative JSON data interpreted by
the host's restricted schema validator; providers never receive a way to inject
configuration UI code into Netcatty.
Terminal Provider declarations are also tied to their least-privilege data
capabilities. Completion requires `terminal.complete`; text-derived visual
providers require terminal output plus decoration access; backgrounds require
decoration access only. Raw interception is represented by two distinct kinds,
`terminal.interceptor.input` and `terminal.interceptor.output`, and each requires
its matching high-risk permission. One generic interceptor kind cannot be used
to acquire both directions implicitly.
Plugin entrypoints should return or register every acquired resource:
```ts
import { definePlugin } from "@netcatty/plugin-sdk";
export default definePlugin({
activate(context) {
context.subscriptions.add(registerSomething());
},
});
```
Activation code must treat cancellation and deadlines as normal outcomes.
Host-side cancellation can stop waiting for a plugin but cannot forcibly unwind
arbitrary JavaScript without terminating the isolated runtime.
## CLI
`@netcatty/plugin-cli` supplies five commands:
- `init` creates a minimal TypeScript plugin;
- `validate` checks a source directory or packaged archive;
- `compatibility` checks Netcatty/API ranges and negotiates required and
optional features;
- `build` validates the manifest and runs the plugin's npm build script without
a shell;
- `pack` emits a deterministic `.ncpkg` archive.
The packer sorts UTF-8 package paths, stores fixed ZIP timestamps and file
modes, and writes entries without platform-dependent compression output. The
same files and manifest therefore produce the same archive bytes.
The validated source-manifest byte length and SHA-256 are bound to the scanned
manifest entry before writing; the archive writer then rechecks every scanned
file while streaming it. A manifest changed after validation cannot be packaged
under the previously validated object model. Source hashing enforces its byte
budget during the read, and archive writing stops before emitting bytes beyond
the scanned size, so a concurrently growing file cannot cause unbounded I/O.
Build, archive-validation, extraction, and directory-validation results also
carry the same versioned `contentSha256`. It hashes sorted logical entries
(path, byte length, declared-companion classification, and file SHA-256), so the host
can compare an extracted tree with a valid archive without assuming that all
publishers used the same ZIP encoder or compression method.
Package validation rejects:
- path traversal, absolute paths, backslashes, and case-colliding names;
- non-UTF-8 names and local/central ZIP header disagreements;
- symbolic links and non-regular files;
- executable files not declared as companion executables;
- companion binaries whose SHA-256 does not match the manifest;
- duplicate entries, encrypted entries, and unsupported compression methods;
- missing entrypoints, views, package icons, and companion variants;
- a source manifest whose packaged bytes differ from the validated snapshot;
- excessive path, file, archive, or expanded-package sizes.
These checks are repeated when reading `.ncpkg` files. Installation in phase 2
must not trust a package merely because the publisher previously ran the CLI.
## Compatibility rules for later phases
The following rules are fixed for this internal pre-release contract:
1. JSON Schema is the wire authority. TypeScript types alone never justify
accepting an unvalidated message.
2. Unknown manifest properties are rejected within API 0.1. This prevents a
misspelled security declaration from silently becoming ineffective.
3. Runtime control envelopes are JSON values. Binary stream data crosses a
MessagePort only through the declared `ArrayBuffer` envelope property and
the matching structured-clone transfer list; native objects,
functions, Electron handles, DOM nodes, and cyclic values remain forbidden.
4. Permission declarations do not grant access. They only make a future user
grant possible.
5. Required and optional permission sets cannot overlap.
6. Secret settings cannot contain defaults in the manifest.
7. Companion executables are content-addressed and explicitly declared.
8. Cancellation identifiers and deadlines are part of the RPC contract so a
slow plugin cannot retain an unbounded host request.
9. Stream sequence numbers and receive windows are part of the public protocol;
producers must stop when the receiver's advertised capacity is exhausted.
10. Manifest schema validation is followed by semantic package validation.
This enforces NFC paths and content-dependent rules that JSON Schema cannot
represent by itself.
11. Secret reads return opaque `SecretRef` values. Plaintext is never a normal
SDK storage result, and possession of a reference never replaces identity,
permission, ownership, or operation checks at the privileged boundary.
12. Contribution IDs use the exact owning plugin ID as their namespace.
13. Engine ranges and required features are checked before activation.
14. Companion stdio uses bounded Content-Length-framed UTF-8 JSON; newline JSON
and unbounded reads are not compatible transports.
## Phase-consumer audit and evolution rules
The cross-phase contract was checked against every planned consumer:
| Phase | Contract used without importing application internals |
| --- | --- |
| PR 2 runtime | manifest header/full validation, `plugin.initialize`, JSON-RPC, progress, cancellation, framing, streams |
| PR 3 security | principal-bound grants, canonical resources, permission requests/decisions, `SecretRef`/`CredentialRef`/`SecretLeaseRef`, mediated SDK capabilities, stable failures and cancellation |
| PR 4 contributions | namespaced settings, commands, menus, views and strict semantic references |
| PR 5 terminal providers | namespaced provider IDs, provider request/result envelopes and bounded streams |
| PR 6 data pipeline | direct MessagePort transfer envelopes, sequence, per-chunk credit, and bounded receive-window fields |
| PR 7 connection/auth/import | provider kinds and configuration schemas, platform-specific companion variants, framing, stable failures and progress |
| PR 8 sync | implemented: namespaced `sync` Providers, `SyncLimits`, operation-keyed connect/read/write/delete/capabilities, inline or streamed encrypted objects |
| PR 9 rollout | schema/API selection, compatibility reporting and the final API 1.0 freeze |
The contract tests construct representative manifests for the contribution UI,
ordinary terminal Provider, privileged input/output interceptor, and combined
connection/authentication/sync/importer phases. These fixtures are validated by
the same strict schema and semantic validator used by the CLI, so a later edit
cannot silently make a planned phase inexpressible.
Core meanings are never changed in place after merge: contribution ownership,
RPC method names, error-code mappings, framing, stream encodings, and feature
negotiation require a new contract revision for incompatible changes. New
setting controls, permission names, provider capabilities, or optional fields
may be added only with an API/schema revision; a plugin that uses them declares
the matching API range and required feature. This keeps strict validation while
giving old hosts a deterministic fail-closed path through the manifest header.
## Repository commands
```bash
npm run generate:plugin-contract
npm run check:plugin-contract
npm run test:plugin-contract
npm run build:plugin-packages
```
The complete application checks remain mandatory because workspace and root
dependency changes affect installation and release builds.

View File

@@ -0,0 +1,302 @@
# Isolated plugin host runtime
Status: internal preview (`0.1.0-internal`)
This document describes the isolated runtime introduced in phase 2 and secured
by phase 3 of the plugin platform tracked by
[#2269](https://github.com/binaricat/Netcatty/issues/2269). The runtime remains
hidden behind `NETCATTY_PLUGIN_DEV=1`; phase 4 adds a development-only native
settings/contribution surface, but there is no production plugin entry or
renderer permission UI yet. The first-party development bootstrap uses a native
Electron confirmation dialog. A host without an injected decision provider
still fails every interactive capability request closed.
## Installation transaction
The main process owns `userData/plugins/` and its SQLite database. A package is
never extracted directly into the active package tree. Installation performs
these steps:
1. open a non-symbolic `.ncpkg` source without following symlinks where the
platform supports it;
2. copy it into a randomly named, mode-`0700` staging directory while hashing
the exact bytes and detecting concurrent source changes;
3. validate and extract that private snapshot through the phase-1 package
validator, including ZIP metadata, local/central header agreement, path
aliases, size limits, CRC, manifest semantics, referenced resources, and
companion digests;
4. retain the validated `.ncpkg` snapshot, write both its archive digest and a
representation-independent logical-content digest, and sync the staged files;
5. when replacing an enabled version, persist a temporary disabled state and
stop the old runtime before publishing any replacement;
6. rename the complete version directory into
`packages/<pluginId>/<version>/` and switch the active version in one SQLite
transaction.
The file rename occurs before the database transaction. A normal database
failure removes the just-published directory and restores the previous runtime.
If the process exits between the durable rename and the transaction, startup
recovery validates the committed directory and imports it as a disabled
version, even when an older version of that plugin was enabled. Files left
under `staging/` were never published and are removed. A database row whose
active package is missing or invalid is disabled and reported as an error
instead of being executed. Committed invalid versions are retained for
diagnosis or repair from their validated snapshot; only invalid uncommitted
orphans are deleted.
Uninstall uses the inverse two-phase move. The plugin directory first moves
under a marked `staging/remove-*` transaction and the database row is deleted
after both rename parent directories have been synchronized. On restart, a
remaining database row restores the directory, while
an already-deleted row completes removal. A crash cannot leave a live database
record pointing at a package that recovery discarded. A `remove-*` directory
created before any package was moved is harmless debris and is deleted even if
its metadata write was interrupted. Once a package has moved into that
directory, valid identity metadata is mandatory; missing or corrupt metadata
fails closed instead of deleting an unidentified package.
Installing the same version and archive is idempotent after the installed tree
is revalidated. Reusing the same plugin ID and version with a different archive
digest is rejected; version substitution must use a new version.
Before every runtime placement decision, `PackageStore.preparePackageRoot()`
rescans the installed tree and compares its logical-content digest with the
retained snapshot verified at startup. The digest binds each normalized path,
byte length, declared-companion classification, and file SHA-256, independent of ZIP
compression or entry ordering. Source-only ignored roots such as `node_modules`
are forbidden in the installed tree. Drift therefore disables the active
version before either a browser or utility runtime can observe modified code.
This asynchronous preparation method, rather than the synchronous path resolver,
is the mandatory execution boundary for all future runtime placements.
Install, enable/disable, restart and uninstall mutations share one manager
queue. A second renderer request cannot race an active-version switch or start
two runtimes for one plugin. Replacing an enabled version first persists a
temporary disabled state and fully stops the old runtime, then switches the
active-version pointer and restores the requested enabled state in the same
database transaction. Lazy activation cannot recreate the old runtime between
those steps. A failure before the pointer switch restores the prior enabled
runtime. If the new version fails activation after the switch, a compare-and-set
transaction restores and restarts the prior version while retaining the failed
package and its version-scoped error state for diagnosis. If the prior runtime
can no longer start, that restored version remains disabled instead of entering
an activation loop.
## Database ownership
`plugins.sqlite` uses WAL, foreign keys, `synchronous=FULL`, explicit schema
versions, and immediate transactions. It records installed versions, the active
version, enabled state, runtime state, version-scoped crash history, and
namespaced JSON key/value storage. The complete initial schema also keeps
permission grants, OS-encrypted secret ciphertext, and bounded security audit
records in user-owned tables with no package-version cascade. Newer unknown
database schemas fail closed. The plugin host has not shipped to users, so it defines one complete
initial schema at version 1 and has no migration chain. Pre-release phases may
still revise that initial schema (or reset development-only databases); schema
migrations begin only after a released build can have durable user data.
Because the host uses the synchronous `node:sqlite` API, transaction callbacks
must also be synchronous; returning a Promise aborts and rolls back instead of
committing an operation whose later failure could no longer be contained.
Crash counters and runtime state never cross a version boundary. A genuinely
new version starts with clean state, reinstalling the same version does not
bypass quarantine, and selecting a retained version restores that version's
prior error/quarantine state.
Explicit recovery clears only the active version's counter and preserves other
retained versions' failure history.
Development databases created by an earlier pre-release schema must be reset;
the project intentionally does not treat unpublished layouts as released
migration sources.
## Runtime selection
An installed manifest can declare browser, Node, or both entrypoints. During
the internal preview the host uses this deterministic placement rule:
- a manifest that declares native companions or a privileged terminal input or
output interceptor is placed in the Node utility runtime, including when it
also declares a browser entrypoint; these manifests must provide the Node
entrypoint and `runtime.advanced`, while companions additionally require
bounded `companion.execute` resources;
- otherwise, a browser entrypoint is preferred whenever it exists;
- a Node entrypoint is used when no browser entrypoint exists.
The rule keeps ordinary dual-target plugins on the least-privileged runtime
while making the privileged utility exceptions explicit and fail closed. A later trust
phase adds verified publisher identity to the advanced Node path; it must not
silently upgrade an ordinary plugin.
### Ordinary browser runtime
Each ordinary plugin receives a hidden `BrowserWindow`, a unique in-memory
session, and a unique unguessable protocol authority. It runs with Chromium's
OS sandbox, `nodeIntegration=false`, `contextIsolation=true`, no DevTools,
dialogs, webviews, popups, navigation, permissions, downloads, or network
requests. The session is forced offline, uses an unreachable proxy without a
loopback bypass, and restricts WebRTC to proxied traffic. It accepts only the
matching `netcatty-plugin://` authority, which remains available while ordinary
network schemes are offline.
The protocol handler reads resources as bytes after decoded path validation,
realpath containment and regular-file checks. It serves a restrictive CSP,
runtime bootstrap modules, the public SDK/contract modules, and only that
runtime's package root. Runtime tokens are removed when the plugin stops, so a
stale document cannot reopen package resources.
The preload has one job: transfer one host-created MessagePort into the plugin
document. A three-stage handshake waits for preload readiness, port receipt and
installation of the plugin-side RPC listener, avoiding load-order message loss.
It does not expose Electron, Node, Netcatty's application preload, or an
arbitrary IPC channel.
Before importing package code, the bootstrap removes direct fetch, XHR,
WebSocket, WebTransport, WebRTC, beacon and worker globals. These APIs are not a
substitute for network permission: ordinary plugins use the phase-3 host broker,
which authorizes each HTTP(S) origin, reauthorizes every redirect origin, omits
ambient cookies, and bounds request and response bytes.
### Advanced utility runtime
Node-only plugins run in a dedicated Electron `utilityProcess`, never in the
main process. The host passes a small environment, disables unsigned-library
loading, uses no shell, captures bounded stdout/stderr diagnostics, and checks
the entrypoint's realpath containment immediately before launch. A module loader
maps only the two public bare imports (`@netcatty/plugin-sdk` and
`@netcatty/plugin-contract`) to packaged host resources.
Stopping an advanced runtime is not complete when `utilityProcess.kill()`
returns. Netcatty closes its RPC authority immediately, requests termination,
and waits for the child `exit` event before a replacement activation may start.
Fatal and protocol errors follow the same ordering: the old process is reaped
before the supervisor publishes the crash. This prevents two privileged
versions of one plugin from overlapping during restart, update, or quarantine.
If the process ignores graceful termination, Netcatty escalates to an OS-level
forced termination after a bounded grace period and still waits for `exit`.
Failure to reap after escalation disables and quarantines the plugin for the
remainder of the application process; a replacement activation is blocked
until Netcatty restarts.
The utility process is an isolation and failure-containment boundary, not the
final permission boundary. Node plugins are still advanced code and must both
declare and receive `runtime.advanced`. Phase 3 enforces that consent, scoped
capability grants, companion digest policy and quotas. Phase 9 still adds
publisher signatures and distribution trust. This is one reason the entire
runtime remains behind the local development gate.
CPU and memory monitoring attaches when the BrowserWindow renderer or utility
process is created and samples immediately, so initialization and activation
run inside the same quota boundary as the steady-state runtime.
`runtime.advanced` is consent to ambient Node, filesystem and network APIs in
the contained utility process. It is not a promise that the fine-grained browser
brokers can sandbox Node built-ins. Ordinary plugins remain broker-only; public
advanced activation additionally depends on phase-9 verified publisher trust.
## RPC and streams
Both runtimes use the phase-1 JSON-RPC contract over one MessagePort. Every
incoming envelope passes the depth/node budget, a schema-owned byte budget, and
the committed JSON Schema before correlation or dispatch. Control messages are
limited to 1 MiB; larger payloads use a stream. Stream frames have their own
24 MiB JSON budget so a maximum 16 MiB base64 chunk remains representable.
Reserved initialize, cancellation, progress and stream messages cannot fall
through as generic methods.
An internal synchronous raw-message guard runs before schema traversal for all
RPC, progress, cancellation and stream messages. It is intentionally policy
free in this phase and gives phase 3 one bounded place to enforce per-runtime
transport quotas without weakening capability middleware. The guard either
returns synchronously or throws to reject the peer; Promise-returning guards are
treated as a host configuration error so untrusted messages cannot build an
unbounded queue of pending quota checks.
The router provides:
- safe integer/string request correlation;
- a bounded pending and in-flight request count;
- request deadlines and `$/cancelRequest` propagation;
- identity-scoped `$/progress` events for later command and Provider registries;
- host-assigned plugin identity on every handler call;
- immediate method-not-supported responses;
- method-specific validation of `plugin.initialize` results;
- one bounded tombstone for a timed-out/cancelled request, allowing exactly one
late response without confusing it with a reused request ID;
- rejection of genuinely unknown or duplicate response IDs and malformed peers.
Stream frames use stable sequence numbers and byte credit. A sender stops when
credit reaches zero. Received credit is returned only after the consumer
releases the materialized chunk. Pending outbound bytes cannot exceed the
negotiated window, duplicate or out-of-order credit updates fail the peer, and
gaps in a direction's sequence fail just like duplicates. Unhandled streams are
cancelled immediately. Router shutdown invalidates retained release callbacks,
and any transport send failure closes the affected stream. Outgoing failure
rejects all pending writes; failure while returning receive credit removes the
incoming stream before notifying its owner, so peers cannot continue with
different window accounting.
The host-side composition and downstream dependency rules are documented in
[`runtime-extension-boundaries.md`](./runtime-extension-boundaries.md). In
particular, permissions and later Provider registries attach through one RPC
middleware/handler registry, while host calls use the supervisor rather than
reaching into runtime routers.
## Lifecycle and failure containment
The host performs compatibility and feature negotiation before activation,
then uses `plugin.initialize` and `plugin.activate`. Activation has a five-second
deadline. Normal stop requests `plugin.deactivate` with a two-second deadline
and then closes the port and process/window even if plugin cleanup hangs.
Placement and runtime startup share one cancellation signal. Each browser or
utility resource-creation boundary rechecks it, so a stopped activation cannot
resume later and create a hidden window or process.
Unexpected renderer loss, utility-process exit, closed control ports and
protocol violations reject all pending work for only that plugin. Three failures
inside five minutes quarantine the plugin. Quarantine survives restart and is
cleared only by an explicit restart or re-enable action. One plugin's state,
process and pending requests are never shared with another plugin.
Plugin-host construction and recovery remain behind the development gate. A
damaged plugin database or missing host resource closes and disables that
subsystem while leaving the rest of Netcatty running. The management status
waits for initialization and reports the host unavailable after rejection; it
does not expose a permanently rejected manager as usable.
Runtime logs are per-plugin, bounded and rotated. Structured fields whose names
look like credentials, passwords, tokens, secrets or private keys are redacted.
Secret values are encrypted through Electron `safeStorage`; the database and
SDK retain only opaque references. Privileged host consumers receive one-use,
operation/runtime/plugin-bound `SecretLease` objects rather than plaintext RPC
results. See [security-and-permissions.md](security-and-permissions.md).
Application quit is coordinated with plugin shutdown after Netcatty's dirty
editor guard succeeds. Runtimes receive the two-second deactivation deadline;
the coordinator then fails open after a short outer deadline so a broken plugin
cannot make the application impossible to quit. The original `before-quit`
event remains cancelled until that asynchronous deadline finishes. On Windows
and Linux, closing the last tracked Netcatty content window initiates the same
quit path directly; hidden plugin host windows are deliberately excluded from
that count, so they cannot leave a headless application running. Terminal
popups participate in this last-window lifecycle but are not dirty-editor
owners, so they are never sent a query their renderer cannot answer.
## Development management bridge
The renderer management bridge exposes status, list, install, enable/disable,
restart and uninstall operations. The main process checks both the explicit
environment gate and the sender's trusted Netcatty origin for every operation.
With the gate off, the host service is not constructed and installed plugins do
not activate. Phase 4 adds the hidden settings, command, menu, and view UI on top
of this bridge without changing the production gate.
## Packaged-resource invariant
The CLI, contract and SDK are root production dependencies, and their runtime
files plus the browser/utility bootstrap are declared packaged resources. Tests
lock this relationship so a dependency cleanup cannot produce a build that
installs plugins but fails to start them outside the repository checkout.
`npm run test:plugin-runtime` covers the pure main-process boundaries. The
separate `npm run test:plugin-runtime:electron` smoke launches both a real
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
bidirectional storage RPC, and checks the recorded runtime ownership.

View File

@@ -0,0 +1,279 @@
# Plugin runtime extension boundaries
Status: phase 3 internal architecture review
This document records the host-runtime decisions that later plugin-platform
phases are allowed to depend on. The goal is to keep permission, contribution,
terminal, connection, synchronization, and distribution work out of the
runtime lifecycle core while still giving those phases stable internal seams.
These are host-internal APIs, not the public plugin API. The public contract
remains `0.1.0-internal` until the phase-9 API 1.0 freeze.
## Runtime identity is the authority root
Every activation receives a new host-generated runtime ID. The identity used by
host handlers contains the plugin ID, active version, runtime kind, package
root, manifest, logger, and host-resolved security principal. It is captured when the runtime starts and cannot be
supplied or replaced by plugin messages.
The RPC registry adds this identity to every request, notification, middleware
call, and incoming stream. A later permission decision can therefore bind a
grant to all of the following without trusting payload fields:
- plugin ID and version;
- one activation (`runtimeId`) for once/session grants;
- browser or advanced utility placement;
- declared manifest permissions and resources;
- the unsigned or later verified publisher security principal;
- the request cancellation and deadline context.
Host-to-plugin calls also verify that the recorded activation still matches the
database's enabled active version. An update cannot accidentally deliver a
command or Provider request to the old version after the active-version pointer
has moved.
Placement resolution and activation repeat that version check after every
asynchronous policy or startup boundary. A late crash or startup failure from
an old version is emitted for cleanup and diagnostics but cannot increment the
replacement version's crash counter, quarantine it, or overwrite its runtime
state. If the old immutable version is still installed, the event updates that
version's own runtime and crash state so a later rollback cannot mistake it for
a clean or still-running release.
## One capability registry, two message classes
`PluginHostRpcRegistry` is the composition point for plugin-to-host authority.
It deliberately distinguishes request handlers from notification handlers.
Storage mutations cannot be invoked as fire-and-forget notifications, and the
logging notification cannot be converted into a request with a meaningful
result. Reserved lifecycle and transport methods cannot be registered as
capabilities.
Registrations have unique method ownership and may carry immutable metadata.
Each registration may also provide a synchronous, side-effect-free parameter
validator. It runs before middleware, so resource extraction, permission
decisions, quotas, and audit records always consume the method's normalized
parameter shape instead of attacker-controlled raw input. Middleware then runs
immediately before the final handler with the host identity, method, validated
parameters, metadata, cancellation signal, request ID, and deadline.
Phase 3 installs permission, quota, audit, and fail-closed UI mediation here,
at the final privileged boundary rather than in renderer components.
Handler metadata is recursively copied and frozen when registered. Runtime
identity is checked before middleware, again after any asynchronous middleware
(such as an approval prompt), and once more before a result leaves the route.
An old activation therefore cannot resume a privileged handler after an update,
disable, quarantine, or stop transition.
Asynchronous handlers also receive `context.assertActive()`. A handler that
prepares I/O and then commits a mutation must call it immediately before the
commit and honor `context.signal` while waiting. The guard checks both the
host-owned activation identity and the request cancellation signal, so a
timed-out approval or Provider operation cannot commit merely because the same
plugin version remains active. Cancellation cannot undo a side effect already
issued to an external service, so this commit guard is part of the
capability-handler contract.
A running activation uses a route snapshot. Registering a new host subsystem
does not mutate a live plugin's authority invisibly; it applies on the next
activation. This is important when a new Netcatty build adds a capability or a
grant changes the available surface.
Capability middleware is not the transport quota boundary: reserved progress,
cancellation, lifecycle, and stream frames do not enter a business-method
handler. The supervisor therefore binds an optional synchronous raw-message
guard to the same host-generated runtime identity. It runs before JSON budget
walking and protocol dispatch for every message class. Phase 3 uses this seam
for per-activation rate and resource accounting, while capability middleware
continues to own permission and operation-specific policy. The guard must be
synchronous so an untrusted message cannot accumulate an unbounded queue of
pending quota decisions.
## Bidirectional invocation and validation
`RuntimeSupervisor.request()`, `notify()`, and `openStream()` are the only
general host-to-plugin entrypoints. All three can bind work to the exact
authorized runtime identity; streams repeat that check before opening and
again after the runtime returns the handle. Browser and utility runtimes
implement the same methods over their private router. Later registries do not
reach into a runtime window, utility process, MessagePort, or router.
Router requests may carry a reviewed structured-clone transfer list. This is
used to establish privileged terminal interceptor ports while retaining one
owner for correlation, deadlines, cancellation, validation, late responses,
close cleanup, and protocol-failure containment. After the attachment request
is accepted, terminal bytes travel only on the transferred dedicated port and
do not enter the JSON-RPC control plane.
Outgoing requests accept a method-specific result validator. Command and
Provider adapters must validate their exact public result schema before using
plugin data. The generic JSON boundary remains the first structural limit, not
a substitute for operation-level validation. Connection Providers additionally
validate the operation-specific result map: `resize`, `signal`, `reconnect`,
and `close` must return JSON `null`; `getStatus` must return a bounded
`ConnectionStatusResult` object and may attach structured diagnostics. Status
diagnostics are propagated through the host-owned terminal finish route so
later disconnect, reconnect, or authentication failures remain visible after
the initial `open` response.
Control-plane JSON is limited to 1 MiB. Large command results, importer data,
sync objects, terminal snapshots, and connection traffic must use the bounded
stream transport rather than raising this limit. A cancelled request ID remains
temporarily retired until one possible late response is discarded, so a slow
provider cannot accidentally answer a newer request after ID wraparound.
Lifecycle methods and `$/` transport methods are excluded from the general
entrypoints. Only the supervisor may initialize, activate, deactivate, cancel,
or account for a runtime.
## Stream ownership and the terminal fast path
Incoming stream handlers are registered centrally and receive a bind function
for the matched stream, an abort signal, and the same runtime identity. The
first handler that recognizes a pre-authorized stream ID owns it; unknown
streams are cancelled. Owner selection has the same bounded deadline as RPC,
so a stalled registry cannot retain an unowned stream indefinitely. Frames are
ordered per stream ID rather than through one global queue: a slow consumer
backpressures its own stream without blocking unrelated connection, importer,
or synchronization streams. Once a handler binds ownership, every local reject,
deadline, transport failure, peer close, or host shutdown reaches its `onClose`
cleanup boundary exactly once. The same abort signal remains live for the whole
owned stream and is aborted before cleanup, so long-running Provider work can
stop promptly instead of polling runtime state.
Handlers registered after activation require a restart, matching RPC route
snapshot semantics.
General RPC streams remain bounded control/data channels for importers, sync,
connection Providers, and non-hot terminal results. Phase 6 still creates its
planned direct terminal-worker-to-utility-process `MessagePort`; it must not put
the 4 ms interceptor budget through this general JSON-RPC path.
Every outbound write either reaches the transport or rejects. Port failure,
router close, peer cancellation, and local cancellation settle all queued
writes and invalidate retained receive-credit callbacks. If returning receive
credit itself fails, the incoming stream is removed and its owner is notified
before the error escapes. Normal end/error frames await the owner's asynchronous
close handler; forced synchronous router shutdown contains a rejected cleanup
promise so it cannot become an unhandled process rejection. Later provider code
must still release consumed chunks promptly and must not retain a release
callback as an application-level acknowledgement.
An outgoing stream that has sent its terminal `end` retains only its bounded
credit state until the peer releases the final chunks. Those ordinary late
window updates retire the stream instead of being misclassified as protocol
violations; writes remain closed as soon as `end` is sent.
## Placement, lifecycle, and packaged modules
Runtime placement is selected through an injectable resolver. The default
continues to prefer the sandboxed browser entrypoint. Phase 3 can require an
advanced-runtime grant, and phase 9 can add trust attestation, without changing
activation, crash, or shutdown ownership.
The resolver receives an abort signal. Stop, disable, uninstall, and application
shutdown cancel both a pending placement decision and an activation already in
progress. Cancellation does not count as a plugin crash. A permission prompt
introduced in phase 3 must honor this signal, so shutdown never waits for a
renderer decision and no runtime can appear after the supervisor has closed.
Manager shutdown starts supervisor cancellation before waiting for its serialized
mutation queue, so a mutation currently blocked inside placement or activation
cannot deadlock the quit path that is waiting for that same mutation.
Concurrent manager or supervisor shutdown callers share the same completion
promise. No caller may observe shutdown completion before runtime teardown and
startup cancellation have both settled.
Browser and utility runtimes recheck the same signal after every asynchronous
resource-creation boundary; cancelling only the outer supervisor promise is not
sufficient.
Start and stop also share a per-plugin transition gate. A lazy activation waits
for the previous process to finish stopping, while disable/uninstall persist the
disabled state before teardown. Later activation events therefore cannot race a
management operation and recreate a runtime that the user just disabled.
For an advanced utility runtime, `kill()` is only a termination request. Its
stop promise remains pending until Electron emits the child `exit` event.
Unexpected fatal and protocol failures likewise revoke RPC immediately but are
published to the supervisor only after the process is reaped. Permission,
connection, synchronization, and companion state can therefore treat the stop
event as a real process-containment boundary rather than an intent signal.
If the process ignores graceful termination, the host escalates to an OS-level
forced termination after a bounded grace period and still waits for `exit`.
Failure to reap after escalation disables and quarantines the plugin for the
rest of the application process; no replacement activation is allowed until
Netcatty restarts. This fail-closed state is deliberately in-memory as well as
persisted, so clearing a normal crash quarantine cannot overlap a still-live
advanced process.
Runtime state listeners receive starting, running, stopped, error, and
quarantined transitions with the stable activation identity. Permission scopes,
commands, views, and Provider registries can release their state on one common
stop boundary. Listener failures cannot break plugin shutdown.
Progress notifications have a separate supervisor event. Each event carries
the host-assigned activation identity together with the schema-validated token
and an immutable progress value, so simultaneous Providers from different
plugins or plugin versions cannot collide on a token alone.
Browser import maps and utility-process loader mappings are generated from one
reviewed host-module resource list. Adding `@netcatty/plugin-ui` or another
host-owned SDK package does not expand arbitrary filesystem access or require a
new protocol route. Plugin packages still cannot add mappings themselves.
## Downstream phase matrix
| Phase | Stable seam available to the phase | Work owned by that phase |
| --- | --- | --- |
| PR 3 permissions | RPC middleware, immutable runtime identity, raw-message guard, placement/principal resolver, runtime stop events | principal-bound grants, resource canonicalization, secrets, credentials, companions, quotas (implemented) |
| PR 4 contributions | host-to-plugin request/notify, runtime events, host module resources | implemented: lazy activation, command/settings/view registries, Context Keys, UI SDK and sandboxed views |
| PR 5 terminal Providers | validated host requests, cancellation, lifecycle events | Provider ranking, deadlines, snapshots, built-in highlighter/autocomplete adapters |
| PR 6 terminal pipeline | runtime identity and placement policy | direct MessagePort fast path, sensitive-input bypass, circuit breaker |
| PR 7 connection/auth/import | activation-owned Provider requests, exact result validators, bounded streams, diagnostics, secret leases, credential refs | implemented: connection sessions, authentication challenges, importer preview/commit |
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | implemented: `kind: "sync"` Providers with `provider.sync`, encrypted-object connect/read/write/delete/capabilities, WebDAV through shared storage surface, non-cascade sidecars for `sync: true` settings and account/CRDT baselines |
| PR 9 distribution | retained immutable versions, compare-and-set restore, placement resolver, module resources | signatures, trust, health checks, audited update and user rollback policy, API 1.0, and the reproducible terminal benchmark harness/environment/release gate for the 1% throughput and 4 ms p95 / 8 ms p99 input-latency targets |
## Data-model decisions that must remain explicit
The phase-2 database retains every installed immutable version and provides a
compare-and-set pointer restore used only when a just-installed version fails
activation. Phase 9 can build audited update, health-check, and user-initiated
rollback policy on this primitive without changing package layout; phase 2 does
not expose that broader policy.
Crash history is keyed by plugin and version. Changing the active version
starts with clean runtime state, while reinstalling identical version bytes
does not clear quarantine. Phase 9 can therefore assess and roll back one bad
release without inheriting or erasing another version's failure history.
Package publication exposes one internal `beforeActivate` commit boundary.
The manager uses it to disable and stop an enabled old activation before the
database pointer changes, and restores that activation if preparation fails.
Phase 9 health checks and rollback must preserve this ordering instead of
writing the active-version pointer directly.
`plugin_kv` is runtime-owned local data and is removed by explicit uninstall.
Phase-3 encrypted secrets, persistent grants and security audit, plus phase-4
settings and view state, use separate non-cascade tables. PR 8 stores encrypted
sync sidecars (`plugin_sync_sidecars`) in the same non-cascade class for
`sync: true` settings plus plugin sync account/CRDT baselines. Missing or
uninstalled plugin code must not cascade-delete those rows or coerce related
sync configuration away.
Activation events are declared by the public manifest. Phase 4 now starts only
`onStartupFinished` plugins during contribution initialization; commands, views,
and Providers call the existing idempotent `start()` boundary at first use.
This implements lazy activation without replacing process supervision.
## Review checklist for changes to this boundary
Before a later phase changes the supervisor or transport, verify:
1. Can the behavior be expressed as a registry handler, middleware, placement
resolver, state listener, validated request, or stream owner instead?
2. Does every privileged operation retain the host-generated runtime identity?
3. Can a request race an update, disable, quarantine, crash, or shutdown and
reach a stale runtime?
4. Are request and notification semantics distinct and exactly validated?
5. Is terminal hot-path work kept off general JSON-RPC?
6. Does missing or uninstalled plugin code preserve user-owned data?
7. Does adding a host SDK module expand only an explicit trusted resource list?
If the answer requires a new public plugin concept, update the canonical JSON
Schema, generated types, SDK, compatibility rules, documentation, and drift
tests together. An internal shortcut must not become an accidental public API.

View File

@@ -0,0 +1,228 @@
# Plugin security and permission boundary
Status: phase 3 internal preview (`0.1.0-internal`)
This phase is available only with `NETCATTY_PLUGIN_DEV=1`. It is deliberately
usable by later contribution and Provider phases, but it is not a public plugin
release. There is no renderer permission UI yet. The first-party development
bootstrap injects a native Electron confirmation dialog; embedders that do not
inject a decision provider still fail closed.
## Authority model
Every privileged plugin-to-host method is registered in
`PluginHostRpcRegistry` with one explicit authorization descriptor. Parameters
are validated first; the descriptor is then built without probing protected
host state, and quota and permission middleware run immediately before the
handler. Filesystem and credential existence checks happen only after that
permission boundary. An unclassified method is denied. The only current public
method is bounded, redacted logging.
The host supplies immutable plugin ID, version, runtime ID, placement, manifest,
package root, cancellation signal, active-runtime guard, and security principal.
Plugin payload fields can never replace that identity. The default pre-signature
principal is a hash of plugin ID, declared publisher, and immutable package
SHA-256, so changed unsigned code cannot inherit persistent grants. The
placement seam also accepts a `resolveSecurityPrincipal` function so phase 9 can
substitute a verified publisher-key fingerprint without changing the permission
engine.
The grant key includes:
- plugin ID and permission;
- canonical resource;
- required-versus-optional declaration semantics and declared resource bounds;
- the host-resolved security principal.
Changing any declaration boundary or principal invalidates reuse. A renderer
decision cannot grant a resource broader than the manifest declaration.
Permission prompts use the canonical contract directly: absent operation and
session IDs are omitted, long host-generated operation IDs become stable
SHA-256 identifiers, reasons are bounded, and no request can carry more than
128 canonical resources.
Runtime trust/placement resolves before permission prompts. The special
`runtime.advanced` permission is excluded from generic required-permission
preflight and requested exactly once only when the host actually selects the
utility runtime.
## Grant lifetimes
- `once` applies only to the request waiting on that decision and is not stored.
- `session` is held in memory and requires a host-owned session ID; ending the
session removes it.
- `application` is held in memory until explicit revoke or shutdown.
- `always` is persisted in `plugin_permission_grants`.
All lifetimes use the same resource-coverage function. Every resource carries
an explicit `exact` or `directory` kind. Only a filesystem `directory` grant
covers descendants with path-boundary comparison; a file remains exact even if
the path is later replaced by a directory. Origins and companions are exact;
`*` is valid only when the manifest declaration also allows it. Concurrent
identical prompts coalesce. Prompt timeout, runtime abort,
cancel, denial, absence of a decision provider, and stale activation all fail
closed. Grant/use/deny/revoke events enter the bounded security audit.
`PermissionRequest` is part of the canonical Schema and carries plugin display
identity, version, runtime placement, permission, canonical resources and their
aligned resource kinds, reason, operation and optional host session. This is
the complete PR-4 UI handoff; the renderer must return the same request ID and
one canonical lifetime decision. The native fallback visibly escapes control,
line-separator, and bidirectional-control characters in every plugin-controlled
display field so untrusted text cannot forge labels or resource lines.
## Host-mediated capabilities
These brokers are the only authority path for ordinary browser plugins. An
advanced utility entrypoint is intentionally different: `runtime.advanced`
means explicit consent to ambient Node, filesystem and network APIs in its
contained process. Fine-grained broker grants do not sandbox that ambient Node
authority. Phase 9 must also require a verified publisher principal before the
advanced path can be publicly enabled.
Required resource-scoped permissions (`network`, filesystem read/write, and
companion execution) must declare non-empty activation-time resource bounds;
the all-resources `*` wildcard is not a valid bound.
The string shorthand remains available only for optional declarations, whose
concrete resource is approved on first use. A package update therefore cannot
activate first and defer a newly required resource decision until later.
Native companions are an advanced-runtime capability. Their manifests require
a Node utility entrypoint plus `runtime.advanced`, and `companion.start` rejects
browser runtime identities before permission middleware can persist a grant.
The first-party placement path selects the utility runtime whenever companions
are declared, including manifests that also provide a browser entrypoint.
The supervisor repeats the placement check immediately before reserving or
spawning a process. Phase 9 adds verified publisher trust to this same boundary.
Privileged Terminal interceptors use the same deterministic utility placement
rule and additionally require their direction-specific interception grant.
### Network
The ordinary browser SDK has no direct network primitive. `network.request` supports
HTTP(S) only, exact origin authorization, bounded headers, a 128 KiB request and
response body, explicit timeout, no URL credentials, no ambient cookies, no
transport headers, and manual redirects. Every redirect origin is authorized.
The SDK forwards the validated request timeout as the host RPC deadline, so the
router cancels stalled broker work at the same boundary.
Cross-origin redirects strip sensitive headers; 301/302/303 transitions do not
replay POST bodies as GET requests.
### Filesystem
Read, write, stat and directory listing require an absolute path. Authorization
first uses only the lexically resolved requested path, including removal of
redundant trailing separators, so an ungranted request cannot probe path
existence, type, symlink targets or real paths. After permission,
the handler resolves the real path and requires it to equal the authorized
resource; callers must therefore supply an already canonical path and symlink
aliases fail closed. File opens use `O_NOFOLLOW` where supported and bind the
opened handle to both the pre-open authorized inode and the current path inode.
Directory listing requires a host adapter that can bind both inode checks and
enumeration to the same native directory handle. Portable Node does not expose
that primitive, so the default implementation fails closed while preserving
the SDK/RPC seam for a native adapter. Reads use the actual handle bytes rather
than trusting a pre-read size, with a 128 KiB cap. Larger payloads use streams.
Arbitrary-path writes currently require an existing regular file and explicit
overwrite; no `O_CREAT` path exists because Node cannot portably bind creation
to an opened parent-directory handle across macOS, Linux, and Windows. A later
native implementation can add secure relative creation behind the same SDK
method. Writes recheck runtime activity immediately before mutation, and
listing is limited to 1,000 entries.
### Secrets and credentials
Secret values are encrypted with Electron `safeStorage`; unavailable OS
encryption or Linux's insecure `basic_text` fallback denies the operation.
SQLite stores ciphertext plus a `SecretRef` containing an opaque random ID and
the non-secret originating key, never plaintext. The key lets lease permission
checks use the same manifest resource as `secrets.get`/`set`; post-permission
lookup revalidates that the random ID still belongs to that key and plugin.
Secret tables and grants are user-owned security
data and do not cascade when a package version is removed.
Plugins can ask `PluginCredentialBroker` for a `SecretLeaseRef`. A lease is
single-consumption, opaque, maximum 60 seconds, and bound to plugin, active
runtime, operation ID, abort signal and secret ownership. Only a host capability
broker can redeem it. A plugin-owned `SecretRef`, a Netcatty-owned opaque
`CredentialRef`, or a lease ID alone is not authority. Netcatty credential
references use an injected main-process resolver. Authorization treats both
secret and credential IDs as opaque identifiers and does not reveal whether
they exist; secret keys are already plugin-declared resources. Ownership,
ID-to-key binding, and existence are checked only after permission,
immediately before lease issue. Plaintext resolves only when the one-use lease
is consumed. This is the stable credential handoff used by
connection/authentication Providers in PR 7. Importer Providers cannot smuggle
hidden built-in plaintext credentials through host drafts; credentials must be
declared as identity/key drafts and then pass through the same host-owned
encrypted persistence and credential-reference flow.
### Companion executables
Only the manifest variant matching the current OS/architecture can start. Its
real path must remain inside the package, be a regular file, and match the
declared SHA-256 immediately before spawn. The host uses an absolute executable,
empty argument vector, private plugin data directory, minimal environment,
`shell:false`, bounded Content-Length JSON-RPC, at most four processes per
runtime and 64 pending calls per process. Companion-to-host methods receive
method-not-found; privileged work remains in the main host brokers.
Timed-out companion RPC identifiers are retired until one late response is
discarded, and the runtime SDK retries a failed stop rather than marking the
handle locally stopped before the host confirms cleanup.
The validated companion request timeout is also forwarded as its host RPC
deadline.
On POSIX, companions start in a dedicated process group; shutdown signals the
whole group, escalates the whole group to `SIGKILL`, and waits until it no longer
exists. Windows uses shell-free `taskkill /T` for both graceful and forced tree
cleanup. A direct parent exit also starts tree cleanup before the handle or
quota monitor is released. An unreaped companion tree is a containment failure
and disables its plugin. Runtime stop events revoke leases and release all owned
companion handles. Disable, restart, upgrade and uninstall wait for that tree
cleanup before returning or mutating package code; a cleanup failure is
persisted as a containment failure and blocks replacement activation.
## Quotas and failure behavior
The raw-message token bucket runs before schema traversal. Capability
concurrency/rate, logging rate, per-category byte windows, companion count and
pending RPC limits bound retained work. Electron process metrics enforce memory
and sustained-CPU policy for browser/utility runtimes and companion processes.
The runtime monitor is attached and takes its first sample immediately when the
BrowserWindow renderer or utility process is created, before initialize or
activation runs. A process policy violation disables and stops only its owning
plugin.
Network, filesystem, secret, credential and companion handlers recheck the
active runtime immediately before commits or returned results. Cancellation,
disable, update, uninstall, quarantine and shutdown therefore cannot resume a
stale privileged operation.
## Initial database policy
The plugin platform has never shipped. The complete current database remains
schema version 1 and includes package/runtime tables plus grants, secrets and
security audit. There is no migration chain. A developer using an older preview
must reset `userData/plugins/plugins.sqlite`; released migrations begin only
after durable user data can exist.
## Downstream contracts
- PR 4 consumes `PermissionRequest`, structured grant lists/revocation, runtime
events and the existing RPC registry for settings/commands/views. Secret
settings retain their declared key in `SecretRef` without exposing plaintext.
- PRs 5-6 reuse immutable caller identity, permission middleware, cancellation,
quotas and runtime-stop cleanup; the direct terminal fast path remains a
separate MessagePort and must still enforce sensitive-input bypass. Their
larger payloads use streams rather than the 128 KiB control-plane budget.
- PR 7 consumes operation-bound secret leases and digest-verified companions;
secret lease authorization uses the declared key while ID ownership remains
a post-permission lookup. Importers use bounded streams, not directory walks,
and their draft schemas reject executable startup commands and hidden
plaintext built-in credential fields before Vault persistence.
- PR 8 stores encrypted sync sidecars in `plugin_sync_sidecars` (no package
cascade) for non-secret `sync: true` settings plus account/CRDT baselines, and
transports larger encrypted objects over the existing stream seam. Secret
settings never enter cloud sidecars.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.

View File

@@ -0,0 +1,87 @@
# Sync providers
Netcatty cloud sync providers are dynamic and namespaced. Built-in providers
(`github`, `google`, `onedrive`, `webdav`, `s3`) stay compatible; plugins
register additional IDs under their plugin namespace with `kind: "sync"` and
permission `provider.sync`.
## Boundary
Plugins implement **encrypted object storage only**:
- `connect` / `disconnect` / `getAccount`
- `getCapabilities` (`revisions`, `conditionalWrites`, `atomicReplacement`, size limits)
- `readObject` / `writeObject` / `deleteObject`
Netcatty owns encryption, the master key, CRDT merge, migrations, protection
snapshots, conflict handling, and read-merge-write-verify. Plugin providers
never receive the vault master key or plaintext sync payloads.
## Secrets
Only non-secret configuration marked for sync enters cloud payloads. Plugin
connect secrets (`password`, `token`, `secret`, `apiKey`, `accessToken`) are
stripped from configuration, stored in the OS-backed plugin secret store, and
passed to `SyncConnectPayload.credential` as opaque `{ kind: "secret", id, key }`
references. Additional extracted secrets are stored under `sync-credential:<field>`
keys so plugins can `secrets.get` / `credentials.createLease` them.
Durable reconnects persist an opaque SecretRef (`{ kind, id, key }`), not
plaintext. The host injects Authorization only after consuming an
operation-bound lease whose `operationId` matches `network:<origin>`.
**Using a SecretRef from a sandbox plugin:** create an operation-bound lease via
`credentials.createLease` with `operationId` set to `network:<origin>` (same
origin the request will call), then call `network.request` with:
```json
{
"url": "https://example.com/…",
"credentialLease": { "kind": "secret-lease", "id": "…", "operationId": "network:https://example.com", "expiresAt": 0 },
"authorization": { "scheme": "Bearer" }
}
```
The host consumes the lease (bound to the request origin, not a plugin-echoed
id) and injects `Authorization` (Bearer or Basic). Plaintext never returns to
the plugin. Companion `credentialLeases` remains available for node-only
companions.
WebDAV continues to exercise the shared EncryptedObjectStorage path for
configuration, proxy behavior, upload/download, and recovery. Write verification
on WebDAV is performed by the adapter's pad+verify upload (not a second host
byte re-read). Credentials remain field-encrypted at rest via the secure field
adapter.
## Streams and SyncLimits
Public `SyncLimits` (see plugin contract) define:
- `maxObjectBytes` — hard ciphertext cap
- `inlineObjectBytes` — maximum size that may travel inline on the control plane
- key / revision length bounds
Above `inlineObjectBytes`, main↔plugin uses credit-window streams
(`STREAM_WINDOW_BYTES` = 256 KiB). Renderer↔main uses structured-clone
`Uint8Array` for inline payloads and pull/chunked IPC (`sync-write-begin` /
`sync-write-chunk` / `sync-write-commit`, `sync-read-chunk`) for larger objects.
Transfers are per-sender, TTL-bounded, capped, and cancelled via
`cancelPluginExtensionRequest(requestId)` / `AbortSignal`.
## Sidecars (non-cascade)
Missing or disabled plugins must not delete synced settings or connection
baselines. Host-owned `plugin_sync_sidecars` carry `sync:true` non-secret
settings and account/CRDT baselines through collect/apply with last-known and
prefer-cloud merge semantics. Device-local baselines survive remote settings
wipes; empty-vault upload guards ignore last-known-only evidence.
## WebDAV
The production WebDAV adapter is wrapped as EncryptedObjectStorage so it shares
the same encrypt→write / read→decrypt surface as plugin providers. WebDAV's
native pad+verify upload already satisfies write verification and may leave
trailing padding on the remote object; the shared bridge therefore skips a
full byte re-read on that path (`assumeVerifiedWrites`) — without that flag the
host compare would false-fail on padded bodies. Plugin providers keep
host-owned byte compare after write.

View File

@@ -0,0 +1,289 @@
# Terminal Provider API
PR 5 adds the host-owned terminal Provider registry on top of the isolated
runtime and permission boundary. Provider declarations remain immutable
manifest data. Listing Providers never starts a plugin; first invocation uses
the existing idempotent `onProvider:<id>` activation seam and revalidates the
active plugin version and runtime identity after the response.
## Runtime registration
An activated plugin registers only contributions owned by its exact plugin ID:
```ts
context.subscriptions.add(context.providers.register(
"com.example.shell.completion",
"terminal.completion",
async ({ payload, cancellationToken }) => {
if (cancellationToken.isCancellationRequested) return { items: [] };
return { items: [{ text: "git status", displayText: "git status", score: 100 }] };
},
));
```
Registration is activation-owned and disposable. A stale disposable cannot
remove a replacement registration. Invocation carries the declared Provider
ID/kind, an operation, a host-generated request ID, a bounded JSON payload, the
deadline, and a cooperative cancellation token. Results use the canonical
`ok`/`cancelled`/`failed` Provider result union and are validated again by the
main process before renderer use.
Each invocation reauthorizes the Provider kind's least-privilege permission
set against the current runtime identity before sending a session snapshot or
request payload. Required grants are reused; optional declarations prompt at
first use and denial/cancellation returns no terminal data to the runtime.
## Terminal snapshots and lifecycle
Providers receive immutable metadata snapshots containing only stable session
identity and presentation context: session/host/workspace IDs, protocol,
connection status, cwd, title, shell type, dimensions, and alternate-screen
state. Active runtimes can subscribe with `context.terminals.onDidChange()`.
Protocol values preserve the actual built-in transport (`ssh`, `mosh`, `et`,
`telnet`, `local`, or `serial`) and accept bounded namespaced identifiers for
future connection Providers instead of collapsing non-SSH transports to SSH.
Immediately before an invocation, a lazily activated Provider receives a
`snapshot` event for the current session so it does not depend on lifecycle
events that occurred before activation.
Lifecycle events cover creation, connection/reconnection, cwd/title/resize/
alternate-screen changes, command submission, host-detected command completion,
disconnect, and disposal. Completion events contain no command text or raw
output and are emitted from OSC 133 completion markers when available, with a
conservative next-prompt fallback for shells without integration markers.
Connection-scoped cwd, title, and alternate-screen metadata is cleared before
disconnect and reconnect publication; viewport dimensions remain available.
Ongoing lifecycle delivery begins only after a successful invocation with a
non-`once` `provider.terminal` grant. Each event rechecks that grant without
opening a new prompt and remains bound to the exact plugin version, runtime ID,
runtime kind, and security principal that received the authorized invocation.
One-use grants receive only the invocation snapshot and payload.
PR 5 intentionally omits command text, password/prompt content, raw terminal
output, xterm objects, backend handles, and terminal-worker ports. The ordinary
JSON-RPC Provider path is not suitable for hot interception. PR 6 owns the
separate permission-gated MessagePort fast path for input/output interceptors,
sensitive-input bypass, circuit breaking, and the 4 ms interceptor budget.
## Privileged terminal data pipeline
PR 6 implements the two declared raw kinds without exposing xterm, Electron
IPC, backend streams, or the general plugin control plane. Only an advanced
utility runtime with `provider.terminal` and the matching
`terminal.intercept.input` or `terminal.intercept.output` grant can be attached.
Authorization is bound to the exact plugin version, runtime ID, runtime kind,
security principal, terminal session, direction, and declared Provider.
Because the transferred port is a long-lived capability, both permissions must
resolve to a session, application, or persistent grant; a one-use grant is
rejected before either port endpoint is published.
Browser runtimes are rejected before a port is transferred. Publisher
signature eligibility remains a distribution-policy decision owned by PR 9;
the advanced runtime and explicit high-risk permission boundary is already
enforced here.
An activated utility plugin uses the same registration owner and receives a
specialized SDK invocation:
```ts
context.subscriptions.add(context.providers.register(
"com.example.filter.input",
"terminal.interceptor.input",
async ({ data, session, sequence }) => {
// The transferred UTF-8 Uint8Array is owned by this invocation.
return data;
},
));
```
For each terminal session, Netcatty permits at most one arbitrary interceptor
per direction. A single candidate can be selected automatically; competing
candidates require an explicit host-owned user choice and "No interceptor" is
the default/cancel action. The choice is session-local and is discarded on
session disposal, contribution withdrawal, runtime replacement, crash, or
quarantine. The requesting renderer must own the terminal session before any
authorization or activation work occurs.
The main process transfers the two ends of one `MessageChannelMain` directly
to the terminal worker and selected plugin utility process. The utility-side
attachment is established by a transfer-aware `PluginRpcRouter` request, so
the existing router owns correlation, deadline, cancellation, validation,
late-response retirement, close cleanup, and protocol-failure containment.
Only the accepted long-lived byte path leaves the control plane. Data messages
contain a monotonic sequence, direction, bounded credit information, and one
transferable `ArrayBuffer`; the main process never copies terminal payloads.
Ready, chunk, successful-result, and failed-result metadata use the canonical
`TerminalInterceptorFrame` union. Both worker and utility peers validate it
from the generated contract bundle, and the shared MessagePort envelope rejects
missing, unexpected, detached, oversized, or byte-length-mismatched transfers.
The worker serializes chunks, caps each transfer at 64 KiB, and limits queued
output to a 256 KiB credit window. Output remains ordered and host output taps
retain the original data. Renderer flow acknowledgements use the original
ingress count even when a plugin expands, contracts, or completely suppresses
visible output. Host-bypassed sensitive input and protocol replies still wait
behind earlier ordinary input so bypass cannot reorder the terminal stream.
Input requests have a 4 ms worker-owned deadline. Output requests have a
bounded 50 ms deadline and a 256 KiB queued-output window. A timeout, malformed
response, invalid UTF-8 result, closed port, runtime exit, or credit-window
overflow trips the circuit breaker immediately: the original chunk fails open,
the interceptor is disabled for that session/direction, and Netcatty displays
a host-owned warning. An interceptor cannot suppress that warning or re-enable
itself without a fresh host authorization path.
These budgets are containment limits, not production performance acceptance
evidence. PR 9 owns the reproducible benchmark harness, supported hardware and
operating-system matrix, and release gate proving no more than 1% no-plugin
throughput regression plus approximately 4 ms p95 / 8 ms p99 added input
latency before the development gate can be removed.
Credential protection is outside plugin control. Input that the host marks as
sensitive/no-echo bypasses the port before buffer creation, including every
character entered while the password-prompt state is active and confirmed
sudo/su credential autofill. Recorded automation credentials use a password
dialog, remain redacted from script activity/logs, and carry the same sensitive
marker through the script bridge. The terminal worker also recognizes authentication
challenges from bounded original-output tails before output interception, so an
output plugin cannot expose a password by hiding or rewriting its prompt.
Generic PTY protocols do not expose an authoritative live echo-mode signal.
Consequently, a custom or promptless program that disables echo may not be
recognized by the host classifier. The native permission dialog states this
limit before granting input interception, and public enablement remains blocked
until PR 9 restricts the capability to explicitly approved signed advanced
plugins. This is a deliberate limitation of the first terminal data path, not
an absolute no-echo confidentiality guarantee.
Sensitive input is also excluded from terminal broadcast. Terminal protocol replies, urgent interrupts, transfer input gates,
transport encoding, Telnet IAC escaping, host logs, renderer flow accounting,
and marker/safety parsing remain host-owned. Output interceptors may create or
suppress visible byte sequences that affect output-derived lifecycle signals
such as OSC 133. Netcatty owns the parser, marker objects, validation, and
cleanup, but deliberately derives those signals from the transformed visible
stream; credential-prompt classification remains based on bounded original
host output before interception. With no active interceptor, the
worker uses the existing synchronous output path and performs no interceptor
Promise, transfer, or payload allocation.
## Host adapters
Netcatty's built-in autocomplete engine and keyword highlighter use the same
application Provider adapters as plugins:
- completion requests run built-in and plugin Providers concurrently;
- one active request exists per session and Provider kind; a newer request
cancels and suppresses the older result;
- Provider ordering is deterministic and can honor a host-owned preference
list; completion items are score-ranked and text-deduplicated;
- one Provider failure is contained and does not suppress other Providers;
- plugin completion responses are capped and normalized before rendering;
- completion insertion/display text rejects control and bidirectional override
characters before it can reach terminal input or suggestion UI. The host
always renders the exact insertion text for third-party completions, so a
friendly label cannot conceal a different command on previewless terminals;
- decoration Providers return declarative rules only. Rule IDs are namespaced,
counts and strings are bounded, colors must be explicit hex values, and
unsupported expressions are rejected before reaching the highlighter, and
accepted plugin patterns are compiled and executed by the linear-time RE2JS
engine with global, case-insensitive matching;
- decoration results are capped again after Provider fan-out at 16 active
rules and 32 total patterns. Plugin matching examines at most the first 4096
characters of each incoming text segment and retains at most 256 plugin
matches per terminal write. Highlight colors are applied to already-parsed
cells; ordinary input and output only rematch dirty rows. When rules change,
the host restores original cell colors and recolors the visible viewport
immediately, then finishes scrollback in idle slices. Heavy output may skip
matching until one quiet-window catch-up. Patterns that can match an empty
string are rejected because they cannot produce a visible highlight. Normal
boot and hibernate wake share the same CWD-triggered decoration refresh path;
- link and hover Providers receive one bounded physical xterm line and return
exact zero-based ranges. Links are restricted to credential-free HTTP(S)
URLs, reuse the host link-modifier policy, and render hover text with host
DOM nodes rather than plugin HTML. UTF-16 result boundaries are mapped back
to xterm cells so wide and combining characters cannot shift activation or
decoration ranges. Requests pause while the terminal is hidden or
disconnected, and in-flight results are aborted and invalidated on either
transition;
- matcher Providers receive at most the latest 32 parsed logical normal-buffer
lines in one batch. Wrapped physical rows are joined before invocation and
exact logical ranges are split back across host-owned xterm decorations.
Each result identifies a host-provided `lineId`; ranges are validated against
that exact line, the combined request text is capped below the 128 KiB
Provider envelope, and at most 64 logical matches remain visible.
Alternate-screen output is excluded;
- semantic Providers receive only a bounded command submitted from a
positively confirmed shell prompt (or an explicitly identified network
device prompt) and require `terminal.input`. Authentication challenges,
REPL input, and other untrusted prompt-shaped input never reach ordinary
Providers. Prompt Providers receive no command or raw output. Their
bounded annotations are rendered at host-detected command completion. A
prompt line is included only when the shared host detector confirms an empty
shell prompt, so the last output line is never mislabeled as prompt context;
- background Providers return at most four solid-color presentation layers.
Per-layer opacity and the combined host overlay are capped at 0.35, plugin
HTML/CSS/images are never accepted, and the request includes the current
terminal background color for contrast-aware results. An omitted layer
opacity uses the host-owned safe default of 0.15. Providers may request
a 250-60000 ms host refresh cadence; refresh pauses while the terminal is
hidden or disconnected and is disabled when reduced motion is requested;
- theme Providers receive the complete current host palette and may return a
bounded partial palette of explicit colors. Providers are merged in the same
deterministic preference order as enumeration, with the first value for each
color winning; host colors remain authoritative for omitted values;
- every ordinary visual adapter applies a renderer-owned end-to-end wait bound
around lazy activation, authorization, and runtime work. Stale generations,
disconnects, contribution changes, runtime replacement, and terminal
disposal cannot reapply old visual results. Provider availability is cached
from immutable enumeration without activation, stale enumeration generations
cannot overwrite newer contribution state, and enumeration errors fail
closed. Autocomplete, decoration, link, hover, matcher, and background paths
therefore perform no plugin RPC work when those contribution kinds are
absent or the development-gated host is disabled.
The operation payload/result shapes for the ordinary adapters are intentionally
declarative. Every payload also contains the immutable `session` snapshot for
the exact invocation:
- `terminal.completion/provideCompletions`: bounded input, cursor, host OS,
CWD source, and result limit -> bounded completion items;
- `terminal.decoration/provideDecorations`: a host refresh reason -> bounded
declarative highlight rules;
- `terminal.link/provideLinks`: `{ line, bufferLineNumber }` ->
`{ links: [{ start, length, uri, label? }] }`;
- `terminal.hover/provideHovers`: `{ line, bufferLineNumber }` ->
`{ hovers: [{ start, length, contents }] }`;
- `terminal.matcher/provideMatches`: `{ lines: [{ lineId, line,
bufferLineNumber }] }` -> `{ matches: [{ lineId, start, length, label,
severity?, color? }] }`;
- `terminal.semantic/provideSemantics`: `{ command }` -> classification,
destructive/idempotent flags, and bounded annotations;
- `terminal.prompt/provideAnnotations`: a host reason -> bounded annotations;
- `terminal.background/provideBackgrounds`: a host reason and optional current
terminal background -> bounded solid-color layers plus optional
`refreshAfterMs`.
- `terminal.theme/provideTheme`: a host reason and complete current host palette
-> a validated partial terminal palette.
The SDK exports and infers the matching payload, item, operation, and result
interfaces for all nine ordinary Provider kinds, including the immutable
host session snapshot attached to every invocation. The generic registration
overload remains available for later Provider kinds, so plugins do not need
application-internal renderer types and PRs 6-9 can add their own typed maps.
The control-plane JSON budget remains 1 MiB, while each terminal Provider
payload and result is additionally limited to 128 KiB. Default terminal
Provider requests have a 1.5 second deadline; autocomplete uses a shorter 750
ms runtime deadline plus an 800 ms renderer-owned end-to-end wait bound that
also covers lazy activation and first-use authorization. Built-in suggestions
therefore remain available when a plugin prompt is unanswered. Renderer
request cancellation is owned by the requesting
WebContents and all outstanding work is aborted when that sender is destroyed.
A single renderer may retain at most 64 active terminal requests, and one
fan-out invokes at most the first 32 deterministically ranked Providers.
## Downstream compatibility
The registry uses the existing generic Provider request/result envelopes,
runtime identity, cancellation, progress, permission names, and stream
protocol. PR 6 added its direct interceptor transport without changing the
ordinary registry. PR 7 reused that registration and runtime lifecycle for
connection, authentication, and importer Providers, with operation-specific
result validators and bounded stream consumers. PR 8 sync Providers (implemented)
and PR 9
rollout can reuse the same boundaries.

View File

@@ -0,0 +1,271 @@
# Plugin platform threat model
Status: phase 3 internal security boundary
Plugins are untrusted code. A useful plugin may parse terminal output, display
content, call remote services, or ship a native companion; none of those needs
imply trust in the author's code, update server, dependencies, or account.
This threat model records the security properties that the nine-stage platform
must preserve. Phase 1 enforces package-format properties and defines the wire
types. Phase 2 implements process isolation and lifecycle containment behind a
development gate. Phase 3 adds capability mediation, scoped grants, encrypted
secrets, companion containment and quotas; later phases add distribution trust.
## Protected assets
- passwords, private keys, API keys, OTP values, and secret-setting plaintext;
- terminal input while echo is disabled or authentication is in progress;
- host addresses, usernames, notes, command history, and terminal output;
- local files and filesystem metadata outside a plugin's data directory;
- Netcatty renderer and main-process authority, Electron IPC, and Node APIs;
- other plugins' packages, storage, logs, settings, and runtime messages;
- cloud synchronization keys and provider credentials;
- the integrity and availability of terminal sessions and the Netcatty process.
## Adversaries
The design assumes any of the following may be hostile:
- a locally installed plugin package;
- a plugin dependency compromised after publication;
- a publisher account or distribution server;
- a companion executable;
- remote content rendered or parsed by a plugin;
- a malformed or intentionally expensive RPC peer;
- an old package crafted to exploit a newer installer;
- an update that requests broader permissions than the installed version.
The operating system, Electron sandbox, Netcatty application package, and user
account are trusted. A machine already controlled by malware is outside the
platform's protection boundary.
## Package attacks
### Archive traversal and aliasing
ZIP entries can target an absolute path, contain `..`, use backslashes on
Windows, differ only by case, or exploit reserved device names. Extractors may
then write outside staging or overwrite a different entry.
The contract CLI accepts one normalized POSIX spelling for each path and
rejects exact, Unicode compatibility, and case-folded duplicates. The phase 2
installer must run the same validation before extraction and must extract only
under a newly created staging directory.
Every archive entry uses the ZIP UTF-8 flag. Validation compares the raw
central-directory name with the local-header name and also requires matching
flags, compression method, CRC, and sizes, preventing different ZIP readers
from validating and extracting different interpretations of one package.
Manifest decoding is fatal UTF-8 on both source directories and archives.
Malformed byte sequences cannot be normalized differently by separate package
inspection and installation paths.
The packer also binds the exact validated manifest bytes to the scanned package
entry with byte length and SHA-256, then rechecks the entry while writing. A
source manifest changed between semantic validation and archive creation is
rejected instead of inheriting the decision made for older bytes.
Every source hash read enforces the file budget incrementally, and the writer
refuses the first byte beyond the scanned size. Concurrent file growth therefore
fails before it can turn validation or packaging into unbounded disk I/O.
Installation retains the validated archive and binds it to both the archived
byte digest and a canonical logical-content digest. The runtime gate rescans the
installed directory immediately before placement and rejects changed, missing,
or injected files before plugin code starts. This is an integrity and recovery
boundary for corruption or unintended local modification; it is not a claim
that Netcatty can defend against an already-compromised same-user operating
system account.
### Symbolic links and executable smuggling
A symbolic link can make an apparently safe relative path resolve outside the
package. An executable bit can also hide an undeclared native program among
ordinary assets.
Packages cannot contain symbolic links. Executable files must appear in a
platform-specific `companionExecutables[].variants` entry; every variant binds
its package path, supported target platforms, and content SHA-256. A later
signature covers both the manifest and deterministic archive.
### Resource exhaustion
Small compressed inputs can expand into very large outputs, or contain huge
file counts and path names. Both source packing and archive validation impose
limits on archive bytes, expanded bytes, individual files, entry count,
manifest bytes, and path bytes. The installer must enforce limits while
streaming, before committing package metadata.
A byte limit alone does not bound parser work: a small manifest can contain
thousands of nested arrays or a very large number of tiny JSON values. Manifest
validation therefore applies explicit depth and node-count budgets before the
recursive JSON Schema validator runs. Exceeding either budget is an ordinary
package validation failure, not an uncaught stack overflow.
## Runtime attacks and capability controls
### Renderer escape
Normal plugins run in a sandboxed Chromium context without Node,
`contextIsolation` bypasses, arbitrary Electron IPC, or direct access to the
application React tree and xterm instance. Plugin documents use a dedicated
protocol with a restrictive Content Security Policy. The bootstrap removes
direct fetch, socket, WebRTC, transport and worker globals before importing
plugin code. Its isolated session is also offline behind an unreachable proxy,
with non-proxied WebRTC disabled, so a fresh iframe global cannot restore
network authority. Ordinary browser plugins access the network only through the
checked phase-3 host broker. An advanced utility plugin is an explicit high-risk
exception: `runtime.advanced` consents to ambient Node, filesystem and network
authority in a contained process. It never runs in the Netcatty main process,
and phase 9 must additionally require verified publisher trust.
### Confused deputy
A plugin may ask the host to act on another plugin, terminal, host, file, or
network origin. Every request must carry runtime identity assigned by the host;
the host must ignore plugin-supplied identity fields. Capability handlers check
the sender, active operation, declared permission, user grant, and resource
scope before using application authority.
### Permission laundering
A plugin could call a broadly capable built-in command or another plugin to
avoid its own permission check. Public commands therefore retain caller
identity, and capability checks occur at the final privileged boundary rather
than only in UI or command registration.
### Secret exfiltration
Secret values are never ordinary settings or JSON-RPC results. The credential
broker uses operation-bound, single-use leases. Terminal input that Netcatty
marks sensitive through host-owned state or recognized original-output
credential challenges bypasses third-party hooks unconditionally. Generic PTYs
do not expose a trustworthy live echo-mode signal, so an arbitrary custom or
promptless no-echo program cannot be identified in every protocol. The native
input-interception permission warning discloses this limit, and public use stays
restricted to explicitly approved signed advanced plugins in the final rollout
stage. Logs, diagnostics, synchronization, and crash reports redact secret
fields before persistence.
The SDK secret store returns an opaque `SecretRef`, never stored plaintext.
Netcatty-owned Vault material uses a distinct opaque `CredentialRef`; its
main-process resolver validates availability without materializing plaintext,
then resolves only while consuming an operation-bound lease. Neither reference
is treated as a bearer capability: every privileged use must revalidate the
calling plugin, resource ownership, permission, runtime, and operation.
Importer Providers receive an exact draft contract rather than arbitrary Vault
objects. Host drafts reject executable startup commands and hidden built-in
plaintext credential fields; imported sensitive material must appear only in
identity/key drafts and is redacted from the bounded safe preview before
persistence.
### Denial of service
RPC requests have deadlines and cancellation IDs. Streams have explicit byte
windows. The supervisor enforces activation and shutdown deadlines, bounded
pending work, bounded logs, crash quarantine, raw-message/capability/byte
quotas, and CPU/memory monitoring for runtimes and companions. Later terminal
phases add interceptor circuit breakers. A
failed plugin must not stop unrelated plugins or terminal sessions.
RPC control JSON is capped at 1 MiB. Stream frames use a separate 24 MiB JSON
budget only to carry a 16 MiB JSON/base64 chunk; transferred buffers are still
validated against the 16 MiB chunk limit. This keeps large data on the
credit-controlled path and prevents a single string from bypassing structural
depth and node limits.
Runtime decoders must apply exact schemas for reserved methods instead of
accepting malformed reserved messages as generic RPC. Transferable stream data
is brand-checked through the native `ArrayBuffer` internal slot; an object that
only spoofs `Symbol.toStringTag` or `byteLength` is not a transferable buffer.
JSON serialization reads validated own data properties directly and never
executes inherited `toJSON()` hooks supplied through a hostile prototype.
All RPC and stream JSON values use the same depth and node-count budgets, plus
their surface-specific byte budgets, so a validly framed peer cannot consume an
unbounded call stack, validation loop, or retained control-message allocation.
The stdio decoder also consumes fragmented byte queues by advancing an index
rather than repeatedly shifting arrays. Small fragments are copied into bounded
slabs, preventing both quadratic work and per-byte object retention when a peer
delivers a large frame in very small chunks. Copying also prevents a caller from
mutating queued Node.js `Buffer` storage after `push()` returns.
### Update substitution and rollback
The final distribution stage uses signed repository metadata, publisher
signatures, staged health checks, atomic version switching, and rollback to the
last healthy version. Permission, API, or trust-level increases require a new
user decision; an existing grant is not silently widened.
## Security invariants
The platform is not ready for public enablement unless all of these hold:
1. Ordinary plugins have no ambient Node, Electron, filesystem, network, React,
or xterm authority.
2. A declaration is not a grant, and a grant is limited to its declared
resource and lifetime.
3. No renderer means interactive permission requests fail closed.
4. Input marked sensitive by host-owned state or recognized credential-prompt
detection never reaches plugin hooks. Generic input interception warns that
arbitrary custom or promptless no-echo input cannot be detected reliably by
a remote PTY, and remains unavailable to public plugins until the signed
advanced-plugin rollout gate is enforced.
5. The package installed is the package validated and, later, signed.
6. A plugin cannot address another plugin's storage or runtime by changing an
identifier in its request.
7. Plugin failure is contained and the terminal data path fails open only where
disclosure is impossible.
8. Secrets never enter manifests, package defaults, logs, diagnostics, or cloud
synchronization sidecars.
9. Unknown newer protocol versions fail closed at privileged boundaries.
10. Disabling every plugin restores the unextended Netcatty behavior and does
not impose more than the agreed terminal throughput budget.
## Phase 1 baseline
The first phase did not load plugin code. It introduced the SDK interfaces,
committed Schema bundle, deterministic package format and package validation so
the runtime boundary could be reviewed separately. Phase 2 now consumes those
artifacts without changing the public contract version.
## Phase 2 runtime boundary
Phase 2 implements package installation, isolated browser and utility-process
runtimes, bounded RPC/streams, lifecycle deadlines and crash quarantine. These
paths remain disabled unless `NETCATTY_PLUGIN_DEV=1` is set. The browser path
has no ambient Node, Electron, filesystem or network authority. The Node path is
explicitly an advanced runtime and remains behind the development gate until
phase 9 adds signed trust policy.
## Phase 3 capability boundary
Phase 3 installs the permission engine at the final host RPC boundary. A
declaration is never a grant. `once`, host-session, application, and persistent
grants share canonical resource-coverage rules and are bound to a declaration
hash plus a host-resolved security principal. The current unsigned principal is
derived from plugin ID, publisher, and immutable package SHA-256; phase 9 can replace it with a verified
publisher-key identity through the placement resolver without changing grant
semantics. A new principal or changed required/resource declaration requires a
new decision.
Network access is origin-scoped, cookie-free and redirect-by-redirect. File
access authorizes a lexically resolved absolute request without probing the filesystem,
then resolves it after permission and requires the caller to have supplied that
canonical real path. Opened reads are bound to the authorized pre-open inode and
the current path inode. Arbitrary-path creation is denied until a portable
opened-parent implementation exists; overwriting an existing regular file
remains available without exposing a parent-symlink creation race.
Companion executables are package-contained, digest-verified immediately before
shell-free spawn, host-RPC clients only, and their complete process group/tree
must be reaped before their handle is released. Failure to contain a companion
disables its plugin, persists the containment error, and prevents package
mutation or replacement activation until containment is restored.
Secret plaintext is encrypted by Electron `safeStorage` and never returned by
ordinary secret RPC. A credential consumer must redeem a one-use lease bound to
plugin, runtime, operation, abort signal and a maximum 60-second lifetime.
Transport, capability, log, byte, process-count, pending-call, memory and CPU
quotas contain abusive runtimes and companions. The capability boundary remains
disabled unless `NETCATTY_PLUGIN_DEV=1` is set. The first-party development path
injects a native Electron decision provider; any host without a decision
provider fails interactive permission requests closed. Runtime CPU/memory
monitoring begins at process creation rather than after plugin activation, and
native prompt text escapes control and bidirectional formatting characters.

View File

@@ -0,0 +1,193 @@
# Plugin UI contributions
Status: internal preview (`0.1.0-internal`), development gate only
Phase 4 of [#2269](https://github.com/binaricat/Netcatty/issues/2269)
connects the manifest contribution contract to Netcatty's native UI and to a
separate sandbox for fully custom views. It does not expose Electron IPC, React
components, the main document, or a browser network stack to plugin code.
## Activation and ownership
Enabled plugins are not started merely because they contribute UI. The host
starts `onStartupFinished` plugins during contribution initialization and
otherwise activates a runtime when one of its declared commands, views, or
Providers is first used. `onCommand:`, `onView:`, and `onProvider:` therefore
share one idempotent supervisor boundary. Contribution IDs must begin with the
owning plugin ID. Plugin-created Context Keys use the exact owning plugin ID
followed by one local key segment; nested dot segments are rejected so plugin
IDs that share a prefix cannot claim each other's UI state.
The Provider seam also exposes an immutable, localized enumeration independent
of database internals and returns the current runtime identity after lazy
activation. PR 5 can therefore build and retire terminal Provider registries
without importing package-storage structures or guessing which activation owns
an in-flight request.
Disabling, replacing, or uninstalling a plugin first removes its contribution
surface and closes its custom views, then stops the runtime. Background work
lives in the runtime, not in a view, so closing one view does not stop an
otherwise active plugin.
## Native settings
Netcatty renders setting declarations with host-owned controls. The supported
controls are switches, radio/select/multiselect, text and password fields,
textarea, number and slider, color, font, file and directory paths, keybindings,
lists, and tables. The main process validates every write against the declared
control, options, numeric range, text pattern, and structured value schema. The
package validator applies the same constraints to declared defaults before a
plugin can install.
Plugin patterns use a deliberately restricted regular-expression subset:
lookarounds, backreferences, and quantified groups are rejected, and patterned
input has a small independent length limit. List and table values use a bounded
JSON Schema subset with explicit types, bounded arrays/strings/numbers, closed
object properties, `required`, `enum`, and `const`. `$ref`, `pattern`, custom
formats, conditionals, unevaluated properties, and executable extensions are
not accepted.
Application, device, workspace, host, and session values are keyed separately.
The central settings surface receives a bounded, host-owned catalog of current
devices, workspaces, hosts, and sessions and requires the user to select an
explicit target before editing a contextual value. It never reads or writes an
ambiguous record. Each main window owns its catalog contribution; the host
merges those contributions for the standalone settings window and withdraws a
window's targets when its renderer closes. Font settings use the host font
picker, while list and table settings use recursive schema-driven native
controls rather than editable JSON. Settings and restored view state are
user-owned records with no foreign-key
cascade to installed package versions, so uninstall does not erase them. The
platform is unreleased, so both tables are part of the complete schema at
`user_version = 1`; there is no migration chain.
Secret settings never enter the settings table or a renderer snapshot. The
host stores plaintext only through the phase-3 safeStorage-backed secret store,
shows a configured indicator, and exposes an opaque `SecretRef` to the owning
runtime. Only non-secret fields explicitly declaring `sync: true` enter the
encrypted sidecar sync path (PR 8).
## Commands, menus, and Context Keys
Commands are registered in the plugin runtime after activation and invoked
through the host's validated `plugin.command.execute` request. A plugin runtime
or custom view may execute only commands owned by the same plugin. The native
host provides command-palette, application-menu, host-context, terminal-context,
terminal-toolbar, and status-bar placements. Visibility, enablement, and checked
state are computed by the host before rendering; plugin HTML is never inserted
into a native menu or the React tree.
Theme icons are resolved through a fixed host icon catalog. Package image icons
must be declared by the currently active manifest, pass package-integrity and
realpath containment checks, and pass byte, format, and dimension preflight
before any image decoder sees plugin bytes. Decoding and resizing run through a
bounded queue of disposable sandboxed renderer workers; only the resulting
small PNG data URL reaches a Netcatty renderer or native application menu.
ViewBox-only SVGs receive their inspected viewport inside that isolated worker,
so common declarative SVG icons remain usable without trusting intrinsic
decoder dimensions.
Context Key expressions use a bounded parser for literals, namespaced keys,
parentheses, `!`, `&&`, `||`, equality/ordering, `in`, and `not in`. There is no
JavaScript evaluation. Invalid, oversized, or over-complex expressions evaluate
to false. Plugin runtimes may update only one-segment keys in their own exact
namespace.
Platform keybindings are resolved by Netcatty and ignored while the user is
typing in an input, textarea, select, any contenteditable or textbox role, or a
Monaco editor surface. Command enablement is rechecked in the main process, and
renderer snapshots fail closed immediately when their host context changes, so
stale UI cannot execute a context-gated action. Menu placements display the
first active platform binding unless the manifest suppresses it, and holding Alt
selects the declared same-plugin alternate command. Application-menu
accelerators pass through a strict bounded parser before reaching Electron.
Terminal context-menu, toolbar, status-bar, and active-terminal keybinding
invocations receive host-owned `terminal.sessionId`, `terminal.status`,
`host.id`, `host.protocol`, and, when applicable, `workspace.id` Context Keys.
Toolbar and status-bar placements are evaluated against their own surface
contexts rather than sharing one renderer snapshot context. This gives the PR 5
Terminal Provider layer a stable session identity without exposing xterm or
allowing renderer-supplied plugin keys to override runtime-owned Context Keys.
## Sandboxed custom views
Each open custom view is a lazily created `WebContentsView` with:
- `sandbox: true`, context isolation, web security, and no Node integration;
- a private ephemeral session and a fixed black-hole proxy;
- browser permissions, downloads, popups, webviews, drag navigation, and
navigation away from the registered entry document denied;
- a protocol token scoped to one plugin package;
- a CSP with `connect-src 'none'`, no frames, workers, objects, media, forms, or
base-URL changes; and
- a Permissions Policy denying camera, microphone, location, display capture,
USB, serial, HID, payment, fullscreen, and clipboard capabilities.
The protocol serves package files only. A view cannot load host runtime modules
or another plugin's package. The view instance is bound to the Netcatty window
that created it; another renderer window cannot resize, message, or close it.
Owner closure, plugin disable, setup failure, and host shutdown all dispose the
view and its protocol/session registrations.
Opening is generation-bound: the host revalidates the exact plugin version and
runtime identity after package preparation and again after document loading,
before attaching the `WebContentsView`. Runtime stop, crash, quarantine,
disable, uninstall, or replacement therefore cancels an in-flight open. Every
host-side close is also broadcast to the owning renderer so retained and active
view state, including native tabs, is withdrawn immediately.
Views declaring `retainContextWhenHidden` are hidden without destroying their
owner-bound `WebContentsView` and are restored with fresh bounds when reopened.
Retained views are still disposed on owner shutdown, plugin disable, runtime
quarantine, or host shutdown; the flag never extends ownership or permissions.
Views declaring `location: "tab"` participate in Netcatty's draggable native
top-tab model, including neighbor activation, middle-click and Cmd/Ctrl+W close,
and close-others/right/all actions. They do not fall through to the overlay
surface used by non-tab locations.
The application-state lifecycle module combines a directly tested controller
for active and retained instances, in-flight open tokens, early-close tombstones,
and explicit-close handling with a hook that owns tab-catalog reconciliation,
bounds, keyboard dispatch, and environment publication. The React component is
rendering glue only. Contribution query changes expose a fail-closed empty
snapshot while loading, but cannot mutate the native tab catalog until the
matching query completes; opening or switching a tab therefore cannot withdraw
an unconditional plugin tab during its own context refresh.
The preload exposes only `postMessage`, same-plugin `executeCommand`, state
get/set, runtime messages, and environment changes. It caches environment
updates before view code subscribes and uses an owner-checked getter as a
fallback, so late subscribers still receive their initial environment. Messages,
state, command arguments, and Context Key values use the exact bounded JSON
value boundary rather than relying on `JSON.stringify` coercion.
## Themes, localization, and accessibility
Localized manifest text is resolved by exact locale, language base, English,
default, then the first declared value. Native contribution labels are plain
text. A localized snapshot refresh preserves the existing owner-bound view and
native tab while labels reload; host-context changes still fail closed
synchronously for context-gated actions and views, while native tab-catalog
mutation waits for the matching query to finish. Open custom views receive
locale, light/dark/system theme identity,
host-owned CSS color tokens, reduced-motion preference, forced/high-contrast
preference, and subsequent environment changes. Theme-token mutations and
accessibility media-query changes are observed while the host is open, rather
than only at initial view creation. Netcatty retains the accessible
name and close control around every custom view; modal placements use dialog
semantics, while aside, panel, tab, and settings placements use named regions.
## SDK surface
`PluginContext` now provides:
- `settings.get`, `settings.update`, and `settings.onDidChange`;
- `commands.registerCommand` and same-plugin `commands.executeCommand`;
- `contextKeys.set`;
- view message/state methods; and
- current locale/theme/accessibility values plus `environment.onDidChange`.
These methods remain JSON-RPC control-plane operations. Terminal hot-path data
is intentionally deferred to PRs 5 and 6.