[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:
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.
|
||||
Reference in New Issue
Block a user