[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
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:
617
docs/plugin-platform/contract-and-sdk.md
Normal file
617
docs/plugin-platform/contract-and-sdk.md
Normal 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.
|
||||
302
docs/plugin-platform/isolated-runtime.md
Normal file
302
docs/plugin-platform/isolated-runtime.md
Normal 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.
|
||||
279
docs/plugin-platform/runtime-extension-boundaries.md
Normal file
279
docs/plugin-platform/runtime-extension-boundaries.md
Normal 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.
|
||||
228
docs/plugin-platform/security-and-permissions.md
Normal file
228
docs/plugin-platform/security-and-permissions.md
Normal 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.
|
||||
87
docs/plugin-platform/sync-providers.md
Normal file
87
docs/plugin-platform/sync-providers.md
Normal 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.
|
||||
289
docs/plugin-platform/terminal-providers.md
Normal file
289
docs/plugin-platform/terminal-providers.md
Normal 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.
|
||||
271
docs/plugin-platform/threat-model.md
Normal file
271
docs/plugin-platform/threat-model.md
Normal 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.
|
||||
193
docs/plugin-platform/ui-contributions.md
Normal file
193
docs/plugin-platform/ui-contributions.md
Normal 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.
|
||||
Reference in New Issue
Block a user