From a6a7e490e659886ff733e89e84e0c1bd17b529f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:02:29 +0000 Subject: [PATCH 1/4] feat(cli, runtime, core): os serve loads installed plugins, re-verified and dispatched by runtime tier; os plugin disable | enable | uninstall (#27123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of 3 of ADR-0025 §3.5 M1 (Claim 6485671080). - runtime: installed-plugin-loader.ts (loadInstalledPlugins, verifyInstalledPlugins, dispatchPluginRuntime). Each installation is re-checked before any of its code is imported: its own install dir, disabled, the kept artifact's sha256, the signature over the raw kept bytes against sys_trusted_key (revoked since install refuses), the unpacked tree byte-equal to the artifact, the manifest id/version, then the tier: node imports in process, sandbox/worker/undeclared refused by name. Uninstall finishes at boot with a shared-dir guard. - runtime: the installations file takes a pid lock for every read-modify-write; a stale lock (dead pid, older than 5 min, own pid while unheld, unparseable and older than 1 s) is cleared by the next writer. - core: the archive reader moves here with a bounded inflate (MAX_UNPACKED_ARTIFACT_BYTES); PluginLoader refuses a signature no host verified (recordPluginArtifactVerdict). - cli: serve registers installed plugins before bootstrap; install verifies the signature before inflating and warns on a tier the boot refuses; new disable / enable / uninstall; the shared trust read plugin-trust-read.ts. - spec: the runtime tier's describe text and TSDoc, its pin, the liveness row live-elsewhere -> live; generated reference pages regenerated. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude --- .changeset/27123-boot-time-plugin-loader.md | 27 + .../27123-runtime-tier-enforced-at-boot.md | 10 + content/docs/protocol/kernel/lifecycle.mdx | 31 +- .../references/api/package-api-assembled.mdx | 6 +- content/docs/references/api/package-api.mdx | 12 +- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 4 +- .../references/kernel/package-registry.mdx | 4 +- packages/cli/README.md | 6 +- packages/cli/src/commands/plugin/disable.ts | 59 ++ packages/cli/src/commands/plugin/enable.ts | 53 ++ .../plugin/install.integration.test.ts | 91 ++- packages/cli/src/commands/plugin/install.ts | 392 ++++++------- .../cli/src/commands/plugin/trust/revoke.ts | 7 +- packages/cli/src/commands/plugin/uninstall.ts | 72 +++ packages/cli/src/commands/serve.ts | 12 + packages/cli/src/utils/format.ts | 10 + .../cli/src/utils/installed-plugins-boot.ts | 116 ++++ packages/cli/src/utils/osplugin.ts | 42 +- packages/cli/src/utils/plugin-install.ts | 67 ++- packages/cli/src/utils/plugin-trust-read.ts | 70 +++ ...igrate.one-shot-family.integration.test.ts | 35 +- ...erve-installed-plugins.integration.test.ts | 265 +++++++++ packages/core/src/plugin-loader.ts | 36 +- packages/core/src/security/index.ts | 20 + .../src/security/plugin-artifact-archive.ts | 79 +++ .../security/plugin-artifact-verdict.test.ts | 125 +++++ .../src/security/plugin-artifact-verdict.ts | 44 ++ packages/runtime/src/index.ts | 32 +- .../src/installed-plugin-loader.test.ts | 476 ++++++++++++++++ .../runtime/src/installed-plugin-loader.ts | 518 ++++++++++++++++++ .../plugin-installation-store.lock.test.ts | 220 ++++++++ .../runtime/src/plugin-installation-store.ts | 280 +++++++++- packages/spec/liveness/manifest.json | 10 +- .../spec/liveness/state-counts/manifest.md | 2 +- packages/spec/src/kernel/manifest.zod.ts | 40 +- .../plugin-runtime-tier-truthful-text.test.ts | 25 +- 37 files changed, 2955 insertions(+), 345 deletions(-) create mode 100644 .changeset/27123-boot-time-plugin-loader.md create mode 100644 .changeset/27123-runtime-tier-enforced-at-boot.md create mode 100644 packages/cli/src/commands/plugin/disable.ts create mode 100644 packages/cli/src/commands/plugin/enable.ts create mode 100644 packages/cli/src/commands/plugin/uninstall.ts create mode 100644 packages/cli/src/utils/installed-plugins-boot.ts create mode 100644 packages/cli/src/utils/plugin-trust-read.ts create mode 100644 packages/cli/test/serve-installed-plugins.integration.test.ts create mode 100644 packages/core/src/security/plugin-artifact-archive.ts create mode 100644 packages/core/src/security/plugin-artifact-verdict.test.ts create mode 100644 packages/core/src/security/plugin-artifact-verdict.ts create mode 100644 packages/runtime/src/installed-plugin-loader.test.ts create mode 100644 packages/runtime/src/installed-plugin-loader.ts create mode 100644 packages/runtime/src/plugin-installation-store.lock.test.ts diff --git a/.changeset/27123-boot-time-plugin-loader.md b/.changeset/27123-boot-time-plugin-loader.md new file mode 100644 index 00000000000..0a3b71d1919 --- /dev/null +++ b/.changeset/27123-boot-time-plugin-loader.md @@ -0,0 +1,27 @@ +--- +'@objectstack/cli': patch +'@objectstack/runtime': patch +'@objectstack/core': patch +--- + +feat(cli, runtime, core): `os serve` loads the plugins `os plugin install` installed, re-verifying each before any of its code is imported, and dispatches on the declared `runtime` tier; `os plugin disable | enable | uninstall` (#27123) + +Clause-②: no — no key, accepted value or export of `@objectstack/spec` changes; the loader, the commands and the new exports are platform code +Metadata-protocol: none + +The second slice of ADR-0025's local install loop (§3.5 M1). Before this, an installed plugin was unpacked and recorded, and nothing loaded it. + +- **The boot-time loader** (`@objectstack/runtime`: `loadInstalledPlugins`, `verifyInstalledPlugins`). `os serve` (and `os dev` / `os start` through it) calls it after the config's own `plugins` are registered and before the kernel bootstraps, then `kernel.use`s each plugin that passed, so dependency order and health checks stay the kernel's. For each installation of this project and environment, in this order, the first failing check refuses that plugin by name and nothing of it is imported: + - the record names the directory the store derives for its id and version (`install_dir_unexpected`); nothing is read from or removed under any other; + - a disabled id is skipped, before anything of it is read; + - the kept `artifact.osplugin` hashes to the recorded sha256 (`artifact_changed`, `install_dir_missing`); + - its detached signature, over the raw kept bytes and before anything is inflated, names the recorded key id, the key is `active` in `sys_trusted_key` with the fingerprint the install verified, and it verifies (`signature_missing`, `signature_malformed`, `signature_key_mismatch`, `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`, `trusted_key_changed`, `signature_invalid`, `trusted_keys_unreadable`). So a key revoked since install refuses the plugin at its next boot. An install admitted under `--trust-unsigned` loads with a warning on every boot; + - the unpacked tree is byte-for-byte the artifact, with no other file and no symbolic link, and the artifact's manifest is the recorded id and version (`tree_changed`, `manifest_mismatch`, `artifact_unreadable`); + - **the tier dispatch point** (`dispatchPluginRuntime`, ADR-0025 §3.6): `runtime: 'node'` imports the entry module in process; `sandbox` and `worker` are refused by name until M3 routes them (`runtime_not_dispatched`), with no in-process fallback; a manifest that declares no tier is refused (`runtime_undeclared`, default-deny); + - the entry module is a file of the artifact (`entry_missing`), it imports (`import_failed`), and it exports a plugin (`not_a_plugin`) by the rule a stack's `plugins` entry takes (a plain bundle is wrapped in `AppPlugin`). + + The environment's trusted keys are read once per boot, through the same read-only one-shot boot `os plugin install` reads them through, and only when a signed install needs them; with no installations file nothing is read at all. A refused plugin does not stop the boot: it is printed as one `✗` line naming the refusal, and the banner counts the refusals. An installed plugin never replaces a plugin the boot already registered under the same name (`plugin_name_taken`). +- **The kernel's signature check is real.** `PluginLoader` used to pass any well-formed `signature` a plugin carried. The host that verified the artifact now records its verdict against the plugin object (`recordPluginArtifactVerdict`, `@objectstack/core`), and the loader refuses a plugin whose signature no host verified. No plugin in the repo sets `signature`. +- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, and the inflate is bounded (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`. +- **`os plugin disable ID` / `os plugin enable ID`** keep an installed plugin from loading, or load it again, at the next boot (the package disable-state store, keyed by the plugin environment id). **`os plugin uninstall ID`** marks the installation; the next boot loads nothing of it, removes its directory unless another project sharing the home and environment still records it, drops the record and forgets its disable. Each refuses an id this project has not installed by name (`plugin_not_installed`); `--json` on all three. +- **The installations file takes a lock** for every read-modify-write: `.lock`, created exclusively with the writer's pid. A writer waits up to 10 s for a live holder, then refuses naming it. A lock a crashed process left is cleared by the next writer: its pid is not a live process, or it is older than 5 minutes, or it names the writer's own pid while the writer holds nothing, or it cannot be parsed and is older than a second. diff --git a/.changeset/27123-runtime-tier-enforced-at-boot.md b/.changeset/27123-runtime-tier-enforced-at-boot.md new file mode 100644 index 00000000000..1a79bbedfff --- /dev/null +++ b/.changeset/27123-runtime-tier-enforced-at-boot.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the plugin manifest's `runtime` tier is described as enforced at boot for a locally installed plugin — only `node` is loaded, `sandbox` and `worker` are refused until ADR-0025 M3 routes them (#27123) + +Clause-②: no — no key or accepted value changes; `node`, `sandbox` and `worker` still parse, and an unset tier is still legal +Metadata-protocol: none + +The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point. diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index b1ec8e9166a..488fe123dcf 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -18,11 +18,13 @@ command, so no `objectstack upgrade …` line on this page is runnable. **Code-plugin installation is local and partial.** `os plugin install ./x.osplugin` verifies a local artifact's signature against the environment's -trusted keys and its per-file integrity, then unpacks it; nothing loads an -installed plugin at boot yet, and there is no `os plugin enable` or `test`. +trusted keys and its per-file integrity, then unpacks it, and the next +`os serve` re-verifies it and loads it — `runtime: 'node'` only, in process; +`sandbox` and `worker` are refused at boot until [ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md) -§3.5 M1 records the loader as the next step; see -[Plugin Installation](#plugin-installation) for what an author does today. +M3. `os plugin disable`, `enable` and `uninstall` act at the next boot; there +is no `os plugin test`. See [Plugin Installation](#plugin-installation) for +what an author does today. ObjectStack manages the complete **lifecycle** of the platform runtime—from initial boot through schema evolution and graceful shutdown, and describes the design intent for plugin installation, upgrades, and rollbacks. The boot sequence is implemented as described; the installation and upgrade flows are design intent (see the callout above). @@ -201,8 +203,11 @@ class AppPlugin implements Plugin { records and the platform ships only in part: `os plugin install ./x.osplugin` verifies a local artifact (its signature against the environment's trusted keys, its files against the manifest's `integrity` map), unpacks it and -records the installation, writing nothing until every check passes. Download -from the cloud, loading an installed plugin at boot, and the install hooks and +records the installation, writing nothing until every check passes. At the +next boot, `os serve` re-verifies each installation (the kept artifact's +sha256, its signature against the trusted keys as they are now, the unpacked +tree) and loads it by its declared tier: `node` in process, `sandbox` and +`worker` refused by name. Download from the cloud and the install hooks and registry below have no command and no runtime path. The sections below up to *Upgrades* describe that design, not a command you can run. See [What an author does today](#what-an-author-does-today). @@ -375,8 +380,8 @@ transitions (this page, above), not code hooks; boot-time code goes in ### What an author does today The one install command for a code plugin is `os plugin install`, for a -signed artifact on local disk, and nothing loads what it installs yet. What -exists, per the +signed artifact on local disk; the next `os serve` loads what it installed. +What exists, per the [CLI reference](https://github.com/objectstack-ai/objectstack/blob/main/packages/cli/README.md): - **Runtime plugins** declared in `objectstack.config.ts` `plugins` are loaded @@ -390,8 +395,12 @@ exists, per the manifest) are packaged and shipped with `os plugin build`, `os plugin sign` and `os plugin publish`. A signed `.osplugin` on local disk is installed with `os plugin install` once its publisher key is trusted (`os plugin keygen`, - `os plugin trust add`); `os serve` does not load installed plugins yet, and a - published `.osplugin` has no `os` command that fetches it from the cloud. + `os plugin trust add`). `os serve` re-verifies and loads it at boot when it + declares `runtime: 'node'` (author code in the host process, not isolated), + refuses `sandbox` and `worker` until they are routed, and prints each refusal + by name; `os plugin disable | enable | uninstall ID` take effect at the next + boot. A published `.osplugin` has no `os` command that fetches it from the + cloud. See [Plugin System](/docs/plugins) for how kernel plugins are created and registered. @@ -920,7 +929,7 @@ Plugin authors must document breaking changes in CHANGELOG.md. ObjectStack lifecycle management provides: - **Deterministic Boot:** 7-phase boot sequence with clear error handling -- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; `os plugin install` verifies a local artifact in full before it writes anything, but nothing loads an installed plugin at boot yet +- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; `os plugin install` verifies a local artifact in full before it writes anything, and `os serve` re-verifies it before loading it - **Zero-Downtime Upgrades:** Blue-green and rolling strategies for production - **Automatic Rollback:** Failed upgrades auto-revert to previous version - **Health Monitoring:** Built-in health checks validate system state diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 7b9fbe81d3d..c472485c3a8 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -105,7 +105,7 @@ Installed package row whose manifest is the assembled package body | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -288,7 +288,7 @@ This schema accepts one of the following structures: | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -351,7 +351,7 @@ This schema accepts one of the following structures: | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index a16418000aa..3cb14a6cb75 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -161,7 +161,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -207,7 +207,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -315,7 +315,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -432,7 +432,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -565,7 +565,7 @@ Upgrade package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -659,7 +659,7 @@ Resolve dependencies request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 68c397e19b2..ebc695a6f5d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2112,7 +2112,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 2c6f02925b9..88b390d450a 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -61,7 +61,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -207,7 +207,7 @@ Structured plugin permission grants (ADR-0025 §3.2) ## PluginRuntime -Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (an unverified publisher requesting `node` is rejected with HTTP 422 and forced to manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares +Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (an unverified publisher requesting `node` is rejected with HTTP 422 and forced to manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until they are routed (ADR-0025 M3), never run in process instead ### Allowed Values diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index f25f1f83e3e..41cfe069935 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -216,7 +216,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -314,7 +314,7 @@ Installed package with runtime lifecycle state | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/packages/cli/README.md b/packages/cli/README.md index b8e89a44838..5d150e15a94 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -151,9 +151,11 @@ A code-bearing plugin — a directory carrying an `objectstack.plugin.json` mani | `os plugin publish [artifact]` | Publish a signed `.osplugin` to ObjectOS Cloud | | `os plugin keygen --out ` | Generate a publisher Ed25519 key pair: `.key.pem` (private, stays with you) and `.pub.pem` | | `os plugin trust add --key-id ` | Trust a publisher public key in this environment's database (`sys_trusted_key`); `trust list` shows the keys, `trust revoke ` revokes one | -| `os plugin install ` | Verify a local `.osplugin`'s detached signature against the trusted keys and its per-file `integrity`, then unpack it to `/plugins////`; `--trust-unsigned` is the explicit dev override for an unsigned artifact | +| `os plugin install ` | Verify a local `.osplugin`'s detached signature against the trusted keys (before anything is unpacked) and its per-file `integrity`, then unpack it to `/plugins////`; `--trust-unsigned` is the explicit dev override for an unsigned artifact | +| `os plugin disable ` / `os plugin enable ` | Keep an installed plugin from loading at the next boot, or load it again | +| `os plugin uninstall ` | Mark an installed plugin for removal: the next boot loads nothing of it and removes its directory | -`os plugin install` refuses, by name, an unknown or revoked key, a signature over other bytes, a missing signature and an integrity mismatch. `os serve` does not load installed plugins yet: loading them at boot is the next step of ADR-0025's M1 install loop. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands). +`os plugin install` refuses, by name, an unknown or revoked key, a signature over other bytes, a missing signature and an integrity mismatch. The next `os serve` (or `os dev` / `os start`) re-verifies every installation before importing any of its code — the kept artifact's sha256, its signature against the trusted keys as they are then (a key revoked since install refuses it), and the unpacked tree — and loads it by its declared `runtime` tier: `node` in process (author code in the host process, not isolated); `sandbox` and `worker` are refused by name until ADR-0025 M3 routes them, and a plugin that declares no tier is refused. A refused plugin does not stop the boot; each refusal is one `✗` line naming why. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands). ### Quality diff --git a/packages/cli/src/commands/plugin/disable.ts b/packages/cli/src/commands/plugin/disable.ts new file mode 100644 index 00000000000..ef6cef9f560 --- /dev/null +++ b/packages/cli/src/commands/plugin/disable.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin disable ID` — keep an installed plugin from loading at the next + * boot, without removing it (ADR-0025 §3.5 M1). + * + * The disable is kept in the package disable-state store under OS_HOME, keyed + * by this project and the plugin environment id, and the boot-time loader + * skips a disabled id before it reads anything of it. `os plugin enable ID` + * undoes it; `os plugin uninstall ID` removes the install. Writes no database + * row. + */ + +import { Args, Command, Flags } from '@oclif/core'; +import { installedPluginDisabledIds, setInstalledPluginDisabled } from '@objectstack/runtime'; +import { emitJson, printError, printHeader, printInfo, printKV, printSuccess } from '../../utils/format.js'; +import { findPluginInstallation, pluginEnvironmentId } from '../../utils/plugin-install.js'; + +export default class PluginDisable extends Command { + static override description = + 'Keep an installed plugin from loading at the next boot, without removing it (ADR-0025 §3.5)'; + + static override examples = ['$ os plugin disable com.acme.stripe']; + + static override args = { + id: Args.string({ description: 'The installed plugin id', required: true }), + }; + + static override flags = { + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginDisable); + const json = flags.json; + if (!json) printHeader('Disable Plugin'); + const environmentId = pluginEnvironmentId(); + const found = findPluginInstallation(environmentId, args.id); + if (!found.ok) { + if (json) { + await emitJson({ error: found.error, message: found.message }, 1, { compact: true }); + return; + } + printError(found.message); + this.exit(1); + return; + } + const changed = !installedPluginDisabledIds(environmentId).has(args.id); + if (changed) setInstalledPluginDisabled(environmentId, args.id, true); + if (json) { + await emitJson({ id: args.id, version: found.record.version, environmentId, disabled: true, changed }, 0, { compact: true }); + return; + } + if (changed) printSuccess(`Disabled ${args.id}@${found.record.version}`); + else printInfo(`${args.id} was already disabled; nothing was written.`); + printKV(' Environment', environmentId); + printInfo('The next boot does not load it. `os plugin enable` loads it again.'); + } +} diff --git a/packages/cli/src/commands/plugin/enable.ts b/packages/cli/src/commands/plugin/enable.ts new file mode 100644 index 00000000000..6d169dc4824 --- /dev/null +++ b/packages/cli/src/commands/plugin/enable.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin enable ID` — undo `os plugin disable ID`: the next boot verifies + * and loads the installed plugin again (ADR-0025 §3.5 M1). Writes no database + * row. + */ + +import { Args, Command, Flags } from '@oclif/core'; +import { installedPluginDisabledIds, setInstalledPluginDisabled } from '@objectstack/runtime'; +import { emitJson, printError, printHeader, printInfo, printKV, printSuccess } from '../../utils/format.js'; +import { findPluginInstallation, pluginEnvironmentId } from '../../utils/plugin-install.js'; + +export default class PluginEnable extends Command { + static override description = 'Load a disabled installed plugin again at the next boot (ADR-0025 §3.5)'; + + static override examples = ['$ os plugin enable com.acme.stripe']; + + static override args = { + id: Args.string({ description: 'The installed plugin id', required: true }), + }; + + static override flags = { + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginEnable); + const json = flags.json; + if (!json) printHeader('Enable Plugin'); + const environmentId = pluginEnvironmentId(); + const found = findPluginInstallation(environmentId, args.id); + if (!found.ok) { + if (json) { + await emitJson({ error: found.error, message: found.message }, 1, { compact: true }); + return; + } + printError(found.message); + this.exit(1); + return; + } + const changed = installedPluginDisabledIds(environmentId).has(args.id); + if (changed) setInstalledPluginDisabled(environmentId, args.id, false); + if (json) { + await emitJson({ id: args.id, version: found.record.version, environmentId, disabled: false, changed }, 0, { compact: true }); + return; + } + if (changed) printSuccess(`Enabled ${args.id}@${found.record.version}`); + else printInfo(`${args.id} was not disabled; nothing was written.`); + printKV(' Environment', environmentId); + printInfo('The next boot re-verifies its kept artifact and loads it.'); + } +} diff --git a/packages/cli/src/commands/plugin/install.integration.test.ts b/packages/cli/src/commands/plugin/install.integration.test.ts index 748a4ad5d28..c64b2f6d446 100644 --- a/packages/cli/src/commands/plugin/install.integration.test.ts +++ b/packages/cli/src/commands/plugin/install.integration.test.ts @@ -17,6 +17,12 @@ * `--trust-unsigned`, installs an UNSIGNED artifact and says so on stderr; it * never makes a present signature pass, whatever is wrong with it. * + * [Slice 2] The signature is verified over the raw bytes BEFORE anything is + * inflated, so an artifact signed by an untrusted key is refused for its key + * even when it is not an archive at all; a tier the boot does not dispatch + * installs with a warning naming the boot's refusal; and `os plugin disable | + * enable | uninstall` refuse an id this project has not installed by name. + * * Each case builds its own home, project and database, so the cases hold in * any order and one at a time. * @@ -36,6 +42,9 @@ import { isExitSignal } from '../../utils/format.js'; import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../../utils/osplugin.js'; import { INSTALLED_ARTIFACT_FILE, INSTALLED_SIGNATURE_FILE } from '../../utils/plugin-install.js'; import PluginInstall from './install.js'; +import PluginDisable from './disable.js'; +import PluginEnable from './enable.js'; +import PluginUninstall from './uninstall.js'; import PluginTrustAdd from './trust/add.js'; import PluginTrustRevoke from './trust/revoke.js'; @@ -100,13 +109,13 @@ const KEY_ID = 'acme-2026'; const text = (t: string) => new TextEncoder().encode(t); /** A `.osplugin` built as `os plugin build` builds one, its integrity map computed unless given. */ -function artifact(name: string, opts: { id: string; version?: string; files?: ArchiveFile[]; integrity?: Record; dir?: string }): string { +function artifact(name: string, opts: { id: string; version?: string; runtime?: string; files?: ArchiveFile[]; integrity?: Record; dir?: string }): string { const files: ArchiveFile[] = opts.files ?? [{ path: 'dist/index.mjs', data: text(`export const id = ${JSON.stringify(opts.id)};\n`) }]; const manifest = { id: opts.id, version: opts.version ?? '1.0.0', type: 'plugin', - runtime: 'node', + runtime: opts.runtime ?? 'node', packaging: 'bundled', integrity: opts.integrity ?? computeIntegrity(files), }; @@ -292,6 +301,9 @@ describe('os plugin install (ADR-0025 §3.5 M1)', () => { const integrity = computeIntegrity([{ path: 'dist/index.mjs', data: text('export const declared = 1;\n') }]); const path = artifact('mismatch', { id: 'com.acme.mismatch', files, integrity }); sign(path); + // The signature is verified first (before anything is inflated), so the + // key is trusted for the integrity check to be what refuses. + await trustPublisher(); const result = await run(PluginInstall, [path, ...db(), '--json']); expectRefused(result, 'integrity_mismatch', 'com.acme.mismatch'); expect(JSON.stringify(result.payload.details)).toContain('dist/index.mjs'); @@ -301,6 +313,7 @@ describe('os plugin install (ADR-0025 §3.5 M1)', () => { const files: ArchiveFile[] = [{ path: '../escape.txt', data: text('x') }]; const path = artifact('escape', { id: 'com.acme.escape', files }); sign(path); + await trustPublisher(); expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'artifact_entries_refused', 'com.acme.escape'); expect(existsSync(join(home, 'plugins', 'env_local', 'com.acme.escape', 'escape.txt'))).toBe(false); expect(existsSync(join(home, 'plugins', 'env_local', 'escape.txt'))).toBe(false); @@ -400,4 +413,78 @@ describe('os plugin install (ADR-0025 §3.5 M1)', () => { expect(same.exitCode, same.stdout.slice(-400)).toBe(0); expect(loadPluginInstallations('env_local').map((r) => r.id)).toEqual([id]); }, CASE_TIMEOUT_MS); + + it('[slice 2] verifies the signature over the raw bytes before inflating anything', async () => { + // Not an archive at all: signed by a key nobody trusts, the refusal is the + // key's, because nothing is inflated before the signature passes. + const path = join(project, 'not-an-archive.osplugin'); + writeFileSync(path, 'these bytes are not gzip'); + sign(path, stranger.privateKeyPem, 'stranger'); + const untrusted = await run(PluginInstall, [path, ...db(), '--json']); + expect(untrusted.payload, untrusted.stdout.slice(-400)).toMatchObject({ error: 'signature_key_untrusted' }); + // Signed by the trusted key, the same bytes reach the inflate, which refuses them. + await trustPublisher(); + sign(path); + const unreadable = await run(PluginInstall, [path, ...db(), '--json']); + expect(unreadable.payload, unreadable.stdout.slice(-400)).toMatchObject({ error: 'artifact_unreadable' }); + expect(unreadable.exitCode).toBe(1); + expect(existsSync(join(home, 'plugins'))).toBe(false); + }, CASE_TIMEOUT_MS); + + it('[slice 2] installs a sandbox-tier artifact with a warning that the boot refuses it until M3', async () => { + await trustPublisher(); + const path = artifact('sandboxed', { id: 'com.acme.sandboxed', runtime: 'sandbox' }); + sign(path); + const result = await run(PluginInstall, [path, ...db(), '--json']); + expect(result.exitCode, result.stdout.slice(-400)).toBe(0); + expect(result.payload).toMatchObject({ installed: { runtime: 'sandbox' }, boot: { loads: false, error: 'runtime_not_dispatched' } }); + expect(result.stderr).toMatch(/the next boot will refuse to load it \(runtime_not_dispatched\)/); + // Control: a node artifact loads at boot, and says so. + const node = artifact('node-tier', { id: 'com.acme.node-tier' }); + sign(node); + const ok = await run(PluginInstall, [node, ...db(), '--json']); + expect(ok.payload).toMatchObject({ boot: { loads: true } }); + expect(ok.stderr).not.toMatch(/refuse to load/); + }, CASE_TIMEOUT_MS); +}); + +describe('[#27123 slice 2] os plugin disable | enable | uninstall', () => { + async function installUnsigned(id: string): Promise { + const result = await run(PluginInstall, [artifact(id, { id }), '--trust-unsigned', '--json']); + expect(result.exitCode, result.stdout.slice(-400)).toBe(0); + } + + it('refuses an id this project has not installed, by name, naming what is installed', async () => { + for (const command of [PluginDisable, PluginEnable, PluginUninstall]) { + const none = await run(command, ['com.acme.absent', '--json']); + expect(none.payload).toMatchObject({ error: 'plugin_not_installed' }); + expect(none.payload.message).toMatch(/nothing is installed/); + expect(none.exitCode).toBe(1); + } + await installUnsigned('com.acme.present'); + const named = await run(PluginDisable, ['com.acme.absent', '--json']); + expect(named.payload.message).toContain('installed: com.acme.present'); + }, CASE_TIMEOUT_MS); + + it('disable and enable flip the state once, and a repeat writes nothing', async () => { + await installUnsigned('com.acme.toggle'); + expect((await run(PluginDisable, ['com.acme.toggle', '--json'])).payload).toMatchObject({ disabled: true, changed: true }); + expect((await run(PluginDisable, ['com.acme.toggle', '--json'])).payload).toMatchObject({ disabled: true, changed: false }); + expect((await run(PluginEnable, ['com.acme.toggle', '--json'])).payload).toMatchObject({ disabled: false, changed: true }); + expect((await run(PluginEnable, ['com.acme.toggle', '--json'])).payload).toMatchObject({ disabled: false, changed: false }); + }, CASE_TIMEOUT_MS); + + it('uninstall marks the record and removes nothing yet; disable then refuses it as pending', async () => { + await installUnsigned('com.acme.leaving'); + const dir = pluginInstallDir('env_local', 'com.acme.leaving', '1.0.0'); + const marked = await run(PluginUninstall, ['com.acme.leaving', '--json']); + expect(marked.payload).toMatchObject({ status: 'uninstall-pending', dir, changed: true }); + expect(existsSync(dir)).toBe(true); + expect((await run(PluginUninstall, ['com.acme.leaving', '--json'])).payload).toMatchObject({ changed: false }); + const pending = await run(PluginDisable, ['com.acme.leaving', '--json']); + expect(pending.payload).toMatchObject({ error: 'plugin_uninstall_pending' }); + // Installing it again before the boot keeps it. + await installUnsigned('com.acme.leaving'); + expect(loadPluginInstallations('env_local')).toEqual([expect.objectContaining({ id: 'com.acme.leaving', status: 'installed' })]); + }, CASE_TIMEOUT_MS); }); diff --git a/packages/cli/src/commands/plugin/install.ts b/packages/cli/src/commands/plugin/install.ts index bb5d7df41ca..75e65bfa9fb 100644 --- a/packages/cli/src/commands/plugin/install.ts +++ b/packages/cli/src/commands/plugin/install.ts @@ -5,24 +5,29 @@ * install it into this environment (ADR-0025 §3.5 steps 4–6, M1: the local T0 * loop, first-party / self-signed plugins only). * - * 1. Read the archive and its compiled `objectstack.plugin.json`; refuse an - * entry that could land outside the install directory. - * 2. Re-verify every file against the manifest's `integrity` map; refuse a + * 1. Verify the detached signature (`.sig`, from `os plugin sign`) + * over the whole artifact, BEFORE anything is inflated: a signature + * covers the compressed bytes, so nothing an untrusted key produced + * reaches gunzip. Its key id is resolved in this environment's trusted + * keys (`sys_trusted_key`, through the READ-ONLY one-shot boot of + * `../../utils/plugin-trust-read.ts`, the read `os serve`'s boot-time + * loader makes too), and the raw bytes are verified with that key by + * `verifyPublisherSignature`. Refused by name: no signature, a malformed + * one, a key id this environment does not trust, a revoked key id, and a + * signature that does not match the bytes. + * 2. Read the archive (the inflate bounded, `MAX_UNPACKED_ARTIFACT_BYTES`) + * and its compiled `objectstack.plugin.json`; refuse an entry that could + * land outside the install directory. + * 3. Re-verify every file against the manifest's `integrity` map; refuse a * digest mismatch, a declared file that is missing, or a file the map * does not declare. - * 3. Verify the detached signature (`.sig`, from `os plugin sign`) - * over the whole artifact. Its key id is resolved in this environment's - * trusted keys (`sys_trusted_key`, through a READ-ONLY one-shot boot of - * the environment's database), and the bytes are verified with that key by - * `verifyPluginArtifact`. Refused by name: no signature, a malformed one, - * a key id this environment does not trust, a revoked key id, and a - * signature that does not match the bytes. - * 4. Before anything is written, read this project's installation records - * (refusing a file it cannot read, which a write would clobber) and - * refuse an install directory that already holds other bytes no record - * of this project owns: two projects sharing one `OS_HOME` and one - * environment id share the directory, and one must not overwrite the - * other's install. + * 4. Holding the installations lock from here to the record, so a second + * install of this project cannot interleave, read this project's + * installation records (refusing a file it cannot read, which a write + * would clobber) and refuse an install directory that already holds + * other bytes no record of this project owns: two projects sharing one + * `OS_HOME` and one environment id share the directory, and one must + * not overwrite the other's install. * 5. Materialize to `/plugins////`, keeping the * original artifact and its signature beside the unpacked files. * 6. Record the installation — key id and fingerprint included — in the @@ -34,6 +39,12 @@ * is refused with or without it. There is no "unsigned with a warning" arm * without the flag. * + * The next `os serve` (or `os dev` / `os start`) loads the install through the + * boot-time loader (`@objectstack/runtime`'s `installed-plugin-loader.ts`), + * which re-verifies the kept artifact first. Only `runtime: 'node'` is + * dispatched in M1: an artifact declaring `sandbox` or `worker` installs, with + * a warning, and is refused by name at boot until M3 routes those tiers. + * * Writes no database row in any mode. M1 is first-party / self-signed only and * is listed on no marketplace surface: a T0 plugin is author code in the host * process (ADR-0025 §3.6). @@ -47,21 +58,18 @@ import { formatIntegrityViolation, parseSignature, verifyIntegrity, - verifyPluginArtifact, + verifyPublisherSignature, } from '@objectstack/core'; import { - DEFAULT_PLUGIN_ENVIRONMENT_ID, - TRUSTED_KEY_OBJECT, loadPluginInstallations, pluginInstallDir, + dispatchPluginRuntime, recordPluginInstallation, - resolveTrustedKey, + withPluginInstallationsLock, type PluginInstallationRecord, - type TrustedKeyResolution, } from '@objectstack/runtime'; import { emitJson, - isExitSignal, printError, printHeader, printKV, @@ -70,15 +78,14 @@ import { printWarningToStderr, } from '../../utils/format.js'; import { MANIFEST_FILENAME, SIGNATURE_FILENAME, readTarGz, sha256Hex, type ArchiveFile } from '../../utils/osplugin.js'; -import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { absentTableReads } from '../../utils/absent-table-reads.js'; -import { NO_TRUST_STORE_ENGINE, trustStoreEngine, trustStorePlugins } from '../../utils/plugin-trust-store.js'; +import { readTrustedKeys } from '../../utils/plugin-trust-read.js'; import { INSTALLED_ARTIFACT_FILE, INSTALLED_SIGNATURE_FILE, archiveEntryProblems, installedArtifactSha, materializePlugin, + pluginEnvironmentId, } from '../../utils/plugin-install.js'; /** One refusal: a machine-readable name and the sentence an operator reads. */ @@ -132,18 +139,17 @@ export default class PluginInstall extends Command { this.exit(1); }; - // 1. The archive and its manifest. ───────────────────────────────── + // 1. The signature, against this environment's trusted keys — over the + // raw bytes, before anything is inflated. ───────────────────────── const artifactPath = resolvePath(process.cwd(), args.artifact); if (!existsSync(artifactPath)) { await refuse({ error: 'artifact_not_found', message: `Artifact not found: ${args.artifact}` }); return; } let bytes: Uint8Array; - let files: ArchiveFile[]; try { if (!statSync(artifactPath).isFile()) throw new Error('not a regular file'); bytes = new Uint8Array(await readFile(artifactPath)); - files = readTarGz(bytes); } catch (error) { await refuse({ error: 'artifact_unreadable', @@ -151,69 +157,7 @@ export default class PluginInstall extends Command { }); return; } - const entryProblems = archiveEntryProblems(files); - if (entryProblems.length > 0) { - await refuse({ - error: 'artifact_entries_refused', - message: `${args.artifact} has ${entryProblems.length} entr${entryProblems.length === 1 ? 'y' : 'ies'} that cannot be unpacked safely; nothing was installed:`, - details: entryProblems, - }); - return; - } - let manifest: Record; - try { - const entry = files.find((f) => f.path === MANIFEST_FILENAME); - if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in the artifact`); - const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILENAME} is not a JSON object`); - manifest = parsed as Record; - } catch (error) { - await refuse({ error: 'manifest_unreadable', message: `Cannot read the artifact's manifest: ${(error as Error).message}` }); - return; - } - const id = typeof manifest.id === 'string' ? manifest.id.trim() : ''; - const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; - if (!id || !version) { - await refuse({ error: 'manifest_incomplete', message: 'The artifact manifest is missing `id` or `version`. Rebuild it with `os plugin build`.' }); - return; - } - const environmentId = process.env.OS_ENVIRONMENT_ID?.trim() || DEFAULT_PLUGIN_ENVIRONMENT_ID; - let installDir: string; - try { - installDir = pluginInstallDir(environmentId, id, version); - } catch (error) { - await refuse({ error: 'manifest_unsafe_identity', message: (error as Error).message }); - return; - } - if (!json) printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`); - - // 2. Per-file integrity, re-verified at unpack (ADR-0025 §3.2). ───── - const declaredIntegrity = manifest.integrity; - if ( - declaredIntegrity !== undefined && declaredIntegrity !== null - && (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity)) - ) { - await refuse({ - error: 'integrity_malformed', - message: 'The artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild it with `os plugin build`.', - }); - return; - } - const integrity = verifyIntegrity(files, declaredIntegrity as Record | undefined, { - exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME], - }); - if (!integrity.ok) { - await refuse({ - error: 'integrity_mismatch', - message: - `Integrity check failed — the artifact's files do not match its manifest \`integrity\` digests ` - + `(${integrity.violations.length} violation${integrity.violations.length === 1 ? '' : 's'}); nothing was installed:`, - details: integrity.violations.map((v) => formatIntegrityViolation(v)), - }); - return; - } - // 3. The signature, against this environment's trusted keys. ──────── const sigPath = resolvePath(process.cwd(), flags.sig ?? `${args.artifact}.sig`); let signature: string | null = null; if (existsSync(sigPath)) { @@ -246,10 +190,6 @@ export default class PluginInstall extends Command { }); return; } - printWarningToStderr( - `--trust-unsigned: installing ${id}@${version} WITHOUT a signature. Nothing proves who built these bytes; ` - + 'the installation record keeps this override. Dev use only.', - ); } else { const parsed = parseSignature(signature); if (!parsed) { @@ -263,11 +203,12 @@ export default class PluginInstall extends Command { printStep('--trust-unsigned has no effect here: the artifact carries a signature, and a present signature is always verified.'); } - const resolved = await this.resolveKey(parsed.keyId, flags['database-url'], json); - if ('error' in resolved) { - await refuse(resolved); + const read = await readTrustedKeys([parsed.keyId], { databaseUrl: flags['database-url'], json }); + if (!read.ok) { + await refuse({ error: read.error, message: read.message }); return; } + const resolved = read.keys.get(parsed.keyId) ?? { verdict: 'unknown' as const }; if (resolved.verdict === 'unknown') { await refuse({ error: 'signature_key_untrusted', @@ -294,19 +235,17 @@ export default class PluginInstall extends Command { const publicKey = resolved.publicKey; let verdict; try { - verdict = await verifyPluginArtifact( - { artifact: bytes, version: { package_id: id, version, signature } }, - { - getPublisherPublicKey: (keyId) => (keyId === parsed.keyId ? publicKey : null), - // M1 is local: no marketplace counter-signature exists for a self-signed artifact. - requirePlatform: false, - }, + // The publisher chain only: M1 is local, and no marketplace + // counter-signature exists for a self-signed artifact. + verdict = await verifyPublisherSignature( + { artifact: bytes, signature }, + (keyId) => (keyId === parsed.keyId ? publicKey : null), ); } catch (error) { await refuse({ error: 'signature_verification_failed', message: `Signature verification failed: ${(error as Error).message}` }); return; } - if (!verdict.ok || !verdict.publisherVerified) { + if (!verdict.ok || !verdict.verified) { await refuse({ error: 'signature_invalid', message: @@ -318,68 +257,179 @@ export default class PluginInstall extends Command { trusted = { keyId: parsed.keyId, fingerprint: resolved.row.fingerprint }; } - // 4. Before anything is written. ────────────────────────────────── - let installations: PluginInstallationRecord[]; + // 2. The archive and its manifest. ───────────────────────────────── + let files: ArchiveFile[]; try { - installations = loadPluginInstallations(environmentId); + files = readTarGz(bytes); } catch (error) { - await refuse({ error: 'record_unreadable', message: `${(error as Error).message} Nothing was installed.` }); + await refuse({ + error: 'artifact_unreadable', + message: `${args.artifact} is not a readable .osplugin (gzipped tar): ${(error as Error).message}`, + }); return; } - const artifactSha = sha256Hex(bytes); - const presentSha = installedArtifactSha(installDir); - if ( - presentSha !== undefined - && presentSha !== artifactSha - && !installations.some((r) => r.id === id && r.dir === installDir && r.artifact?.sha256 === presentSha) - ) { + const entryProblems = archiveEntryProblems(files); + if (entryProblems.length > 0) { await refuse({ - error: 'install_dir_taken', - message: - `${installDir} already holds ${id}@${version} ` - + (presentSha === null ? `with no readable ${INSTALLED_ARTIFACT_FILE}` : `built from other bytes (artifact sha256 ${presentSha})`) - + ', and no installation of this project put it there: another project sharing this OS_HOME and environment did, ' - + 'or something else wrote it. Nothing was installed. Give this project its own environment (OS_ENVIRONMENT_ID) ' - + 'or home (OS_HOME), or publish the change under a new version.', + error: 'artifact_entries_refused', + message: `${args.artifact} has ${entryProblems.length} entr${entryProblems.length === 1 ? 'y' : 'ies'} that cannot be unpacked safely; nothing was installed:`, + details: entryProblems, }); return; } - - // 5. Materialize. 6. Record. ─────────────────────────────────────── - let replaced: boolean; + let manifest: Record; try { - ({ replaced } = materializePlugin({ dir: installDir, files, artifact: bytes, signature })); + const entry = files.find((f) => f.path === MANIFEST_FILENAME); + if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in the artifact`); + const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILENAME} is not a JSON object`); + manifest = parsed as Record; } catch (error) { - await refuse({ error: 'materialize_failed', message: `Cannot write ${installDir}: ${(error as Error).message}` }); + await refuse({ error: 'manifest_unreadable', message: `Cannot read the artifact's manifest: ${(error as Error).message}` }); return; } - const permissions = manifest.permissions; - const record: PluginInstallationRecord = { - id, - version, - versionId: `${id}@${version}`, - runtime: typeof manifest.runtime === 'string' ? manifest.runtime : null, - status: 'installed', - grantedPermissions: permissions && typeof permissions === 'object' && !Array.isArray(permissions) ? permissions : null, - dir: installDir, - artifact: { file: INSTALLED_ARTIFACT_FILE, sha256: artifactSha }, - signatureFile: signature === null ? null : INSTALLED_SIGNATURE_FILE, - signature: trusted, - trustUnsigned: signature === null, - installedAt: new Date().toISOString(), - }; + const id = typeof manifest.id === 'string' ? manifest.id.trim() : ''; + const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; + if (!id || !version) { + await refuse({ error: 'manifest_incomplete', message: 'The artifact manifest is missing `id` or `version`. Rebuild it with `os plugin build`.' }); + return; + } + const environmentId = pluginEnvironmentId(); + let installDir: string; try { - recordPluginInstallation(environmentId, record); + installDir = pluginInstallDir(environmentId, id, version); } catch (error) { + await refuse({ error: 'manifest_unsafe_identity', message: (error as Error).message }); + return; + } + if (!json) printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`); + if (signature === null) { + printWarningToStderr( + `--trust-unsigned: installing ${id}@${version} WITHOUT a signature. Nothing proves who built these bytes; ` + + 'the installation record keeps this override, and every boot that loads it says so. Dev use only.', + ); + } + + // 3. Per-file integrity, re-verified at unpack (ADR-0025 §3.2). ───── + const declaredIntegrity = manifest.integrity; + if ( + declaredIntegrity !== undefined && declaredIntegrity !== null + && (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity)) + ) { await refuse({ - error: 'record_failed', - message: `${installDir} was written, but the installation was not recorded: ${(error as Error).message}`, + error: 'integrity_malformed', + message: 'The artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild it with `os plugin build`.', }); return; } + const integrity = verifyIntegrity(files, declaredIntegrity as Record | undefined, { + exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME], + }); + if (!integrity.ok) { + await refuse({ + error: 'integrity_mismatch', + message: + `Integrity check failed — the artifact's files do not match its manifest \`integrity\` digests ` + + `(${integrity.violations.length} violation${integrity.violations.length === 1 ? '' : 's'}); nothing was installed:`, + details: integrity.violations.map((v) => formatIntegrityViolation(v)), + }); + return; + } + + // 4. Before anything is written. 5. Materialize. 6. Record. ───────── + // Under the installations lock throughout, so the check that the install + // directory is this project's to write and the record that follows the + // write cannot interleave with another install of this project. + const artifactSha = sha256Hex(bytes); + const permissions = manifest.permissions; + let outcome: { refusal: Refusal } | { record: PluginInstallationRecord; replaced: boolean }; + try { + outcome = withPluginInstallationsLock(environmentId, () => { + let installations: PluginInstallationRecord[]; + try { + installations = loadPluginInstallations(environmentId); + } catch (error) { + return { refusal: { error: 'record_unreadable', message: `${(error as Error).message} Nothing was installed.` } }; + } + const presentSha = installedArtifactSha(installDir); + if ( + presentSha !== undefined + && presentSha !== artifactSha + && !installations.some((r) => r.id === id && r.dir === installDir && r.artifact?.sha256 === presentSha) + ) { + return { + refusal: { + error: 'install_dir_taken', + message: + `${installDir} already holds ${id}@${version} ` + + (presentSha === null ? `with no readable ${INSTALLED_ARTIFACT_FILE}` : `built from other bytes (artifact sha256 ${presentSha})`) + + ', and no installation of this project put it there: another project sharing this OS_HOME and environment did, ' + + 'or something else wrote it. Nothing was installed. Give this project its own environment (OS_ENVIRONMENT_ID) ' + + 'or home (OS_HOME), or publish the change under a new version.', + }, + }; + } + + let replaced: boolean; + try { + ({ replaced } = materializePlugin({ dir: installDir, files, artifact: bytes, signature })); + } catch (error) { + return { refusal: { error: 'materialize_failed', message: `Cannot write ${installDir}: ${(error as Error).message}` } }; + } + const record: PluginInstallationRecord = { + id, + version, + versionId: `${id}@${version}`, + runtime: typeof manifest.runtime === 'string' ? manifest.runtime : null, + status: 'installed', + grantedPermissions: permissions && typeof permissions === 'object' && !Array.isArray(permissions) ? permissions : null, + dir: installDir, + artifact: { file: INSTALLED_ARTIFACT_FILE, sha256: artifactSha }, + signatureFile: signature === null ? null : INSTALLED_SIGNATURE_FILE, + signature: trusted, + trustUnsigned: signature === null, + installedAt: new Date().toISOString(), + }; + try { + recordPluginInstallation(environmentId, record); + } catch (error) { + return { + refusal: { + error: 'record_failed', + message: `${installDir} was written, but the installation was not recorded: ${(error as Error).message}`, + }, + }; + } + return { record, replaced }; + }); + } catch (error) { + // The lock itself: another live writer held it past the wait. + outcome = { refusal: { error: 'installations_locked', message: `${(error as Error).message} Nothing was installed.` } }; + } + if ('refusal' in outcome) { + await refuse(outcome.refusal); + return; + } + const { record, replaced } = outcome; + // ADR-0025 §3.6: only `node` is dispatched in M1. + const tier = dispatchPluginRuntime(record.runtime ?? undefined); + if (!tier.ok) { + printWarningToStderr( + `${id}@${version} is installed, and the next boot will refuse to load it (${tier.error}): ${tier.message}`, + ); + } if (json) { - await emitJson({ installed: record, replaced, integrity: { checked: integrity.checked, skipped: integrity.skipped } }, 0, { compact: true }); + await emitJson( + { + installed: record, + replaced, + integrity: { checked: integrity.checked, skipped: integrity.skipped }, + boot: tier.ok ? { loads: true } : { loads: false, error: tier.error }, + }, + 0, + { compact: true }, + ); return; } printSuccess(`Installed ${id}@${version}${replaced ? ' (replaced the copy installed before)' : ''}`); @@ -392,48 +442,10 @@ export default class PluginInstall extends Command { : `${integrity.checked} file(s) verified against the manifest digests`, ); printKV(' Signature', trusted ? `verified with trusted key '${trusted.keyId}' (${trusted.fingerprint})` : 'NONE — installed under --trust-unsigned'); - printStep('`os serve` does not load installed plugins yet: the boot-time loader is the next step of this install loop.'); - } - - /** - * Resolve `keyId` in the environment's `sys_trusted_key` through the - * READ-ONLY one-shot boot. A table the boot measured absent has never - * trusted a key, so the key is unknown there. - */ - private async resolveKey( - keyId: string, - databaseUrl: string | undefined, - json: boolean, - ): Promise { - let stack; - try { - stack = await bootSchemaStack({ - jsonOutput: json, - databaseUrl, - extraPlugins: await trustStorePlugins(false), - deferSchemaDdl: true, - readOnlyProbe: true, - }); - } catch (error) { - return { error: 'boot_failed', message: error instanceof Error ? error.message : String(error) }; - } - try { - const engine = trustStoreEngine(stack); - if (!engine) return { error: 'no_engine', message: NO_TRUST_STORE_ENGINE }; - const reads = absentTableReads(stack); - if (reads.absent(TRUSTED_KEY_OBJECT)) { - reads.notice(json); - return { verdict: 'unknown' }; - } - return await resolveTrustedKey(engine, keyId); - } catch (error) { - if (isExitSignal(error)) throw error; - return { - error: 'trusted_keys_unreadable', - message: `Cannot read this environment's trusted keys: ${error instanceof Error ? error.message : String(error)}`, - }; - } finally { - await stack.shutdown(); - } + printStep( + tier.ok + ? 'The next `os serve` (or `os dev` / `os start`) re-verifies the kept artifact and loads it.' + : 'The next `os serve` refuses to load it until its tier is dispatched (ADR-0025 M3).', + ); } } diff --git a/packages/cli/src/commands/plugin/trust/revoke.ts b/packages/cli/src/commands/plugin/trust/revoke.ts index f60bedae439..a13e4d32057 100644 --- a/packages/cli/src/commands/plugin/trust/revoke.ts +++ b/packages/cli/src/commands/plugin/trust/revoke.ts @@ -7,8 +7,9 @@ * Flips the `sys_trusted_key` row's `status` to `revoked` through a one-shot * boot of the environment's database, with the audit CRUD mirror armed so the * act leaves a `sys_audit_log` row. The row is kept: from then on - * `os plugin install` refuses an artifact signed under `ID` by name, and - * `os plugin trust add` refuses to trust any key under `ID` again. + * `os plugin install` refuses an artifact signed under `ID` by name, the next + * boot refuses to load a plugin installed under it, and `os plugin trust add` + * refuses to trust any key under `ID` again. * * Operator-only: there is no HTTP surface, and the store's API is read-only. */ @@ -75,7 +76,7 @@ export default class PluginTrustRevoke extends Command { } printKV(' Fingerprint', result.row.fingerprint); printKV(' Database', stack.dbLabel); - printInfo('`os plugin install` refuses artifacts signed under this key id from now on.'); + printInfo('`os plugin install` refuses artifacts signed under this key id from now on, and the next boot refuses to load any installed under it.'); } catch (error) { if (isExitSignal(error)) throw error; printError(error instanceof Error ? error.message : String(error)); diff --git a/packages/cli/src/commands/plugin/uninstall.ts b/packages/cli/src/commands/plugin/uninstall.ts new file mode 100644 index 00000000000..df6c3a3670d --- /dev/null +++ b/packages/cli/src/commands/plugin/uninstall.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin uninstall ID` — remove an installed plugin (ADR-0025 §3.5 M1). + * + * Marks the installation `uninstall-pending` under the installations lock and + * removes nothing yet: a running `os serve` may have the plugin's code + * imported from its directory. The next boot loads nothing of it, removes the + * install directory — unless another project sharing this OS_HOME and + * environment still records it, when the directory is kept for that project — + * then drops the record and forgets any disable of the id. Installing the id + * again before that boot replaces the mark. Writes no database row. + */ + +import { Args, Command, Flags } from '@oclif/core'; +import { markPluginUninstall } from '@objectstack/runtime'; +import { emitJson, printError, printHeader, printInfo, printKV, printSuccess } from '../../utils/format.js'; +import { findPluginInstallation, pluginEnvironmentId } from '../../utils/plugin-install.js'; + +export default class PluginUninstall extends Command { + static override description = + 'Remove an installed plugin: the next boot loads nothing of it and removes its directory (ADR-0025 §3.5)'; + + static override examples = ['$ os plugin uninstall com.acme.stripe']; + + static override args = { + id: Args.string({ description: 'The installed plugin id', required: true }), + }; + + static override flags = { + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginUninstall); + const json = flags.json; + if (!json) printHeader('Uninstall Plugin'); + const refuse = async (error: string, message: string): Promise => { + if (json) { + await emitJson({ error, message }, 1, { compact: true }); + return; + } + printError(message); + this.exit(1); + }; + const environmentId = pluginEnvironmentId(); + const found = findPluginInstallation(environmentId, args.id, { pendingIsInstalled: true }); + if (!found.ok) { + await refuse(found.error, found.message); + return; + } + const changed = found.record.status !== 'uninstall-pending'; + let record = found.record; + if (changed) { + try { + record = markPluginUninstall(environmentId, args.id) ?? record; + } catch (error) { + await refuse('installations_locked', `${(error as Error).message} Nothing was changed.`); + return; + } + } + if (json) { + await emitJson({ id: args.id, version: record.version, environmentId, status: record.status, dir: record.dir, changed }, 0, { compact: true }); + return; + } + if (changed) printSuccess(`Marked ${args.id}@${record.version} for removal`); + else printInfo(`${args.id} is already marked for removal; nothing was written.`); + printKV(' Directory', record.dir); + printKV(' Environment', environmentId); + printInfo('The next boot loads nothing of it and removes its directory, unless another project here still records it.'); + } +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index b794e88fd40..aae4aded4cb 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -10,6 +10,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; +import { registerInstalledPlugins } from '../utils/installed-plugins-boot.js'; import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js'; import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js'; import { makeDirectorySync } from '../utils/make-directory.js'; @@ -4731,6 +4732,16 @@ export default class Serve extends Command { } } + // [ADR-0025 §3.5 M1] The plugins `os plugin install` installed for this + // project and environment, verified again before any of their code is + // imported (the kept artifact's sha256, its signature against the + // environment's trusted keys, the unpacked tree, the `runtime` tier) and + // registered here, after the config's own `plugins`, while the kernel + // still takes plugins. Only `runtime: 'node'` is dispatched; a refused + // plugin is printed by name and the boot goes on without it. No + // installations file, no read. See `../utils/installed-plugins-boot.ts`. + const installedPluginsBoot = await registerInstalledPlugins({ kernel, track: trackPlugin }); + // Register REST API and Dispatcher plugins (consume http.server + protocol services) // // [#22202] The REST API plugin is composed on EVERY boot; only the @@ -5794,6 +5805,7 @@ export default class Serve extends Command { isDev, pluginCount: loadedPlugins.length, pluginNames: loadedPlugins, + installedPluginsRefused: installedPluginsBoot?.refused.length ?? 0, uiEnabled: enableUI, consolePath: loadedPlugins.includes('ConsoleUI') ? CONSOLE_PATH : undefined, driverLabel: resolvedDriverLabel, diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index e45389e7c18..7b135df99a5 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -791,6 +791,12 @@ export interface ServerReadyOptions { isDev: boolean; pluginCount: number; pluginNames?: string[]; + /** + * [ADR-0025 §3.5 M1] Installed plugins (`os plugin install`) the boot-time + * loader refused, each already printed by name as a `✗` line. `0` or absent: + * no row. + */ + installedPluginsRefused?: number; uiEnabled?: boolean; consolePath?: string; /** Resolved storage driver display name (e.g. "MongoDBDriver", "SqlDriver(pg)"). */ @@ -1280,6 +1286,10 @@ export function printServerReady(opts: ServerReadyOptions) { if (opts.pluginNames && opts.pluginNames.length > 0) { console.error(chalk.dim(` ${opts.pluginNames.join(', ')}`)); } + if (opts.installedPluginsRefused && opts.installedPluginsRefused > 0) { + const n = opts.installedPluginsRefused; + console.error(chalk.yellow(` ⚠ ${n} installed plugin${n === 1 ? ' was' : 's were'} refused and not loaded (the ✗ lines above say why)`)); + } // [#22073] Print-once: every banner section that RESTATES a boot-phase logger // record hands back the record it restated, at the moment it prints its own // line, and `printBootDiagnostics` withholds exactly those. See diff --git a/packages/cli/src/utils/installed-plugins-boot.ts b/packages/cli/src/utils/installed-plugins-boot.ts new file mode 100644 index 00000000000..ccca3f9b8e4 --- /dev/null +++ b/packages/cli/src/utils/installed-plugins-boot.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { existsSync } from 'node:fs'; +import chalk from 'chalk'; +import type { Plugin } from '@objectstack/core'; +import { + loadInstalledPlugins, + pluginInstallationsFile, + type InstalledPluginRefusal, +} from '@objectstack/runtime'; +import { pluginEnvironmentId } from './plugin-install.js'; +import { readTrustedKeys } from './plugin-trust-read.js'; + +/** + * `os serve`'s half of the boot-time loader for plugins `os plugin install` + * installed (ADR-0025 §3.5 M1). Called once, after the config's `plugins` are + * registered and before the kernel bootstraps, which takes no plugin after it + * starts. + * + * The verification is `@objectstack/runtime`'s `loadInstalledPlugins`; this + * supplies the host's parts: the environment's trusted keys, read once through + * the read-only one-shot boot `os plugin install` reads them through + * (`./plugin-trust-read.ts`, against `OS_DATABASE_URL` or the project + * database, as the `os plugin` commands resolve it); `AppPlugin` for a plain + * bundle; and `kernel.use` for each plugin that passed, so dependency order + * and health checks are the kernel's. + * + * A refused plugin does not stop the boot — the config `plugins` loop's + * precedent — but nothing of it runs and it is printed by name, one `✗` line + * each, with the count on the banner. An installed plugin never supersedes a + * plugin this boot already registered under the same name (the kernel's + * same-name rule would replace the host's): it is refused instead. + * + * No installations file for this project and environment: nothing is read, + * and the boot is as it was. + */ +export interface InstalledPluginsBoot { + loaded: string[]; + refused: InstalledPluginRefusal[]; +} + +interface BootKernel { + use(plugin: Plugin): Promise; + hasPlugin(name: string): boolean; +} + +export async function registerInstalledPlugins(input: { + kernel: BootKernel; + track(name: string): void; +}): Promise { + const environmentId = pluginEnvironmentId(); + if (!existsSync(pluginInstallationsFile(environmentId))) return null; + + const result = await loadInstalledPlugins({ + environmentId, + resolveKeys: async (keyIds) => { + const read = await readTrustedKeys(keyIds, { databaseUrl: process.env.OS_DATABASE_URL, json: false }); + if (!read.ok) throw new Error(read.message); + return read.keys; + }, + wrapBundle: async (bundle) => { + const { AppPlugin } = await import('@objectstack/runtime'); + return new AppPlugin(bundle); + }, + }); + + const out: InstalledPluginsBoot = { loaded: [], refused: [...result.refused] }; + for (const { record, kept } of result.removed) { + console.error( + chalk.dim( + ` Uninstalled plugin ${record.id}@${record.version}` + + (kept ? ` (its directory ${record.dir} is kept: another project here still records it)` : ''), + ), + ); + } + for (const record of result.disabled) { + console.error(chalk.dim(` Installed plugin ${record.id}@${record.version} is disabled (os plugin enable ${record.id})`)); + } + for (const { record, plugin } of result.loaded) { + const name = String((plugin as { name?: unknown }).name ?? record.id); + if (input.kernel.hasPlugin(name)) { + out.refused.push({ + id: record.id, + version: record.version, + error: 'plugin_name_taken', + message: `its plugin name '${name}' is a plugin this boot already registered, and an installed plugin does not replace one.`, + }); + continue; + } + try { + await input.kernel.use(plugin as Plugin); + } catch (error) { + out.refused.push({ + id: record.id, + version: record.version, + error: 'kernel_refused', + message: (error as Error).message, + }); + continue; + } + if (record.trustUnsigned) { + console.error( + chalk.yellow( + ` ⚠ Installed plugin ${record.id}@${record.version} loaded UNSIGNED (installed with --trust-unsigned). ` + + 'Nothing proves who built it. Dev use only.', + ), + ); + } + input.track(name); + out.loaded.push(`${record.id}@${record.version}`); + } + for (const r of out.refused) { + console.error(chalk.red(` ✗ Refused installed plugin ${r.id}@${r.version} (${r.error}): ${r.message}`)); + } + return out; +} diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts index 470b0eae525..9fee8df5ec0 100644 --- a/packages/cli/src/utils/osplugin.ts +++ b/packages/cli/src/utils/osplugin.ts @@ -27,16 +27,18 @@ * * Everything here is pure (no oclif / filesystem) so it can be unit-tested * and reused by `os plugin sign` / `publish` / `install`. + * + * The READER (`readTar`, `readTarGz` with its bounded gunzip, `sha256Hex`) + * lives in `@objectstack/core` since the runtime's boot-time loader re-reads + * the artifact an install kept (ADR-0025 §3.5 M1); it is re-exported here so + * every CLI caller keeps its import. */ import { createHash } from 'node:crypto'; -import { gzipSync, gunzipSync } from 'node:zlib'; +import { gzipSync } from 'node:zlib'; +import { readTarGz, type ArchiveFile } from '@objectstack/core'; -/** A single file destined for the archive. `path` is POSIX, archive-relative. */ -export interface ArchiveFile { - path: string; - data: Uint8Array; -} +export { readTar, readTarGz, sha256Hex, MAX_UNPACKED_ARTIFACT_BYTES, type ArchiveFile } from '@objectstack/core'; /** * Subresource-Integrity-style digest of `bytes`: `sha256-`. @@ -49,11 +51,6 @@ export function sriDigest(bytes: Uint8Array): string { return 'sha256-' + createHash('sha256').update(bytes).digest('base64'); } -/** Plain hex sha256 of bytes (used for the whole-artifact checksum). */ -export function sha256Hex(bytes: Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} - /** * Build the per-file `integrity` map (archive-relative POSIX path → * `sha256-`) for the given files. The compiled manifest itself @@ -136,29 +133,6 @@ export function createTarGz(files: ArchiveFile[]): Buffer { return gzipSync(createTar(files), { level: 9 }); } -/** - * Parse a ustar buffer back into its files — the inverse of {@link createTar}. - * Reads regular-file entries only; stops at the end-of-archive zero block. - */ -export function readTar(buf: Uint8Array): ArchiveFile[] { - const b = Buffer.from(buf); - const files: ArchiveFile[] = []; - let off = 0; - while (off + BLOCK <= b.length) { - const name = b.toString('utf8', off, off + 100).replace(/\0.*$/s, ''); - if (!name) break; // zero block → end of archive - const size = parseInt(b.toString('ascii', off + 124, off + 136).replace(/\0.*$/s, '').trim(), 8) || 0; - files.push({ path: name, data: new Uint8Array(b.subarray(off + BLOCK, off + BLOCK + size)) }); - off += BLOCK + Math.ceil(size / BLOCK) * BLOCK; - } - return files; -} - -/** gunzip + untar an `.osplugin` blob into its files. */ -export function readTarGz(blob: Uint8Array): ArchiveFile[] { - return readTar(gunzipSync(Buffer.from(blob))); -} - /** * Extract + parse the compiled `objectstack.plugin.json` from an `.osplugin` * blob. Throws if the manifest is absent or not valid JSON. diff --git a/packages/cli/src/utils/plugin-install.ts b/packages/cli/src/utils/plugin-install.ts index 75a21a5574e..3083cb5f4f6 100644 --- a/packages/cli/src/utils/plugin-install.ts +++ b/packages/cli/src/utils/plugin-install.ts @@ -2,6 +2,13 @@ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, posix } from 'node:path'; +import { + DEFAULT_PLUGIN_ENVIRONMENT_ID, + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + loadPluginInstallations, + type PluginInstallationRecord, +} from '@objectstack/runtime'; import { sha256Hex, type ArchiveFile } from './osplugin.js'; import { makeDirectorySync } from './make-directory.js'; @@ -13,12 +20,62 @@ import { makeDirectorySync } from './make-directory.js'; /** * The names the install directory keeps the ORIGINAL artifact and its detached - * signature under, beside the unpacked files, so a loader can re-verify the - * signed bytes rather than the unpacked copy. No archive entry may take either - * name. + * signature under, beside the unpacked files, so the boot-time loader + * re-verifies the signed bytes rather than the unpacked copy. No archive entry + * may take either name. Declared beside the installation store in + * `@objectstack/runtime`, whose loader reads them. */ -export const INSTALLED_ARTIFACT_FILE = 'artifact.osplugin'; -export const INSTALLED_SIGNATURE_FILE = 'artifact.osplugin.sig'; +export { INSTALLED_ARTIFACT_FILE, INSTALLED_SIGNATURE_FILE }; + +/** + * The environment id local plugin installations are keyed by: `OS_ENVIRONMENT_ID`, + * else `env_local`, the id `os serve` stamps. `os plugin install | disable | + * enable | uninstall` write under it and `os serve`'s boot-time loader reads + * under it, so all of them name one set of installations. + */ +export function pluginEnvironmentId(): string { + return process.env.OS_ENVIRONMENT_ID?.trim() || DEFAULT_PLUGIN_ENVIRONMENT_ID; +} + +/** + * This project's installation of `id` in `environmentId`, or the refusal + * `os plugin disable | enable | uninstall` print: the installations file + * cannot be read, no installation of `id` is recorded, or (unless + * `pendingIsInstalled`) it is already marked for removal at the next boot. + */ +export function findPluginInstallation( + environmentId: string, + id: string, + opts: { pendingIsInstalled?: boolean } = {}, +): + | { ok: true; record: PluginInstallationRecord } + | { ok: false; error: 'record_unreadable' | 'plugin_not_installed' | 'plugin_uninstall_pending'; message: string } { + let records: PluginInstallationRecord[]; + try { + records = loadPluginInstallations(environmentId); + } catch (error) { + return { ok: false, error: 'record_unreadable', message: (error as Error).message }; + } + const record = records.find((r) => r.id === id); + if (!record) { + const installed = records.map((r) => r.id); + return { + ok: false, + error: 'plugin_not_installed', + message: + `No installation of '${id}' is recorded for this project in environment '${environmentId}'` + + (installed.length > 0 ? ` (installed: ${installed.join(', ')}).` : ' (nothing is installed).'), + }; + } + if (record.status === 'uninstall-pending' && !opts.pendingIsInstalled) { + return { + ok: false, + error: 'plugin_uninstall_pending', + message: `'${id}' is marked for removal at the next boot (\`os plugin uninstall\`). Install it again to keep it.`, + }; + } + return { ok: true, record }; +} /** * Every reason the archive's entries cannot be unpacked as they stand, one diff --git a/packages/cli/src/utils/plugin-trust-read.ts b/packages/cli/src/utils/plugin-trust-read.ts new file mode 100644 index 00000000000..8b7570285a6 --- /dev/null +++ b/packages/cli/src/utils/plugin-trust-read.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { TRUSTED_KEY_OBJECT, resolveTrustedKey, type TrustedKeyResolution } from '@objectstack/runtime'; +import { isExitSignal } from './format.js'; +import { bootSchemaStack } from './schema-migrate.js'; +import { absentTableReads } from './absent-table-reads.js'; +import { NO_TRUST_STORE_ENGINE, trustStoreEngine, trustStorePlugins } from './plugin-trust-store.js'; + +/** + * The ONE read of an environment's trusted plugin-publisher keys that a + * signature is verified against (ADR-0025 §3.5 M1): `os plugin install` asks it + * for the key its artifact names, and `os serve`'s boot-time loader asks it + * for every key the installed plugins name, so an install and every later boot + * of it trust the same store the same way. + * + * It boots the environment's database READ-ONLY (`deferSchemaDdl` + + * `readOnlyProbe`), once for all the key ids, and shuts the boot down before it + * answers. `os serve` asks before its own kernel bootstraps: the kernel takes + * no plugin after that, and the keys are what decide which installed plugins + * it takes. A table the boot measured absent has never trusted a key, so every + * id is unknown there. + * + * The one member of the `bootSchemaStack` family that is a utility, not a + * command: `schema-migrate.one-shot-family.integration.test.ts` runs its boot + * through `os plugin install`. + */ +export type TrustedKeysRead = + | { ok: true; keys: Map } + | { ok: false; error: 'boot_failed' | 'no_engine' | 'trusted_keys_unreadable'; message: string }; + +export async function readTrustedKeys( + keyIds: readonly string[], + opts: { databaseUrl?: string; json: boolean }, +): Promise { + const keys = new Map(); + if (keyIds.length === 0) return { ok: true, keys }; + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: opts.json, + databaseUrl: opts.databaseUrl, + extraPlugins: await trustStorePlugins(false), + deferSchemaDdl: true, + readOnlyProbe: true, + }); + } catch (error) { + return { ok: false, error: 'boot_failed', message: error instanceof Error ? error.message : String(error) }; + } + try { + const engine = trustStoreEngine(stack); + if (!engine) return { ok: false, error: 'no_engine', message: NO_TRUST_STORE_ENGINE }; + const reads = absentTableReads(stack); + if (reads.absent(TRUSTED_KEY_OBJECT)) { + reads.notice(opts.json); + for (const keyId of keyIds) keys.set(keyId, { verdict: 'unknown' }); + return { ok: true, keys }; + } + for (const keyId of keyIds) keys.set(keyId, await resolveTrustedKey(engine, keyId)); + return { ok: true, keys }; + } catch (error) { + if (isExitSignal(error)) throw error; + return { + ok: false, + error: 'trusted_keys_unreadable', + message: `Cannot read this environment's trusted keys: ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + await stack.shutdown(); + } +} diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index fa2f784b1de..2c64720dd66 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -305,18 +305,6 @@ const CALLERS: Record = { noWrite: [{ label: 'migrate value-shapes', argv: ['--database-url', '@DB@', '--json'] }], write: [{ label: 'migrate value-shapes --apply', argv: ['--apply', '--yes', '--database-url', '@DB@', '--json'] }], }, - 'commands/plugin/install.ts': { - run: invoke(PluginInstall), - // The fixture artifact is signed under a key id the database does not - // trust, so the run reads the trusted-key store and refuses by name. The - // boot is what this case is about. - noWrite: [{ - label: 'plugin install (key not trusted)', - argv: ['@FIXTURE@/com.example.osf-plugin-1.0.0.osplugin', '--database-url', '@DB@', '--json'], - }], - write: [], - note: 'it writes no database row in any mode: it only reads the trusted-key store, and an install lands under OS_HOME', - }, 'commands/plugin/trust/add.ts': { run: invoke(PluginTrustAdd), noWrite: [], @@ -359,6 +347,22 @@ const CALLERS: Record = { write: [], note: 'report-only: it has no writing mode', }, + // [#27123] The trusted-key read `os plugin install` and `os serve`'s + // boot-time loader share: a utility, so its boot runs through the command + // that reaches it from oclif. (`os serve` reaches it too, before its own + // kernel bootstraps; a served boot is not a one-shot.) + 'utils/plugin-trust-read.ts': { + run: invoke(PluginInstall), + // The fixture artifact is signed under a key id the database does not + // trust, so the run reads the trusted-key store and refuses by name. The + // boot is what this case is about. + noWrite: [{ + label: 'plugin install (key not trusted)', + argv: ['@FIXTURE@/com.example.osf-plugin-1.0.0.osplugin', '--database-url', '@DB@', '--json'], + }], + write: [], + note: 'it writes no database row in any mode: it only reads the trusted-key store, and an install lands under OS_HOME', + }, }; // ── The family, read off the source ────────────────────────────────────────── @@ -630,11 +634,12 @@ describe('[#21391] the bootSchemaStack family is the table above', () => { describe('[#27123] the plugin install case reaches its boot, not an earlier refusal', () => { it('the fixture artifact passes every check before the store read, and the store read refuses its key', async () => { - const caller = CALLERS['commands/plugin/install.ts']; + const caller = CALLERS['utils/plugin-trust-read.ts']; const c = prepareCase(caller.noWrite[0].argv); const result = await runJson(caller.run, c.argv); - // An unreadable artifact, a bad manifest or integrity map would refuse - // before the boot and leave the no-write cases above holding vacuously. + // A missing or malformed signature would refuse before the boot and + // leave the no-write cases above holding vacuously. (The signature is + // verified before the archive is read, so the archive no longer can.) expect(result.payload, JSON.stringify(result.payload).slice(0, 400)).toMatchObject({ error: 'signature_key_untrusted' }); expect(result.exitCode).toBe(1); }, CASE_TIMEOUT_MS); diff --git a/packages/cli/test/serve-installed-plugins.integration.test.ts b/packages/cli/test/serve-installed-plugins.integration.test.ts new file mode 100644 index 00000000000..de7d255c9f4 --- /dev/null +++ b/packages/cli/test/serve-installed-plugins.integration.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] `os serve` loads what `os plugin install` installed + * (ADR-0025 §3.5 M1), end to end: the commands run in process through oclif + * against a real SQLite file and an OS_HOME of their own, and the boot is the + * real command, spawned with `OS_MIGRATE_AND_EXIT=1 --no-server` (the + * kernel-only boot that runs every plugin's `init` and exits). + * + * Five signed installs, each with one fate, in ONE boot: + * - `com.acme.loads` `runtime: 'node'`, untouched → its `init` runs + * - `com.acme.sandboxed` `runtime: 'sandbox'` → refused, `runtime_not_dispatched` + * - `com.acme.tampered` an unpacked file edited → refused, `tree_changed` + * - `com.acme.disabled` `os plugin disable` → skipped, says so + * - `com.acme.removed` `os plugin uninstall` → directory removed, record dropped + * then a second boot after `os plugin enable com.acme.disabled` and + * `os plugin uninstall com.acme.loads`: the enabled one's `init` runs and the + * uninstalled one's does not. + * + * Each plugin's `init` writes a marker file, so "it ran" and "nothing of it + * ran" are read off the disk, not off the log. The controls: both boots exit 0 + * and print the migrate path's completion line, so a boot that never reached + * the plugins cannot pass. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; +import { loadPluginInstallations, pluginInstallDir } from '@objectstack/runtime'; +import { isExitSignal } from '../src/utils/format.js'; +import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../src/utils/osplugin.js'; +import PluginInstall from '../src/commands/plugin/install.js'; +import PluginDisable from '../src/commands/plugin/disable.js'; +import PluginEnable from '../src/commands/plugin/enable.js'; +import PluginUninstall from '../src/commands/plugin/uninstall.js'; +import PluginTrustAdd from '../src/commands/plugin/trust/add.js'; +import { CLI, E2E_SECRET_KEY, KERNEL_ASSIGNED_PORT, TSX, childEnv } from './helpers/serve-process.js'; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const ENV_KEYS = ['OS_HOME', 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_ARTIFACT_PATH', 'OS_ENVIRONMENT_ID'] as const; +const KEY_ID = 'acme-2026'; +const publisher = generateEd25519KeyPair(); +const text = (t: string) => new TextEncoder().encode(t); + +const CONFIG = ` +export default { + manifest: { + id: 'com.example.installed-plugins', + namespace: 'installed_plugins', + version: '1.0.0', + type: 'app', + name: 'Installed plugins boot probe', + }, + objects: [{ name: 'ip_note', label: 'Note', sharingModel: 'private', fields: { body: { type: 'text', label: 'Body' } } }], +}; +`; + +interface Run { + payload: any; + stdout: string; + stderr: string; + exitCode: number; +} + +/** One command through oclif, its `--json` document and exit status captured. */ +async function run(command: { run: (argv: string[], opts: { root: string }) => Promise }, argv: string[]): Promise { + const savedExit = process.exitCode; + const out: string[] = []; + const err: string[] = []; + const capture = (into: string[]) => ((chunk: unknown, ...rest: unknown[]) => { + into.push(String(chunk)); + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + vi.spyOn(process.stdout, 'write').mockImplementation(capture(out)); + vi.spyOn(process.stderr, 'write').mockImplementation(capture(err)); + for (const method of ['log', 'warn', 'error'] as const) { + vi.spyOn(console, method).mockImplementation((...a: unknown[]) => void err.push(`${a.join(' ')}\n`)); + } + let thrownExit: number | undefined; + try { + try { + await command.run(argv, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const stdout = out.join(''); + let payload: any; + try { payload = JSON.parse(stdout.trim()); } catch { payload = undefined; } + return { payload, stdout, stderr: err.join(''), exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +let root: string; +let home: string; +let app: string; +let dbFile: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** The marker a plugin's `init` writes; its presence is "the plugin's code ran". */ +const marker = (id: string) => join(home, `init-${id}`); + +/** A signed `.osplugin` whose `init` writes {@link marker}. */ +function signedArtifact(id: string, runtime = 'node'): string { + const files: ArchiveFile[] = [{ + path: 'dist/index.mjs', + data: text( + "import { writeFileSync } from 'node:fs';\n" + + `export default { name: ${JSON.stringify(id)}, version: '1.0.0', ` + + `init() { writeFileSync(${JSON.stringify(marker(id))}, 'ran'); } };\n`, + ), + }]; + const manifest = { id, version: '1.0.0', type: 'plugin', runtime, packaging: 'bundled', integrity: computeIntegrity(files) }; + const path = join(root, `${id}.osplugin`); + writeFileSync(path, createTarGz([...files, { path: MANIFEST_FILENAME, data: text(JSON.stringify(manifest)) }])); + writeFileSync(`${path}.sig`, signPayload(new Uint8Array(readFileSync(path)), publisher.privateKeyPem, KEY_ID)); + return path; +} + +interface Boot { + code: number | null; + output: string; +} + +/** `os serve --no-server` with `OS_MIGRATE_AND_EXIT=1` from the app directory; waits for its own exit. */ +function boot(): Promise { + return new Promise((done, fail) => { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '--no-server', '--port', KERNEL_ASSIGNED_PORT], { + cwd: app, + env: childEnv({ + NO_COLOR: '1', + OS_HOME: home, + OS_DATABASE_URL: `file:${dbFile}`, + OS_ENVIRONMENT_ID: undefined, + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_MIGRATE_AND_EXIT: '1', + }), + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (chunk) => { output += String(chunk); }); + child.stderr.on('data', (chunk) => { output += String(chunk); }); + child.on('error', fail); + child.on('close', (code) => done({ code, output })); + }); +} + +const IDS = ['com.acme.loads', 'com.acme.sandboxed', 'com.acme.tampered', 'com.acme.disabled', 'com.acme.removed'] as const; + +let first: Boot; +let second: Boot; +/** Whose `init` had run, read off the markers right after each boot. */ +let ranAfterFirst: string[]; +let ranInSecond: string[]; +const ran = () => IDS.filter((id) => existsSync(marker(id))); + +beforeAll(async () => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + root = mkdtempSync(join(tmpdir(), 'os-27123-boot-')); + home = join(root, 'home'); + app = join(root, 'app'); + mkdirSync(home); + mkdirSync(app); + dbFile = join(app, 'app.db'); + writeFileSync(join(app, 'objectstack.config.ts'), CONFIG, 'utf8'); + writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'os-27123-boot-fixture', private: true, type: 'module' }), 'utf8'); + writeFileSync(join(root, 'acme.pub.pem'), publisher.publicKeyPem); + process.env.OS_HOME = home; + process.chdir(app); + const db = ['--database-url', `file:${dbFile}`]; + + const trusted = await run(PluginTrustAdd, [join(root, 'acme.pub.pem'), '--key-id', KEY_ID, ...db]); + expect(trusted.exitCode, trusted.stderr.slice(-600)).toBe(0); + for (const [id, runtime] of [ + ['com.acme.loads', 'node'], + ['com.acme.sandboxed', 'sandbox'], + ['com.acme.tampered', 'node'], + ['com.acme.disabled', 'node'], + ['com.acme.removed', 'node'], + ] as const) { + const installed = await run(PluginInstall, [signedArtifact(id, runtime), ...db, '--json']); + expect(installed.exitCode, `${id}: ${installed.stdout.slice(-400)}`).toBe(0); + } + writeFileSync(join(pluginInstallDir('env_local', 'com.acme.tampered', '1.0.0'), 'dist', 'index.mjs'), 'export default { name: "evil", init() {} };\n'); + expect((await run(PluginDisable, ['com.acme.disabled', '--json'])).exitCode).toBe(0); + expect((await run(PluginUninstall, ['com.acme.removed', '--json'])).exitCode).toBe(0); + + first = await boot(); + ranAfterFirst = ran(); + + expect((await run(PluginEnable, ['com.acme.disabled', '--json'])).exitCode).toBe(0); + expect((await run(PluginUninstall, ['com.acme.loads', '--json'])).exitCode).toBe(0); + for (const id of IDS) rmSync(marker(id), { force: true }); + + second = await boot(); + ranInSecond = ran(); +}, 600_000); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('[#27123] os serve loads installed plugins, verified, by tier', () => { + it('both boots reached the plugins and exited cleanly (the controls)', () => { + for (const b of [first, second]) { + expect(b.code, b.output.slice(-2000)).toBe(0); + expect(b.output).toMatch(/Migration complete/); + } + }); + + it('runs the untouched node plugin\'s init, and only its', () => { + expect(ranAfterFirst).toEqual(['com.acme.loads']); + expect(first.output).not.toMatch(/Refused installed plugin com\.acme\.loads/); + }); + + it('refuses the sandbox tier and the tampered tree by name, and runs nothing of either', () => { + expect(first.output).toMatch(/✗ Refused installed plugin com\.acme\.sandboxed@1\.0\.0 \(runtime_not_dispatched\)/); + expect(first.output).toMatch(/✗ Refused installed plugin com\.acme\.tampered@1\.0\.0 \(tree_changed\)/); + expect(ranAfterFirst).not.toContain('com.acme.sandboxed'); + expect(ranInSecond).not.toContain('com.acme.sandboxed'); + expect(ranAfterFirst).not.toContain('com.acme.tampered'); + }); + + it('skips the disabled plugin, saying so, until it is enabled', () => { + expect(first.output).toMatch(/Installed plugin com\.acme\.disabled@1\.0\.0 is disabled/); + expect(ranAfterFirst).not.toContain('com.acme.disabled'); + expect(second.output).not.toMatch(/com\.acme\.disabled@1\.0\.0 is disabled/); + expect(ranInSecond).toEqual(['com.acme.disabled']); + }); + + it('finishes an uninstall at the next boot: directory gone, record dropped, init never run', () => { + expect(first.output).toMatch(/Uninstalled plugin com\.acme\.removed@1\.0\.0/); + expect(existsSync(pluginInstallDir('env_local', 'com.acme.removed', '1.0.0'))).toBe(false); + expect(ranAfterFirst).not.toContain('com.acme.removed'); + + expect(second.output).toMatch(/Uninstalled plugin com\.acme\.loads@1\.0\.0/); + expect(existsSync(pluginInstallDir('env_local', 'com.acme.loads', '1.0.0'))).toBe(false); + expect(ranInSecond).not.toContain('com.acme.loads'); + expect(loadPluginInstallations('env_local').map((r) => r.id).sort()).toEqual([ + 'com.acme.disabled', + 'com.acme.sandboxed', + 'com.acme.tampered', + ]); + }); +}); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 1a2b065ccde..12f3eb5c9de 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -3,6 +3,7 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; import { parseSignature } from './security/plugin-artifact-signature.js'; +import { pluginArtifactVerdict } from './security/plugin-artifact-verdict.js'; import { serviceNotRegisteredError } from './service-not-registered.js'; import { assertPluginContract } from './plugin-contract.js'; import { SEMVER_2_0_0_VERSION_PATTERN } from '@objectstack/spec/kernel'; @@ -513,25 +514,36 @@ export class PluginLoader { if (!plugin.signature) { return; } - - // Cryptographic verification of a third-party plugin's PUBLISHER and - // PLATFORM signatures is performed against the `.osplugin` artifact - // bytes + version identity at materialize/install time, by - // `verifyPluginArtifact` (security/plugin-artifact-signature.ts — - // ADR-0025 §3.7). By the time a plugin reaches loadPlugin() it is an - // in-memory module with no artifact bytes, so we cannot re-run the - // artifact chains here; we only validate that any signature carried - // on the metadata is well-formed (`ed25519::`) and - // surface its keyId, failing fast on a malformed value. + + // [ADR-0025 §3.5 M1] A signature covers the `.osplugin` artifact bytes, + // and by the time a plugin reaches loadPlugin() it is an in-memory + // module with none, so the artifact chains cannot run here. The host + // that verified the artifact runs them — the runtime's boot-time loader + // for installed plugins (`verifyPluginArtifact` over the kept artifact, + // against the environment's trusted keys) — and records its verdict + // against the plugin object (security/plugin-artifact-verdict.ts). + // + // This check reads that verdict. A plugin carrying a signature no host + // verified is REFUSED: a signature string a module declares for itself + // proves nothing, and passing it as well-formed would let it read as + // signed. A malformed one is refused by name first. const parsed = parseSignature(plugin.signature); if (!parsed) { throw new Error( `Plugin ${plugin.name} carries a malformed signature (expected ed25519::)`, ); } + const verdict = pluginArtifactVerdict(plugin); + if (!verdict || verdict.signature !== plugin.signature) { + throw new Error( + `Plugin ${plugin.name} carries a signature (keyId=${parsed.keyId}) that no host verified against its artifact. ` + + 'A signature is verified over the .osplugin bytes when the plugin is installed and again at boot ' + + '(`os plugin install`, then the boot-time loader); a plugin object cannot vouch for itself.', + ); + } this.logger.debug( - `Plugin ${plugin.name} signature well-formed (alg=${parsed.alg}, keyId=${parsed.keyId}); ` + - `artifact verification occurs at materialize time`, + `Plugin ${plugin.name} signature verified by its host (alg=${parsed.alg}, keyId=${parsed.keyId}, ` + + `artifact sha256=${verdict.artifactSha256})`, ); } diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 507268961b5..08759adf3ce 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -48,6 +48,26 @@ export { type VerifyIntegrityResult, } from './plugin-artifact-integrity.js'; +// [ADR-0025 §3.5 M1] Reading an `.osplugin` (gunzip bounded, then ustar) — one +// reader for `os plugin install` and the runtime's boot-time loader, which +// re-reads the artifact an install kept. The writer stays with `os plugin build`. +export { + MAX_UNPACKED_ARTIFACT_BYTES, + readTar, + readTarGz, + sha256Hex, + type ArchiveFile, +} from './plugin-artifact-archive.js'; + +// [ADR-0025 §3.5 M1] The verdict the host that verified an installed plugin's +// artifact records against the plugin object, which `PluginLoader` reads: a +// plugin carrying a signature no host verified is refused. +export { + recordPluginArtifactVerdict, + pluginArtifactVerdict, + type PluginArtifactVerdict, +} from './plugin-artifact-verdict.js'; + // The outbound HTTP signature scheme (`X-Objectstack-Signature`) — one // definition shared by the messaging outbox and the flow `http` node's inline // arm, which cannot import each other; `@objectstack/service-messaging` diff --git a/packages/core/src/security/plugin-artifact-archive.ts b/packages/core/src/security/plugin-artifact-archive.ts new file mode 100644 index 00000000000..d7db4b27c28 --- /dev/null +++ b/packages/core/src/security/plugin-artifact-archive.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Reading a `.osplugin` artifact (ADR-0025 §3.1): a gzipped ustar of the + * compiled manifest, the bundled code and its assets. + * + * Two readers need the same bytes read the same way: `os plugin install`, + * which unpacks a verified artifact into its install directory, and the + * runtime's boot-time loader, which re-reads the artifact an install kept to + * prove the unpacked tree is still the one that was signed (ADR-0025 §3.5 M1). + * The writer stays with `os plugin build` in the CLI. + * + * ## The inflate is bounded + * + * A signature covers the COMPRESSED bytes, so both readers verify it before + * they inflate anything; what is inflated is then bytes a trusted key signed. + * The bound still holds the line for the one artifact that reaches the inflate + * unverified — an unsigned install under `--trust-unsigned` — and for a key + * whose holder ships a bomb: past {@link MAX_UNPACKED_ARTIFACT_BYTES} the + * inflate stops and throws, naming the bound, instead of growing without end. + * + * Pure apart from `node:zlib` / `node:crypto`; no file system. + */ + +import { createHash } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; + +/** A single file of an artifact. `path` is POSIX, archive-relative. */ +export interface ArchiveFile { + path: string; + data: Uint8Array; +} + +/** The most an artifact may inflate to: 256 MiB of tar. */ +export const MAX_UNPACKED_ARTIFACT_BYTES = 256 * 1024 * 1024; + +const BLOCK = 512; + +/** Plain hex sha256 of `bytes` (the whole-artifact checksum an install records). */ +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Parse a ustar buffer into its files. Reads regular-file entries only; stops + * at the end-of-archive zero block. + */ +export function readTar(buf: Uint8Array): ArchiveFile[] { + const b = Buffer.from(buf); + const files: ArchiveFile[] = []; + let off = 0; + while (off + BLOCK <= b.length) { + const name = b.toString('utf8', off, off + 100).replace(/\0.*$/s, ''); + if (!name) break; // zero block → end of archive + const size = parseInt(b.toString('ascii', off + 124, off + 136).replace(/\0.*$/s, '').trim(), 8) || 0; + files.push({ path: name, data: new Uint8Array(b.subarray(off + BLOCK, off + BLOCK + size)) }); + off += BLOCK + Math.ceil(size / BLOCK) * BLOCK; + } + return files; +} + +/** + * gunzip + untar an `.osplugin` blob into its files. Throws when the blob is + * not gzip, or when it inflates past `maxUnpackedBytes` + * (default {@link MAX_UNPACKED_ARTIFACT_BYTES}), naming the bound. + */ +export function readTarGz(blob: Uint8Array, options: { maxUnpackedBytes?: number } = {}): ArchiveFile[] { + const max = options.maxUnpackedBytes ?? MAX_UNPACKED_ARTIFACT_BYTES; + let tar: Buffer; + try { + tar = gunzipSync(Buffer.from(blob), { maxOutputLength: max }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') { + throw new Error(`the artifact inflates past ${max} bytes, the most an .osplugin may unpack to`); + } + throw error; + } + return readTar(tar); +} diff --git a/packages/core/src/security/plugin-artifact-verdict.test.ts b/packages/core/src/security/plugin-artifact-verdict.test.ts new file mode 100644 index 00000000000..b45380030cb --- /dev/null +++ b/packages/core/src/security/plugin-artifact-verdict.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] The kernel loader's signature check reads a host verdict + * (ADR-0025 §3.5 M1), and the artifact reader's inflate is bounded. + * + * Pinned: a plugin carrying a signature no host verified is refused, a plugin + * whose recorded verdict is for that exact signature loads, a verdict for + * another signature does not stand in for it, and a malformed signature is + * refused by name first; a plugin with no signature is unaffected. The reader + * round-trips an archive, and an artifact that inflates past the bound throws + * naming it instead of growing. + */ + +import { describe, it, expect } from 'vitest'; +import { gzipSync } from 'node:zlib'; +import { PluginLoader } from '../plugin-loader'; +import { ObjectKernel } from '../kernel'; +import { createLogger } from '../logger'; +import type { Plugin, PluginContext } from '../types'; +import { recordPluginArtifactVerdict, pluginArtifactVerdict } from './plugin-artifact-verdict'; +import { MAX_UNPACKED_ARTIFACT_BYTES, readTar, readTarGz, sha256Hex } from './plugin-artifact-archive'; + +const SIG = 'ed25519:acme-2026:' + Buffer.alloc(64, 1).toString('base64url'); + +function loader(): PluginLoader { + const logger = createLogger({ level: 'silent' }); + const l = new PluginLoader(logger); + l.setContext({ + registerService: () => {}, + getService: () => { throw new Error('none'); }, + hook: () => {}, + trigger: async () => {}, + getServices: () => new Map(), + logger, + getKernel: () => ({}) as never, + } as unknown as PluginContext); + return l; +} + +function plugin(extra: Record = {}): Plugin { + return { name: 'com.acme.signed', version: '1.0.0', init: async () => {}, ...extra } as Plugin; +} + +describe('PluginLoader reads the host verdict for a signed plugin', () => { + it('refuses a signature no host verified', async () => { + const result = await loader().loadPlugin(plugin({ signature: SIG })); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/carries a signature \(keyId=acme-2026\) that no host verified/); + }); + + it('loads a plugin whose host recorded a verdict for that exact signature', async () => { + const p = plugin({ signature: SIG }); + recordPluginArtifactVerdict(p, { signature: SIG, keyId: 'acme-2026', fingerprint: 'sha256:00', artifactSha256: 'ab' }); + const result = await loader().loadPlugin(p); + expect(result.success).toBe(true); + }); + + it('does not let a verdict for another signature stand in', async () => { + const p = plugin({ signature: SIG }); + recordPluginArtifactVerdict(p, { signature: null, keyId: null, fingerprint: null, artifactSha256: 'ab' }); + expect((await loader().loadPlugin(p)).success).toBe(false); + }); + + it('refuses a malformed signature by name, verdict or not', async () => { + const p = plugin({ signature: 'nope' }); + recordPluginArtifactVerdict(p, { signature: 'nope', keyId: null, fingerprint: null, artifactSha256: 'ab' }); + const result = await loader().loadPlugin(p); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/malformed signature/); + }); + + it('leaves a plugin without a signature as it was', async () => { + expect((await loader().loadPlugin(plugin())).success).toBe(true); + }); + + it('is what kernel.use answers: the unverified signed plugin is not registered', async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false } as never); + await expect(kernel.use(plugin({ signature: SIG }))).rejects.toThrow(/no host verified/); + expect(kernel.hasPlugin('com.acme.signed')).toBe(false); + }); + + it('keeps the verdict frozen, off the object, and per object', () => { + const a = plugin(); + const b = plugin(); + recordPluginArtifactVerdict(a, { signature: SIG, keyId: 'k', fingerprint: 'f', artifactSha256: 's' }); + expect(Object.isFrozen(pluginArtifactVerdict(a))).toBe(true); + expect(Object.keys(a)).not.toContain('verdict'); + expect(pluginArtifactVerdict(b)).toBeUndefined(); + }); +}); + +/** One ustar entry plus the end-of-archive blocks. */ +function tarOf(path: string, data: Uint8Array): Uint8Array { + const h = Buffer.alloc(512, 0); + h.write(path, 0, 100, 'utf8'); + h.write(data.byteLength.toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii'); + const pad = (512 - (data.byteLength % 512)) % 512; + return new Uint8Array(Buffer.concat([h, Buffer.from(data), Buffer.alloc(pad), Buffer.alloc(1024)])); +} + +describe('readTarGz bounds the inflate', () => { + it('round-trips an archive, and sha256Hex is the plain hex digest', () => { + const tar = tarOf('dist/index.mjs', new Uint8Array(Buffer.from('export {};\n'))); + expect(readTar(tar)).toEqual([{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export {};\n')) }]); + expect(readTarGz(gzipSync(tar))).toEqual(readTar(tar)); + expect(sha256Hex(new Uint8Array(0))).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + + it('throws naming the bound when an artifact inflates past it', () => { + // 4 MiB of zeros compresses to a few KiB: a small bomb. + const bomb = gzipSync(tarOf('zeros.bin', new Uint8Array(4 * 1024 * 1024))); + expect(bomb.byteLength).toBeLessThan(64 * 1024); + expect(() => readTarGz(bomb, { maxUnpackedBytes: 1024 * 1024 })).toThrow( + /inflates past 1048576 bytes, the most an \.osplugin may unpack to/, + ); + // Control: the same artifact under the default bound reads. + expect(readTarGz(bomb)[0].data.byteLength).toBe(4 * 1024 * 1024); + expect(MAX_UNPACKED_ARTIFACT_BYTES).toBe(256 * 1024 * 1024); + }); + + it('still throws the inflate\'s own error for bytes that are not gzip', () => { + expect(() => readTarGz(new Uint8Array(Buffer.from('not gzip')))).toThrow(/incorrect header check|Z_DATA_ERROR/); + }); +}); diff --git a/packages/core/src/security/plugin-artifact-verdict.ts b/packages/core/src/security/plugin-artifact-verdict.ts new file mode 100644 index 00000000000..68881032929 --- /dev/null +++ b/packages/core/src/security/plugin-artifact-verdict.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What a host proved about an installed plugin's artifact before it imported + * the plugin's code (ADR-0025 §3.5 M1). + * + * A signature is verified over the `.osplugin` bytes it signs, and by the time + * a plugin reaches the kernel's `PluginLoader` it is an in-memory module with + * no bytes to verify. So the host that verified the artifact — the runtime's + * boot-time loader for installed plugins — records its verdict against the + * plugin object here, and `PluginLoader` reads it: a plugin that carries a + * `signature` with no recorded verdict for that exact signature is refused, + * because a signature string a plugin declares for itself proves nothing. + * + * Kept in a module-private `WeakMap`, so the verdict follows the object, adds + * no property to it, and goes with it. + */ + +/** One host verification of an installed plugin's artifact. */ +export interface PluginArtifactVerdict { + /** + * The detached signature that verified over the artifact bytes, or `null` + * for an install admitted unsigned under `--trust-unsigned`. + */ + signature: string | null; + /** The trusted key's id, or `null` for an unsigned install. */ + keyId: string | null; + /** The trusted key's fingerprint (`sha256:` + hex of the SPKI DER bytes), or `null`. */ + fingerprint: string | null; + /** sha256 (hex) of the artifact bytes the verdict covers. */ + artifactSha256: string; +} + +const VERDICTS = new WeakMap>(); + +/** Record that `plugin`'s artifact was verified, and how. */ +export function recordPluginArtifactVerdict(plugin: object, verdict: PluginArtifactVerdict): void { + VERDICTS.set(plugin, Object.freeze({ ...verdict })); +} + +/** The verdict a host recorded for `plugin`, if any. */ +export function pluginArtifactVerdict(plugin: object): Readonly | undefined { + return VERDICTS.get(plugin); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 20243218c14..9ab3b45df6d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -18,8 +18,9 @@ export { devAutoMigrateConfig } from './dev-auto-migrate.js'; export type { DevAutoMigrateConfig } from './dev-auto-migrate.js'; // [ADR-0025 §3.5 M1] The local plugin install loop's two stores — public because -// `@objectstack/cli` writes them (`os plugin trust`, `os plugin install`) and -// the runtime's boot-time loader is to read the same ones. +// `@objectstack/cli` writes them (`os plugin trust`, `os plugin install`, +// `os plugin disable | enable | uninstall`) and the runtime's boot-time loader +// below reads the same ones. export { TRUSTED_KEY_OBJECT, TRUSTED_KEY_ID_PATTERN, @@ -44,13 +45,40 @@ export type { } from './plugin-trusted-keys.js'; export { DEFAULT_PLUGIN_ENVIRONMENT_ID, + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + INSTALLATIONS_LOCK_WAIT_MS, + INSTALLATIONS_LOCK_STALE_MS, pluginsHome, pluginInstallDir, pluginInstallationsFile, loadPluginInstallations, recordPluginInstallation, + withPluginInstallationsLock, + markPluginUninstall, + dropPluginInstallation, + installedPluginDisabledIds, + setInstalledPluginDisabled, } from './plugin-installation-store.js'; export type { PluginInstallationRecord } from './plugin-installation-store.js'; +// The boot-time loader and its tier dispatch point (ADR-0025 §3.5 / §3.6, M1): +// `os serve` calls it before the kernel bootstraps and `kernel.use`s what it +// answers. +export { + dispatchPluginRuntime, + verifyInstalledPlugins, + loadInstalledPlugins, +} from './installed-plugin-loader.js'; +export type { + InstalledPluginRefusalCode, + InstalledPluginRefusal, + VerifiedInstalledPlugin, + InstalledPluginsVerdict, + VerifyInstalledPluginsInput, + LoadedInstalledPlugin, + InstalledPluginsLoad, + LoadInstalledPluginsInput, +} from './installed-plugin-loader.js'; // The ONE libSQL/Turso loader (commit 68f5eccb1). Public because `@objectstack/cli` is a // consumer, not a second implementation: `utils/storage-driver.ts` delegates to diff --git a/packages/runtime/src/installed-plugin-loader.test.ts b/packages/runtime/src/installed-plugin-loader.test.ts new file mode 100644 index 00000000000..843d48bddf9 --- /dev/null +++ b/packages/runtime/src/installed-plugin-loader.test.ts @@ -0,0 +1,476 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] The boot-time loader for installed plugins (ADR-0025 §3.5 + * M1) and its tier dispatch point (§3.6), on a real temporary `OS_HOME`. + * + * Every install below is laid out the way `os plugin install` lays one out — + * the unpacked files, the original artifact and its detached signature kept + * beside them, and the record — and then one thing about it is changed. + * Pinned: the untouched install loads, with its verified signature and the + * host verdict the kernel's loader reads; each change is refused by its own + * name and nothing of that plugin is imported; a disabled install is skipped; + * an uninstall removes the directory unless another project still records it; + * and only `runtime: 'node'` is ever imported. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createPublicKey } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +import { generateEd25519KeyPair, pluginArtifactVerdict, sha256Hex, signPayload } from '@objectstack/core'; + +import { dispatchPluginRuntime, loadInstalledPlugins, verifyInstalledPlugins } from './installed-plugin-loader.js'; +import { + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + loadPluginInstallations, + markPluginUninstall, + pluginInstallDir, + pluginInstallationsFile, + recordPluginInstallation, + setInstalledPluginDisabled, + type PluginInstallationRecord, +} from './plugin-installation-store.js'; +import { packageStateFileName } from './package-state-store.js'; +import { publicKeyFingerprint, type TrustedKeyResolution } from './plugin-trusted-keys.js'; + +const ENV = 'env_local'; + +let home: string; +const saved = { OS_HOME: process.env.OS_HOME }; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-installed-plugins-')); + process.env.OS_HOME = home; +}); + +afterEach(() => { + if (saved.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = saved.OS_HOME; + rmSync(home, { recursive: true, force: true }); +}); + +// ── An artifact, as `os plugin build` writes one ────────────────────────────── + +interface File { + path: string; + data: Uint8Array; +} + +/** A minimal ustar writer: regular files, sorted, two zero blocks at the end. */ +function tarGz(files: readonly File[]): Uint8Array { + const octal = (h: Buffer, value: number, offset: number, len: number) => + h.write(value.toString(8).padStart(len - 1, '0') + '\0', offset, len, 'ascii'); + const chunks: Buffer[] = []; + for (const f of [...files].sort((a, b) => (a.path < b.path ? -1 : 1))) { + const h = Buffer.alloc(512, 0); + h.write(f.path, 0, 100, 'utf8'); + octal(h, 0o644, 100, 8); + octal(h, 0, 108, 8); + octal(h, 0, 116, 8); + octal(h, f.data.byteLength, 124, 12); + octal(h, 0, 136, 12); + h.write(' ', 148, 8, 'ascii'); + h.write('0', 156, 1, 'ascii'); + h.write('ustar\0', 257, 6, 'ascii'); + h.write('00', 263, 2, 'ascii'); + let sum = 0; + for (let i = 0; i < 512; i++) sum += h[i]; + h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii'); + chunks.push(h, Buffer.from(f.data)); + const pad = (512 - (f.data.byteLength % 512)) % 512; + if (pad) chunks.push(Buffer.alloc(pad, 0)); + } + chunks.push(Buffer.alloc(1024, 0)); + return new Uint8Array(gzipSync(Buffer.concat(chunks))); +} + +const text = (s: string) => new Uint8Array(Buffer.from(s, 'utf8')); + +const PUBLISHER = generateEd25519KeyPair(); +const STRANGER = generateEd25519KeyPair(); +const KEY_ID = 'acme-2026'; +const FINGERPRINT = publicKeyFingerprint(createPublicKey(PUBLISHER.publicKeyPem)); + +function activeKeys(over: Partial> = {}) { + const calls: string[][] = []; + const resolveKeys = async (keyIds: readonly string[]) => { + calls.push([...keyIds]); + const out = new Map(); + for (const id of keyIds) { + out.set( + id, + over[id] ?? (id === KEY_ID + ? { + verdict: 'active', + row: { key_id: id, public_key: PUBLISHER.publicKeyPem, fingerprint: FINGERPRINT, source: 'local', status: 'active', added_by: null, added_at: 'then' }, + publicKey: createPublicKey(PUBLISHER.publicKeyPem), + } + : { verdict: 'unknown' }), + ); + } + return out; + }; + return { resolveKeys, calls }; +} + +interface InstallOptions { + id?: string; + version?: string; + manifest?: Record; + entry?: string; + extraFiles?: File[]; + /** `false`: an unsigned install under `--trust-unsigned`. */ + signed?: boolean; + keyId?: string; + privateKeyPem?: string; +} + +/** Lay an install out on disk and record it, exactly as `os plugin install` does. */ +function install(o: InstallOptions = {}): PluginInstallationRecord & { artifactBytes: Uint8Array } { + const id = o.id ?? 'com.acme.hello'; + const version = o.version ?? '1.0.0'; + const manifest = { id, version, name: 'Hello', runtime: 'node', ...o.manifest }; + const entry = o.entry ?? `export default { name: ${JSON.stringify(id)}, version: '1.0.0', init() { globalThis.__initCount = (globalThis.__initCount ?? 0) + 1; } };\n`; + const files: File[] = [ + { path: 'objectstack.plugin.json', data: text(JSON.stringify(manifest)) }, + { path: 'dist/index.mjs', data: text(entry) }, + ...(o.extraFiles ?? []), + ]; + const artifact = tarGz(files); + const signed = o.signed ?? true; + const signature = signed ? signPayload(artifact, o.privateKeyPem ?? PUBLISHER.privateKeyPem, o.keyId ?? KEY_ID) : null; + const dir = pluginInstallDir(ENV, id, version); + mkdirSync(dir, { recursive: true }); + for (const f of files) { + mkdirSync(dirname(join(dir, f.path)), { recursive: true }); + writeFileSync(join(dir, f.path), f.data); + } + writeFileSync(join(dir, INSTALLED_ARTIFACT_FILE), artifact); + if (signature !== null) writeFileSync(join(dir, INSTALLED_SIGNATURE_FILE), `${signature}\n`); + const record: PluginInstallationRecord = { + id, + version, + versionId: `${id}@${version}`, + runtime: typeof manifest.runtime === 'string' ? manifest.runtime : null, + status: 'installed', + grantedPermissions: null, + dir, + artifact: { file: INSTALLED_ARTIFACT_FILE, sha256: sha256Hex(artifact) }, + signatureFile: signature === null ? null : INSTALLED_SIGNATURE_FILE, + signature: signature === null ? null : { keyId: o.keyId ?? KEY_ID, fingerprint: FINGERPRINT }, + trustUnsigned: signature === null, + installedAt: '2026-10-29T00:00:00.000Z', + }; + recordPluginInstallation(ENV, record); + return { ...record, artifactBytes: artifact }; +} + +/** Load, with the module import counted, so a refusal can be shown to import nothing. */ +async function load(resolveKeys = activeKeys().resolveKeys) { + const imported: string[] = []; + const result = await loadInstalledPlugins({ + environmentId: ENV, + resolveKeys, + wrapBundle: (bundle) => ({ name: 'wrapped', init() {}, bundle }), + importModule: async (url) => { + imported.push(url); + return import(/* @vite-ignore */ url); + }, + }); + return { ...result, imported }; +} + +function rewriteRecord(id: string, change: (r: PluginInstallationRecord) => PluginInstallationRecord) { + const file = pluginInstallationsFile(ENV); + const installs = loadPluginInstallations(ENV).map((r) => (r.id === id ? change(r) : r)); + writeFileSync(file, JSON.stringify({ installations: installs })); +} + +// ── The cases ──────────────────────────────────────────────────────────────── + +describe('dispatchPluginRuntime — the tier dispatch point (ADR-0025 §3.6)', () => { + it('dispatches node, and refuses every other tier and an undeclared one by name', () => { + expect(dispatchPluginRuntime('node')).toEqual({ ok: true, tier: 'node' }); + for (const tier of ['sandbox', 'worker', 'isolated']) { + expect(dispatchPluginRuntime(tier)).toMatchObject({ ok: false, error: 'runtime_not_dispatched' }); + } + for (const unset of [undefined, null, '']) { + expect(dispatchPluginRuntime(unset)).toMatchObject({ ok: false, error: 'runtime_undeclared' }); + } + expect((dispatchPluginRuntime('sandbox') as { message: string }).message).toMatch(/M3/); + }); +}); + +describe('an untouched install loads', () => { + it('imports the signed node plugin once, carrying its verified signature and the host verdict', async () => { + const r = install(); + const keys = activeKeys(); + const out = await load(keys.resolveKeys); + expect(out.refused).toEqual([]); + expect(out.loaded).toHaveLength(1); + const plugin = out.loaded[0].plugin as { name: string; init: unknown; signature: string }; + expect(plugin.name).toBe('com.acme.hello'); + expect(typeof plugin.init).toBe('function'); + const sig = readFileSync(join(r.dir, INSTALLED_SIGNATURE_FILE), 'utf8').trim(); + expect(plugin.signature).toBe(sig); + expect(pluginArtifactVerdict(plugin)).toEqual({ + signature: sig, + keyId: KEY_ID, + fingerprint: FINGERPRINT, + artifactSha256: sha256Hex(r.artifactBytes), + }); + // One trust read for every signed install. + expect(keys.calls).toEqual([[KEY_ID]]); + }); + + it('loads an unsigned --trust-unsigned install without reading the trusted keys', async () => { + install({ signed: false }); + const keys = activeKeys(); + const out = await load(keys.resolveKeys); + expect(out.refused).toEqual([]); + expect(out.loaded).toHaveLength(1); + expect(out.loaded[0].record.trustUnsigned).toBe(true); + expect(pluginArtifactVerdict(out.loaded[0].plugin as object)).toMatchObject({ signature: null, keyId: null }); + expect(keys.calls).toEqual([]); + }); + + it('wraps a plain bundle default export, the rule a stack `plugins` entry takes', async () => { + install({ entry: "export default { objects: [] };\n" }); + const out = await load(); + expect(out.refused).toEqual([]); + expect((out.loaded[0].plugin as { name: string }).name).toBe('wrapped'); + }); + + it('reads no installations file into existence when there is none', async () => { + const out = await load(); + expect(out).toMatchObject({ loaded: [], refused: [], disabled: [], removed: [] }); + expect(existsSync(pluginInstallationsFile(ENV))).toBe(false); + }); +}); + +describe('every change to an install refuses that plugin by name and imports nothing of it', () => { + type Case = [label: string, error: string, arrange: () => { resolveKeys?: ReturnType['resolveKeys'] } | void]; + const cases: Case[] = [ + ['the record names another directory', 'install_dir_unexpected', () => { + install(); + rewriteRecord('com.acme.hello', (r) => ({ ...r, dir: join(home, 'elsewhere') })); + }], + ['the install directory is gone', 'install_dir_missing', () => { + rmSync(install().dir, { recursive: true }); + }], + ['the kept artifact was replaced', 'artifact_changed', () => { + const r = install(); + writeFileSync(join(r.dir, INSTALLED_ARTIFACT_FILE), 'other bytes'); + }], + ['the kept signature is gone', 'signature_missing', () => { + rmSync(join(install().dir, INSTALLED_SIGNATURE_FILE)); + }], + ['the kept signature is malformed', 'signature_malformed', () => { + writeFileSync(join(install().dir, INSTALLED_SIGNATURE_FILE), 'not a signature'); + }], + ['the kept signature names another key id', 'signature_key_mismatch', () => { + const r = install(); + writeFileSync(join(r.dir, INSTALLED_SIGNATURE_FILE), signPayload(r.artifactBytes, PUBLISHER.privateKeyPem, 'other-key')); + }], + ['the key is no longer trusted', 'signature_key_untrusted', () => { + install(); + return { resolveKeys: activeKeys({ [KEY_ID]: { verdict: 'unknown' } }).resolveKeys }; + }], + ['the key was revoked since install', 'signature_key_revoked', () => { + install(); + return { + resolveKeys: activeKeys({ + [KEY_ID]: { verdict: 'revoked', row: { key_id: KEY_ID, public_key: '', fingerprint: FINGERPRINT, source: 'local', status: 'revoked', added_by: null, added_at: 'then' } }, + }).resolveKeys, + }; + }], + ['the trusted key row cannot verify', 'trusted_key_invalid', () => { + install(); + return { + resolveKeys: activeKeys({ + [KEY_ID]: { verdict: 'invalid', message: 'bad', row: { key_id: KEY_ID, public_key: '', fingerprint: FINGERPRINT, source: 'local', status: 'active', added_by: null, added_at: 'then' } }, + }).resolveKeys, + }; + }], + ['the key id now names another key', 'trusted_key_changed', () => { + install(); + return { + resolveKeys: activeKeys({ + [KEY_ID]: { + verdict: 'active', + row: { key_id: KEY_ID, public_key: STRANGER.publicKeyPem, fingerprint: publicKeyFingerprint(createPublicKey(STRANGER.publicKeyPem)), source: 'local', status: 'active', added_by: null, added_at: 'then' }, + publicKey: createPublicKey(STRANGER.publicKeyPem), + }, + }).resolveKeys, + }; + }], + ['the signature was made by another key under the same id', 'signature_invalid', () => { + const r = install(); + writeFileSync(join(r.dir, INSTALLED_SIGNATURE_FILE), signPayload(r.artifactBytes, STRANGER.privateKeyPem, KEY_ID)); + }], + ['the trusted keys cannot be read', 'trusted_keys_unreadable', () => { + install(); + return { resolveKeys: async () => { throw new Error('database is down'); } }; + }], + ['the record is unsigned without the override', 'unsigned_without_override', () => { + install({ signed: false }); + rewriteRecord('com.acme.hello', (r) => ({ ...r, trustUnsigned: false })); + }], + ['an unpacked file was edited', 'tree_changed', () => { + writeFileSync(join(install().dir, 'dist', 'index.mjs'), 'export default { name: "evil", init() {} };\n'); + }], + ['a file was added beside the unpacked tree', 'tree_changed', () => { + writeFileSync(join(install().dir, 'dist', 'extra.mjs'), 'export {};\n'); + }], + ['an unpacked file was removed', 'tree_changed', () => { + install({ extraFiles: [{ path: 'assets/a.txt', data: text('a') }] }); + rmSync(join(pluginInstallDir(ENV, 'com.acme.hello', '1.0.0'), 'assets', 'a.txt')); + }], + ['a symbolic link sits in the tree', 'tree_changed', () => { + symlinkSync('/etc/hostname', join(install().dir, 'link')); + }], + ['the kept artifact is another version than the record', 'manifest_mismatch', () => { + install({ manifest: { version: '2.0.0' } }); + }], + ['the kept artifact is not an archive', 'artifact_unreadable', () => { + const r = install({ signed: false }); + writeFileSync(join(r.dir, INSTALLED_ARTIFACT_FILE), 'not gzip'); + rewriteRecord('com.acme.hello', (x) => ({ ...x, artifact: { ...x.artifact, sha256: sha256Hex(text('not gzip')) } })); + }], + ['the manifest declares runtime: sandbox', 'runtime_not_dispatched', () => { + install({ manifest: { runtime: 'sandbox' } }); + }], + ['the manifest declares runtime: worker', 'runtime_not_dispatched', () => { + install({ manifest: { runtime: 'worker' } }); + }], + ['the manifest declares no runtime', 'runtime_undeclared', () => { + install({ manifest: { runtime: undefined } }); + }], + ['the entry module is not in the artifact', 'entry_missing', () => { + install({ manifest: { main: 'dist/missing.mjs' } }); + }], + ['the entry module escapes the install directory', 'entry_missing', () => { + install({ manifest: { main: '../../outside.mjs' } }); + }], + ]; + + it.each(cases)('%s → %s', async (_label, error, arrange) => { + (globalThis as { __initCount?: number }).__initCount = 0; + const over = arrange() ?? {}; + const out = await load(over.resolveKeys); + expect(out.loaded).toEqual([]); + expect(out.imported).toEqual([]); + expect(out.refused).toHaveLength(1); + expect(out.refused[0]).toMatchObject({ id: 'com.acme.hello', version: '1.0.0', error }); + expect(out.refused[0].message.length).toBeGreaterThan(10); + }); + + it('refuses every plugin, by one named refusal, when the installations file cannot be read', async () => { + install(); + writeFileSync(pluginInstallationsFile(ENV), '{ not json'); + const out = await load(); + expect(out.loaded).toEqual([]); + expect(out.refused).toEqual([expect.objectContaining({ id: '*', error: 'records_unreadable' })]); + }); + + it('refuses one plugin and still loads the next', async () => { + install({ id: 'com.acme.sandboxed', manifest: { runtime: 'sandbox' } }); + install({ id: 'com.acme.fine' }); + const out = await load(); + expect(out.refused.map((r) => [r.id, r.error])).toEqual([['com.acme.sandboxed', 'runtime_not_dispatched']]); + expect(out.loaded.map((l) => l.record.id)).toEqual(['com.acme.fine']); + }); +}); + +describe('what the import answers', () => { + it.each([ + ['the import throws', 'import_failed', 'throw new Error("broken at import");\n'], + ['the module exports no plugin', 'not_a_plugin', 'export default 42;\n'], + ['the plugin declares another signature', 'signature_conflict', 'export default { name: "x", init() {}, signature: "ed25519:other:AAAA" };\n'], + ['the plugin object is frozen', 'signature_conflict', 'export default Object.freeze({ name: "x", init() {} });\n'], + ])('%s → %s', async (_label, error, entry) => { + install({ entry }); + const out = await load(); + expect(out.loaded).toEqual([]); + expect(out.refused).toEqual([expect.objectContaining({ id: 'com.acme.hello', error })]); + }); + + it('an unsigned install whose plugin declares a signature → signature_conflict', async () => { + install({ signed: false, entry: 'export default { name: "x", init() {}, signature: "ed25519:acme:AAAA" };\n' }); + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ error: 'signature_conflict' })]); + }); +}); + +describe('disable and uninstall', () => { + it('skips a disabled install before reading anything of it', async () => { + const r = install(); + rmSync(join(r.dir, INSTALLED_ARTIFACT_FILE)); + setInstalledPluginDisabled(ENV, r.id, true); + const keys = activeKeys(); + const out = await load(keys.resolveKeys); + expect(out.loaded).toEqual([]); + expect(out.refused).toEqual([]); + expect(out.disabled.map((d) => d.id)).toEqual([r.id]); + expect(keys.calls).toEqual([]); + }); + + it('finishes an uninstall: removes the directory and its empty parent, drops the record, imports nothing', async () => { + const r = install(); + markPluginUninstall(ENV, r.id); + setInstalledPluginDisabled(ENV, r.id, true); + const out = await load(); + expect(out.removed).toEqual([expect.objectContaining({ kept: false })]); + expect(out.loaded).toEqual([]); + expect(out.imported).toEqual([]); + expect(existsSync(r.dir)).toBe(false); + expect(existsSync(dirname(r.dir))).toBe(false); + expect(loadPluginInstallations(ENV)).toEqual([]); + // The drop forgets the disable, so a later install starts enabled. + const again = install(); + expect((await load()).loaded.map((l) => l.record.id)).toEqual([again.id]); + }); + + it('keeps a directory another project in this environment still records', async () => { + const r = install(); + const other = join(dirname(pluginInstallationsFile(ENV)), packageStateFileName(ENV, join(home, 'other-project'))); + writeFileSync(other, JSON.stringify({ installations: [{ ...r, artifactBytes: undefined }] })); + markPluginUninstall(ENV, r.id); + const out = await load(); + expect(out.removed).toEqual([expect.objectContaining({ kept: true })]); + expect(existsSync(join(r.dir, INSTALLED_ARTIFACT_FILE))).toBe(true); + expect(loadPluginInstallations(ENV)).toEqual([]); + }); + + it('removes nothing under a directory the record does not own', async () => { + const r = install(); + const victim = join(home, 'victim'); + mkdirSync(victim); + writeFileSync(join(victim, 'keep.txt'), 'keep'); + markPluginUninstall(ENV, r.id); + rewriteRecord(r.id, (x) => ({ ...x, dir: victim })); + const out = await load(); + expect(out.removed).toEqual([]); + expect(out.refused).toEqual([expect.objectContaining({ error: 'install_dir_unexpected' })]); + expect(readFileSync(join(victim, 'keep.txt'), 'utf8')).toBe('keep'); + }); +}); + +describe('verifyInstalledPlugins changes nothing', () => { + it('answers the verdict without importing, removing or dropping', async () => { + const r = install(); + install({ id: 'com.acme.gone' }); + markPluginUninstall(ENV, 'com.acme.gone'); + const verdict = await verifyInstalledPlugins({ environmentId: ENV, resolveKeys: activeKeys().resolveKeys }); + expect(verdict.verified.map((v) => v.record.id)).toEqual([r.id]); + expect(verdict.verified[0].entry).toBe(join(r.dir, 'dist', 'index.mjs')); + expect(verdict.uninstallPending.map((u) => u.id)).toEqual(['com.acme.gone']); + expect(existsSync(pluginInstallDir(ENV, 'com.acme.gone', '1.0.0'))).toBe(true); + expect(loadPluginInstallations(ENV)).toHaveLength(2); + }); +}); diff --git a/packages/runtime/src/installed-plugin-loader.ts b/packages/runtime/src/installed-plugin-loader.ts new file mode 100644 index 00000000000..fb6fe667942 --- /dev/null +++ b/packages/runtime/src/installed-plugin-loader.ts @@ -0,0 +1,518 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The boot-time loader for plugins `os plugin install` installed + * (ADR-0025 §3.5 M1, the second slice of the local install loop). + * + * At boot, before the kernel bootstraps, the host reads this project's + * installation records ({@link loadPluginInstallations}) and hands each one + * through {@link verifyInstalledPlugins}. A plugin is imported only when every + * check below passes, in this order; the first that fails refuses that plugin + * by name and nothing of it runs: + * + * 0. **The record names its own directory.** `dir` must be the one the + * store derives for the record's environment, id and version + * (`pluginInstallDir`), or nothing is read from it or removed under it. + * 1. **Disabled** (`os plugin disable`, kept in the package disable-state + * store under the plugin environment id) — skipped, not refused. + * 2. **The kept artifact is the recorded one.** `artifact.osplugin` in the + * install directory hashes to the record's `sha256`. Two projects sharing + * one `OS_HOME` and environment share the directory, so this is what tells + * a project that the tree is no longer the one it installed. + * 3. **The signature, over the raw kept bytes, before anything is inflated.** + * The detached `artifact.osplugin.sig` names a key id; it must be the + * record's, the key must be `active` in `sys_trusted_key` with the + * fingerprint the record kept, and `verifyPluginArtifact` must verify it. + * So a key revoked since install refuses the plugin at its next boot. An + * install admitted unsigned under `--trust-unsigned` loads with a warning + * on every boot. + * 4. **The tree is the artifact.** The artifact is read back (bounded, + * `readTarGz`) and every file in the install directory must be byte-equal + * to its entry, with no file the artifact does not hold beside the two the + * install keeps. Its manifest's `id` and `version` are the record's. + * 5. **The tier dispatch point** ({@link dispatchPluginRuntime}). M1 wires + * `runtime: 'node'` only: the entry module is imported in process. + * `sandbox` and `worker` are refused by name until M3 routes them, and a + * manifest that declares no tier is refused too — trust is default-deny + * (ADR-0025 §3.6), and nothing loads in process without saying `node`. + * + * {@link loadInstalledPlugins} runs the same checks, finishes uninstalls + * (`os plugin uninstall` marks a record; the directory goes here, at the next + * boot, unless another project's record still names it), and imports what + * passed. Registering the plugins is the caller's: it `kernel.use`s each, so + * dependency ordering and health checks are the kernel's `PluginLoader`'s. + */ +import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + materializeStackPlugin, + parseSignature, + readTarGz, + recordPluginArtifactVerdict, + sha256Hex, + verifyPluginArtifact, + type ArchiveFile, +} from '@objectstack/core'; + +import { + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + dropPluginInstallation, + installedPluginDisabledIds, + loadPluginInstallations, + otherProjectsInstallationDirs, + pluginInstallDir, + type PluginInstallationRecord, +} from './plugin-installation-store.js'; +import type { TrustedKeyResolution } from './plugin-trusted-keys.js'; + +/** The manifest an artifact carries at its root. */ +const MANIFEST_FILE = 'objectstack.plugin.json'; +/** The entry `os plugin build` compiles to, when a manifest names none. */ +const DEFAULT_ENTRY = 'dist/index.mjs'; + +/** Why an installed plugin did not load. */ +export type InstalledPluginRefusalCode = + | 'records_unreadable' + | 'install_dir_unexpected' + | 'install_dir_missing' + | 'artifact_changed' + | 'signature_missing' + | 'signature_malformed' + | 'signature_key_mismatch' + | 'signature_key_untrusted' + | 'signature_key_revoked' + | 'trusted_keys_unreadable' + | 'trusted_key_invalid' + | 'trusted_key_changed' + | 'signature_invalid' + | 'unsigned_without_override' + | 'artifact_unreadable' + | 'manifest_mismatch' + | 'tree_changed' + | 'runtime_undeclared' + | 'runtime_not_dispatched' + | 'entry_missing' + | 'import_failed' + | 'not_a_plugin' + | 'signature_conflict' + // Raised by the host that registers what loaded (`os serve`): + | 'plugin_name_taken' + | 'kernel_refused'; + +export interface InstalledPluginRefusal { + id: string; + version: string; + error: InstalledPluginRefusalCode; + message: string; +} + +/** An installed plugin every check passed: what loading it would import. */ +export interface VerifiedInstalledPlugin { + record: PluginInstallationRecord; + /** The entry module, absolute. */ + entry: string; + /** The detached signature the artifact verified under, or `null` for an unsigned install. */ + signature: string | null; +} + +export interface InstalledPluginsVerdict { + verified: VerifiedInstalledPlugin[]; + refused: InstalledPluginRefusal[]; + /** Installed but disabled (`os plugin disable`): not loaded, not refused. */ + disabled: PluginInstallationRecord[]; + /** Marked by `os plugin uninstall`, waiting for the next boot to remove them. */ + uninstallPending: PluginInstallationRecord[]; +} + +export interface VerifyInstalledPluginsInput { + environmentId?: string; + /** + * Resolve the key ids the signed installs name, in one read of the + * environment's `sys_trusted_key`. Not called when no install is signed. + */ + resolveKeys(keyIds: readonly string[]): Promise>; +} + +/** + * The tier dispatch point (ADR-0025 §3.6). M1 dispatches `node` (T0, in + * process) and refuses every other tier by name until M3 routes `sandbox` to + * the QuickJS runner and decides `worker`; there is no in-process fallback. + */ +export function dispatchPluginRuntime( + runtime: unknown, +): { ok: true; tier: 'node' } | { ok: false; error: 'runtime_undeclared' | 'runtime_not_dispatched'; message: string } { + if (runtime === 'node') return { ok: true, tier: 'node' }; + if (runtime === undefined || runtime === null || runtime === '') { + return { + ok: false, + error: 'runtime_undeclared', + message: + 'its manifest declares no `runtime` tier. Trust is default-deny (ADR-0025 §3.6): nothing loads in process ' + + "without declaring `runtime: 'node'`.", + }; + } + return { + ok: false, + error: 'runtime_not_dispatched', + message: + `its manifest declares \`runtime: ${JSON.stringify(runtime)}\`, and only \`node\` is dispatched today. ` + + "`sandbox` and `worker` are refused until they are routed (ADR-0025 M3); the plugin is not loaded in process instead.", + }; +} + +/** A relative POSIX path that stays inside the install directory. */ +function safeEntryPath(path: string): boolean { + if (!path || path.startsWith('/') || /[\\\u0000]/.test(path) || /^[A-Za-z]:/.test(path)) return false; + return path.split('/').every((s) => s !== '' && s !== '.' && s !== '..'); +} + +/** Every regular file under `dir`, relative and POSIX; a symbolic link or other non-file is named in `odd`. */ +function walkTree(dir: string, prefix = ''): { files: string[]; odd: string[] } { + const files: string[] = []; + const odd: string[] = []; + for (const entry of readdirSync(join(dir, prefix), { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + const inner = walkTree(dir, rel); + files.push(...inner.files); + odd.push(...inner.odd); + } else if (entry.isFile()) files.push(rel); + else odd.push(rel); + } + return { files, odd }; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.byteLength === b.byteLength && Buffer.from(a).equals(Buffer.from(b)); +} + +type Check = { ok: true; value: VerifiedInstalledPlugin } | { ok: false; error: InstalledPluginRefusalCode; message: string }; + +function refuse(error: InstalledPluginRefusalCode, message: string): Check { + return { ok: false, error, message }; +} + +/** + * Whether `record.dir` is the directory the store derives for its id and + * version. Nothing is read from, or removed under, a directory a record names + * otherwise: the record is a file under `OS_HOME`, and a path it carries is + * not trusted for itself. + */ +function expectedDir(environmentId: string | undefined, record: PluginInstallationRecord): string | null { + try { + const dir = pluginInstallDir(environmentId, record.id, record.version); + return record.dir === dir ? dir : null; + } catch { + return null; + } +} + +function unexpectedDir(environmentId: string | undefined, record: PluginInstallationRecord): string { + let derived: string; + try { + derived = pluginInstallDir(environmentId, record.id, record.version); + } catch (error) { + derived = `none (${(error as Error).message})`; + } + return `its record names the directory ${JSON.stringify(record.dir)}, and the install directory for ${record.id}@${record.version} is ${derived}. Nothing was read from or removed under it. Install it again.`; +} + +/** Checks 2 and 3 up to the key lookup: the kept bytes, and the signature's key id. */ +function readKept(record: PluginInstallationRecord): + | { ok: true; artifact: Uint8Array; signature: string | null; keyId: string | null } + | { ok: false; error: InstalledPluginRefusalCode; message: string } { + const dir = record.dir; + if (typeof dir !== 'string' || !existsSync(dir) || !lstatSync(dir).isDirectory()) { + return { ok: false, error: 'install_dir_missing', message: `its install directory ${dir} is not there. Install it again.` }; + } + let artifact: Uint8Array; + try { + artifact = new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE))); + } catch (error) { + return { ok: false, error: 'artifact_changed', message: `${join(dir, INSTALLED_ARTIFACT_FILE)} cannot be read (${(error as Error).message}).` }; + } + const sha = sha256Hex(artifact); + if (sha !== record.artifact?.sha256) { + return { + ok: false, + error: 'artifact_changed', + message: + `${join(dir, INSTALLED_ARTIFACT_FILE)} is not the artifact this project installed (sha256 ${sha}, recorded ` + + `${record.artifact?.sha256 ?? 'none'}): another project sharing this OS_HOME and environment replaced it, or it was edited. Install it again.`, + }; + } + const sigPath = join(dir, INSTALLED_SIGNATURE_FILE); + if (record.signature === null || record.signature === undefined) { + if (!record.trustUnsigned) { + return { ok: false, error: 'unsigned_without_override', message: 'its record names no signature and no --trust-unsigned override.' }; + } + return { ok: true, artifact, signature: null, keyId: null }; + } + let signature: string; + try { + signature = readFileSync(sigPath, 'utf8').trim(); + } catch { + return { ok: false, error: 'signature_missing', message: `it was installed signed, and ${sigPath} is not there now.` }; + } + const parsed = parseSignature(signature); + if (!parsed) return { ok: false, error: 'signature_malformed', message: `${sigPath} is not an \`ed25519:KEYID:SIG\` signature.` }; + if (parsed.keyId !== record.signature.keyId) { + return { + ok: false, + error: 'signature_key_mismatch', + message: `${sigPath} names key id '${parsed.keyId}', and the install verified key id '${record.signature.keyId}'.`, + }; + } + return { ok: true, artifact, signature, keyId: parsed.keyId }; +} + +async function checkRecord( + record: PluginInstallationRecord, + kept: { artifact: Uint8Array; signature: string | null; keyId: string | null }, + keys: ReadonlyMap, +): Promise { + const { artifact, signature, keyId } = kept; + // 3. The signature, over the raw kept bytes. + if (signature !== null && keyId !== null) { + const resolved = keys.get(keyId) ?? { verdict: 'unknown' as const }; + if (resolved.verdict === 'unknown') { + return refuse('signature_key_untrusted', `it is signed with key id '${keyId}', which this environment no longer trusts.`); + } + if (resolved.verdict === 'revoked') { + return refuse('signature_key_revoked', `it is signed with key id '${keyId}', which was revoked in this environment (${resolved.row.fingerprint}).`); + } + if (resolved.verdict === 'invalid') { + return refuse('trusted_key_invalid', `the trusted key '${keyId}' cannot verify anything: ${resolved.message}.`); + } + if (resolved.row.fingerprint !== record.signature?.fingerprint) { + return refuse( + 'trusted_key_changed', + `key id '${keyId}' now names the key ${resolved.row.fingerprint}, and the install verified ${record.signature?.fingerprint}.`, + ); + } + const publicKey = resolved.publicKey; + const verdict = await verifyPluginArtifact( + { artifact, version: { package_id: record.id, version: record.version, signature } }, + { getPublisherPublicKey: (id) => (id === keyId ? publicKey : null), requirePlatform: false }, + ); + if (!verdict.ok || !verdict.publisherVerified) { + return refuse('signature_invalid', `its signature does not verify against the trusted key '${keyId}' (${verdict.reason ?? 'not verified'}).`); + } + } + + // 4. The tree is the artifact. + let files: ArchiveFile[]; + try { + files = readTarGz(artifact); + } catch (error) { + return refuse('artifact_unreadable', `its kept artifact cannot be read: ${(error as Error).message}.`); + } + const unsafe = files.find((f) => !safeEntryPath(f.path)); + if (unsafe) return refuse('artifact_unreadable', `its kept artifact holds the entry ${JSON.stringify(unsafe.path)}, which cannot sit in an install directory.`); + let manifest: Record; + try { + const entry = files.find((f) => f.path === MANIFEST_FILE); + if (!entry) throw new Error(`${MANIFEST_FILE} not found`); + const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILE} is not a JSON object`); + manifest = parsed as Record; + } catch (error) { + return refuse('artifact_unreadable', `its kept artifact's manifest cannot be read: ${(error as Error).message}.`); + } + if (manifest.id !== record.id || manifest.version !== record.version) { + return refuse( + 'manifest_mismatch', + `its kept artifact is ${String(manifest.id)}@${String(manifest.version)}, and the record is ${record.id}@${record.version}.`, + ); + } + const expected = new Map(files.map((f) => [f.path, f.data] as const)); + const keptFiles = new Set([INSTALLED_ARTIFACT_FILE, ...(signature === null ? [] : [INSTALLED_SIGNATURE_FILE])]); + const tree = walkTree(record.dir); + const problems: string[] = tree.odd.map((p) => `${p}: not a regular file`); + for (const path of tree.files) { + if (keptFiles.has(path)) continue; + const data = expected.get(path); + if (data === undefined) problems.push(`${path}: not in the artifact`); + else if (!sameBytes(new Uint8Array(readFileSync(join(record.dir, ...path.split('/')))), data)) problems.push(`${path}: differs from the artifact`); + } + const present = new Set(tree.files); + for (const path of expected.keys()) if (!present.has(path)) problems.push(`${path}: missing`); + if (problems.length > 0) { + return refuse('tree_changed', `its install directory is not the signed artifact any more: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}.`); + } + + // 5. The tier dispatch point. + const tier = dispatchPluginRuntime(manifest.runtime); + if (!tier.ok) return refuse(tier.error, tier.message); + + const main = typeof manifest.main === 'string' && manifest.main.trim() ? manifest.main.trim() : DEFAULT_ENTRY; + if (!safeEntryPath(main) || !expected.has(main)) { + return refuse('entry_missing', `its entry module ${JSON.stringify(main)} is not a file of the artifact.`); + } + return { ok: true, value: { record, entry: join(record.dir, ...main.split('/')), signature } }; +} + +/** + * Every check of this module's header, for every installation this project + * recorded in `environmentId`; imports nothing and changes nothing. An + * installations file that cannot be read refuses every plugin (one refusal, + * `records_unreadable`) rather than loading any. + */ +export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput): Promise { + const out: InstalledPluginsVerdict = { verified: [], refused: [], disabled: [], uninstallPending: [] }; + let records: PluginInstallationRecord[]; + try { + records = loadPluginInstallations(input.environmentId); + } catch (error) { + out.refused.push({ id: '*', version: '*', error: 'records_unreadable', message: (error as Error).message }); + return out; + } + if (records.length === 0) return out; + const disabled = installedPluginDisabledIds(input.environmentId); + const pending: Array<{ record: PluginInstallationRecord; kept: { artifact: Uint8Array; signature: string | null; keyId: string | null } }> = []; + for (const record of records) { + if (expectedDir(input.environmentId, record) === null) { + out.refused.push({ + id: String(record.id), + version: String(record.version), + error: 'install_dir_unexpected', + message: unexpectedDir(input.environmentId, record), + }); + continue; + } + if (record.status === 'uninstall-pending') { + out.uninstallPending.push(record); + continue; + } + if (disabled.has(record.id)) { + out.disabled.push(record); + continue; + } + const kept = readKept(record); + if (!kept.ok) { + out.refused.push({ id: record.id, version: record.version, error: kept.error, message: kept.message }); + continue; + } + pending.push({ record, kept }); + } + const keyIds = [...new Set(pending.map((p) => p.kept.keyId).filter((k): k is string => k !== null))]; + let keys: ReadonlyMap = new Map(); + let toCheck = pending; + if (keyIds.length > 0) { + try { + keys = await input.resolveKeys(keyIds); + } catch (error) { + // No key could be read, so no signed install is verified: each is + // refused by name, and an unsigned one is still checked below. + const message = `this environment's trusted keys cannot be read: ${(error as Error).message}`; + for (const { record, kept } of pending) { + if (kept.keyId !== null) out.refused.push({ id: record.id, version: record.version, error: 'trusted_keys_unreadable', message }); + } + toCheck = pending.filter((p) => p.kept.keyId === null); + } + } + for (const { record, kept } of toCheck) { + const checked = await checkRecord(record, kept, keys); + if (checked.ok) out.verified.push(checked.value); + else out.refused.push({ id: record.id, version: record.version, error: checked.error, message: checked.message }); + } + return out; +} + +/** One plugin {@link loadInstalledPlugins} imported, ready for `kernel.use`. */ +export interface LoadedInstalledPlugin { + record: PluginInstallationRecord; + plugin: unknown; +} + +export interface InstalledPluginsLoad { + loaded: LoadedInstalledPlugin[]; + refused: InstalledPluginRefusal[]; + disabled: PluginInstallationRecord[]; + /** Uninstalls finished this boot; `kept` when another project's record still names the directory. */ + removed: Array<{ record: PluginInstallationRecord; kept: boolean }>; +} + +export interface LoadInstalledPluginsInput extends VerifyInstalledPluginsInput { + /** Wrap a plain bundle default export into the plugin that registers it (the `plugins` entry rule). */ + wrapBundle(bundle: Record): unknown | Promise; + /** The dynamic import, injectable for tests. */ + importModule?: (url: string) => Promise; +} + +/** + * Finish pending uninstalls, verify every other installation, and import each + * one that passed through the rule a stack's `plugins` entry takes + * (`materializeStackPlugin`). The host registers the answers with + * `kernel.use`; a refused plugin is in `refused`, by name, and was not imported. + */ +export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Promise { + const verdict = await verifyInstalledPlugins(input); + const out: InstalledPluginsLoad = { loaded: [], refused: [...verdict.refused], disabled: verdict.disabled, removed: [] }; + + for (const record of verdict.uninstallPending) { + const sharedDirs = otherProjectsInstallationDirs(input.environmentId); + const kept = sharedDirs === null || sharedDirs.has(record.dir); + if (!kept) { + rmSync(record.dir, { recursive: true, force: true }); + // The `` directory goes with its last version. + try { + rmdirSync(dirname(record.dir)); + } catch { + // not empty, or already gone + } + } + dropPluginInstallation(input.environmentId, record.id); + out.removed.push({ record, kept }); + } + + const importModule = input.importModule ?? ((url: string) => import(/* webpackIgnore: true */ url)); + for (const item of verdict.verified) { + const { record } = item; + let plugin: unknown; + try { + plugin = await materializeStackPlugin(pathToFileURL(item.entry).href, { + importSpecifier: importModule, + wrapBundle: input.wrapBundle, + }); + } catch (error) { + out.refused.push({ id: record.id, version: record.version, error: 'import_failed', message: `importing ${item.entry} failed: ${(error as Error).message}` }); + continue; + } + if (!plugin || typeof plugin !== 'object' || typeof (plugin as { init?: unknown }).init !== 'function') { + out.refused.push({ id: record.id, version: record.version, error: 'not_a_plugin', message: `${item.entry} does not export a plugin (an object with \`init\`).` }); + continue; + } + // The signature the artifact verified under rides on the plugin, and the + // verdict that verified it is recorded for the kernel's loader, which + // refuses a plugin that carries a signature nothing verified. + const carried = (plugin as { signature?: unknown }).signature; + if (item.signature === null && carried !== undefined) { + out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin declares a signature, and its artifact was installed unsigned (--trust-unsigned).' }); + continue; + } + if (item.signature !== null) { + if (carried !== undefined && carried !== item.signature) { + out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin declares a signature other than the one its artifact verified under.' }); + continue; + } + try { + (plugin as { signature?: string }).signature = item.signature; + } catch { + out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin object cannot carry its verified signature (it is frozen).' }); + continue; + } + } + recordPluginArtifactVerdict(plugin as object, { + signature: item.signature, + keyId: record.signature?.keyId ?? null, + fingerprint: record.signature?.fingerprint ?? null, + artifactSha256: record.artifact.sha256, + }); + out.loaded.push({ record, plugin }); + } + return out; +} diff --git a/packages/runtime/src/plugin-installation-store.lock.test.ts b/packages/runtime/src/plugin-installation-store.lock.test.ts new file mode 100644 index 00000000000..01fe51d9f9f --- /dev/null +++ b/packages/runtime/src/plugin-installation-store.lock.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] The installations file's lock, and the record changes that + * run under it: the uninstall mark, the boot's drop, and the disable kept for + * an installed plugin. + * + * Pinned: a writer waits for a live holder and then refuses, naming it and the + * lock file; a stale lock (dead pid, too old, an unparseable leftover, this + * process's own pid while it holds nothing) is cleared by the next writer; the + * lock is re-entrant within a process and always released; two processes + * writing at once both land every record; and another project's records keep + * their directories out of an uninstall. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + INSTALLATIONS_LOCK_STALE_MS, + dropPluginInstallation, + installedPluginDisabledIds, + loadPluginInstallations, + markPluginUninstall, + otherProjectsInstallationDirs, + pluginInstallDir, + pluginInstallationsFile, + recordPluginInstallation, + setInstalledPluginDisabled, + withPluginInstallationsLock, + type PluginInstallationRecord, +} from './plugin-installation-store.js'; +import { loadDisabledPackageIds, packageStateFileName } from './package-state-store.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +let home: string; +const saved = { OS_HOME: process.env.OS_HOME }; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-plugin-installs-lock-')); + process.env.OS_HOME = home; +}); + +afterEach(() => { + if (saved.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = saved.OS_HOME; + rmSync(home, { recursive: true, force: true }); +}); + +function record(id: string, version = '1.0.0'): PluginInstallationRecord { + return { + id, + version, + versionId: `${id}@${version}`, + runtime: 'node', + status: 'installed', + grantedPermissions: null, + dir: pluginInstallDir('env_local', id, version), + artifact: { file: 'artifact.osplugin', sha256: '0'.repeat(64) }, + signatureFile: null, + signature: null, + trustUnsigned: true, + installedAt: '2026-10-29T00:00:00.000Z', + }; +} + +function lockFile(): string { + const file = pluginInstallationsFile('env_local'); + mkdirSync(dirname(file), { recursive: true }); + return `${file}.lock`; +} + +/** A pid that was a process a moment ago and is not one now. */ +function deadPid(): number { + const child = spawnSync(process.execPath, ['-e', '']); + return child.pid as number; +} + +describe('the installations lock', () => { + it('waits for a live holder, then refuses naming the holder and the lock file', () => { + const lock = lockFile(); + // The parent of this worker: alive for the whole test. + writeFileSync(lock, JSON.stringify({ pid: process.ppid, at: '2026-10-29T00:00:00.000Z' })); + const t0 = Date.now(); + expect(() => withPluginInstallationsLock('env_local', () => 'never', { waitMs: 300 })) + .toThrow(new RegExp(`locked by process ${process.ppid} .*remove ${lock.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + expect(Date.now() - t0).toBeGreaterThanOrEqual(300); + // The holder's lock is left alone. + expect(JSON.parse(readFileSync(lock, 'utf8')).pid).toBe(process.ppid); + }); + + it.each([ + ['a dead pid', () => JSON.stringify({ pid: deadPid(), at: 'then' }), 0], + ['this process\'s own pid while it holds nothing', () => JSON.stringify({ pid: process.pid, at: 'then' }), 0], + ['a live pid older than the stale bound', () => JSON.stringify({ pid: process.ppid, at: 'then' }), INSTALLATIONS_LOCK_STALE_MS + 60_000], + ['an unparseable leftover older than a second', () => '{"pid":', 5_000], + ])('clears a stale lock: %s', (_label, body, ageMs) => { + const lock = lockFile(); + writeFileSync(lock, body()); + if (ageMs > 0) { + const then = new Date(Date.now() - ageMs); + utimesSync(lock, then, then); + } + expect(withPluginInstallationsLock('env_local', () => 'ran', { waitMs: 300 })).toBe('ran'); + expect(existsSync(lock)).toBe(false); + }); + + it('does not clear an unparseable lock a writer has only just created', () => { + const lock = lockFile(); + writeFileSync(lock, ''); + expect(() => withPluginInstallationsLock('env_local', () => 'never', { waitMs: 200 })) + .toThrow(/a writer that has not yet written its pid/); + expect(existsSync(lock)).toBe(true); + }); + + it('is re-entrant within a process, and released when the outer holder returns or throws', () => { + const lock = `${pluginInstallationsFile('env_local')}.lock`; + const inner = withPluginInstallationsLock('env_local', () => { + expect(JSON.parse(readFileSync(lock, 'utf8')).pid).toBe(process.pid); + recordPluginInstallation('env_local', record('com.acme.a')); + return withPluginInstallationsLock('env_local', () => 'inner'); + }); + expect(inner).toBe('inner'); + expect(existsSync(lock)).toBe(false); + expect(() => withPluginInstallationsLock('env_local', () => { throw new Error('boom'); })).toThrow('boom'); + expect(existsSync(lock)).toBe(false); + expect(loadPluginInstallations('env_local').map((r) => r.id)).toEqual(['com.acme.a']); + }); + + it('two processes writing at once both land every record', async () => { + const subject = pathToFileURL(resolve(HERE, 'plugin-installation-store.ts')).href; + // Both children import first, then meet at a barrier, so the writes + // overlap rather than one child finishing before the other has started. + const script = [ + 'const [subject, home, tag] = process.argv.slice(1);', + 'process.env.OS_HOME = home;', + 'const store = await import(subject);', + 'const fs = await import("node:fs");', + 'fs.writeFileSync(home + "/ready-" + tag, "");', + 'const until = Date.now() + 60000;', + 'while (!(fs.existsSync(home + "/ready-a") && fs.existsSync(home + "/ready-b")) && Date.now() < until) {}', + 'for (let i = 0; i < 40; i++) {', + ' const id = `com.acme.${tag}${i}`;', + ' store.recordPluginInstallation("env_local", { id, version: "1.0.0", versionId: id + "@1.0.0", runtime: "node",', + ' status: "installed", grantedPermissions: null, dir: store.pluginInstallDir("env_local", id, "1.0.0"),', + ' artifact: { file: "artifact.osplugin", sha256: "0".repeat(64) }, signatureFile: null, signature: null,', + ' trustUnsigned: true, installedAt: new Date().toISOString() });', + '}', + ].join('\n'); + const run = (tag: string) => + new Promise<{ code: number | null; stderr: string }>((done) => { + const child = spawn(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script, subject, home, tag], { + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? '', HOME: home, ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}) }, + }); + let stderr = ''; + child.stderr.on('data', (d) => { stderr += String(d); }); + const timer = setTimeout(() => child.kill('SIGKILL'), 90_000); + child.on('exit', (code) => { clearTimeout(timer); done({ code, stderr }); }); + }); + const [a, b] = await Promise.all([run('a'), run('b')]); + expect(a.code, a.stderr).toBe(0); + expect(b.code, b.stderr).toBe(0); + const ids = loadPluginInstallations('env_local').map((r) => r.id).sort(); + expect(ids).toHaveLength(80); + expect(new Set(ids).size).toBe(80); + expect(existsSync(`${pluginInstallationsFile('env_local')}.lock`)).toBe(false); + }, 120_000); +}); + +describe('the uninstall mark, the drop, and the disable', () => { + it('marks an installation uninstall-pending, and answers undefined for an id with none', () => { + recordPluginInstallation('env_local', record('com.acme.a')); + recordPluginInstallation('env_local', record('com.acme.b')); + expect(markPluginUninstall('env_local', 'com.acme.none')).toBeUndefined(); + expect(markPluginUninstall('env_local', 'com.acme.a')?.status).toBe('uninstall-pending'); + expect(loadPluginInstallations('env_local').map((r) => [r.id, r.status])).toEqual([ + ['com.acme.a', 'uninstall-pending'], + ['com.acme.b', 'installed'], + ]); + // A later install of the id replaces the mark. + recordPluginInstallation('env_local', record('com.acme.a', '1.1.0')); + expect(loadPluginInstallations('env_local')[0]).toMatchObject({ version: '1.1.0', status: 'installed' }); + }); + + it('keeps the disable under the plugin environment id, and the drop forgets it', () => { + recordPluginInstallation(undefined, record('com.acme.a')); + setInstalledPluginDisabled(undefined, 'com.acme.a', true); + expect(installedPluginDisabledIds(undefined).has('com.acme.a')).toBe(true); + // `env_local` when unset — the plugin store's default, not the package store's `default`. + expect(existsSync(join(home, 'package-state', packageStateFileName('env_local', process.cwd())))).toBe(true); + expect(loadDisabledPackageIds('env_local').has('com.acme.a')).toBe(true); + dropPluginInstallation(undefined, 'com.acme.a'); + expect(loadPluginInstallations(undefined)).toEqual([]); + expect(installedPluginDisabledIds(undefined).has('com.acme.a')).toBe(false); + }); + + it('names the directories another project in this environment still records, and only those', () => { + const own = pluginInstallationsFile('env_local'); + recordPluginInstallation('env_local', record('com.acme.a')); + const dir = dirname(own); + const other = join(dir, packageStateFileName('env_local', join(home, 'other-project'))); + writeFileSync(other, JSON.stringify({ installations: [record('com.acme.shared')] })); + // Another environment's file is not this environment's sharing. + writeFileSync( + join(dir, packageStateFileName('env_staging', join(home, 'other-project'))), + JSON.stringify({ installations: [record('com.acme.staging')] }), + ); + writeFileSync(`${own}.lock.leftover.tmp`, 'ignored'); + expect([...(otherProjectsInstallationDirs('env_local') ?? [])]).toEqual([pluginInstallDir('env_local', 'com.acme.shared', '1.0.0')]); + // A file it cannot read keeps every directory. + writeFileSync(other, '{ not json'); + expect(otherProjectsInstallationDirs('env_local')).toBeNull(); + }); +}); diff --git a/packages/runtime/src/plugin-installation-store.ts b/packages/runtime/src/plugin-installation-store.ts index ff387a69e6d..53469ac32ed 100644 --- a/packages/runtime/src/plugin-installation-store.ts +++ b/packages/runtime/src/plugin-installation-store.ts @@ -7,8 +7,9 @@ * * /plugins//// * - * and records the installation here, so the install survives a restart and a - * boot-time loader can find what was installed and how it was trusted: + * and records the installation here, so the install survives a restart and + * the boot-time loader (`installed-plugin-loader.ts`) can find what was + * installed and how it was trusted: * * /plugin-installations/..json * → { "installations": [ PluginInstallationRecord, … ] } @@ -29,8 +30,8 @@ * installations, so a record shared between two projects that share one home * and one environment id would load a plugin into a project that never * installed it. The materialized bytes are keyed by environment, id and - * version alone (the path ADR-0025 §3.5 names); a reader checks them against - * the record's `sha256` before it trusts them. + * version alone (the path ADR-0025 §3.5 names); the boot-time loader checks + * them against the record's `sha256` before it trusts them. * * ## A record this store cannot read is never overwritten * @@ -38,19 +39,53 @@ * read as empty: rewriting it from an empty start would drop every other * plugin's record while the write reported success. * + * ## Every read-modify-write holds a lock + * + * Two `os plugin` commands for one project and environment at once would each + * read the file and the second rename would drop the first's change. So every + * write (install, the uninstall mark, the boot's removal of an uninstalled + * record) runs under `.lock`, created exclusively (`wx`) and holding the + * writer's pid and start time. A writer that finds the lock waits for it, up + * to {@link INSTALLATIONS_LOCK_WAIT_MS}, and then refuses, naming the holder. + * + * A lock a crashed process left behind is cleared by the next writer, never by + * hand in the ordinary case: the lock is stale, and is removed, when its pid + * is no longer a live process on this host, or when it is older than + * {@link INSTALLATIONS_LOCK_STALE_MS} (a write takes milliseconds, so an older + * lock is a pid the operating system reused), or when it names this process's + * own pid while this process does not hold it (a dead process that had the pid + * before), or when it cannot be parsed and is older than a second (a writer + * that died between creating it and writing its pid). Only when none of these + * holds does the refusal ask the operator to remove the lock file, by its + * path. + * * Exported from the package index for the CLI, which writes it; the runtime's * boot-time loader is its reader. */ -import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { makeDirectorySync } from './make-directory.js'; -import { packageStateFileName } from './package-state-store.js'; +import { loadDisabledPackageIds, packageStateFileName, setPackageDisabled } from './package-state-store.js'; import { resolveObjectStackHome } from './standalone-stack.js'; /** The environment id `os serve` stamps when `OS_ENVIRONMENT_ID` is unset. */ export const DEFAULT_PLUGIN_ENVIRONMENT_ID = 'env_local'; +/** + * The names the install directory keeps the ORIGINAL artifact and its detached + * signature under, beside the unpacked files, so the boot-time loader + * re-verifies the signed bytes rather than the unpacked copy. No archive entry + * may take either name. + */ +export const INSTALLED_ARTIFACT_FILE = 'artifact.osplugin'; +export const INSTALLED_SIGNATURE_FILE = 'artifact.osplugin.sig'; + +/** How long a writer waits for another writer's lock before it refuses. */ +export const INSTALLATIONS_LOCK_WAIT_MS = 10_000; +/** A lock older than this is stale whatever its pid answers. */ +export const INSTALLATIONS_LOCK_STALE_MS = 5 * 60_000; + /** One installed plugin version, as `os plugin install` recorded it. */ export interface PluginInstallationRecord { /** The manifest `id` (reverse-domain). */ @@ -60,7 +95,11 @@ export interface PluginInstallationRecord { versionId: string; /** The manifest's declared `runtime` tier, or `null` when it declares none. */ runtime: string | null; - status: 'installed'; + /** + * `installed`, or `uninstall-pending` once `os plugin uninstall` marked it: + * the next boot removes the directory and drops the record. + */ + status: 'installed' | 'uninstall-pending'; /** The manifest's declared `permissions` — the granted set equals it. `null` when it declares none. */ grantedPermissions: Record | null; /** The materialized directory, absolute. */ @@ -118,22 +157,16 @@ export function pluginInstallDir(environmentId: string | undefined, id: string, return join(pluginsHome(environmentId), requireSafeSegment('id', id), requireSafeSegment('version', version)); } +function installationsDir(): string { + return join(resolveObjectStackHome(), 'plugin-installations'); +} + /** The installations file for this environment and project (the working directory). */ export function pluginInstallationsFile(environmentId?: string): string { - return join( - resolveObjectStackHome(), - 'plugin-installations', - packageStateFileName(environmentSegment(environmentId), process.cwd()), - ); + return join(installationsDir(), packageStateFileName(environmentSegment(environmentId), process.cwd())); } -/** - * Every installation recorded for this environment and project, in the order - * they were first installed. An absent file is no installations; a file that - * is not an installations file throws, naming it. - */ -export function loadPluginInstallations(environmentId?: string): PluginInstallationRecord[] { - const file = pluginInstallationsFile(environmentId); +function readInstallationsFile(file: string): PluginInstallationRecord[] { if (!existsSync(file)) return []; let parsed: unknown; try { @@ -155,19 +188,210 @@ export function loadPluginInstallations(environmentId?: string): PluginInstallat } /** - * Record `record` as the installed version of its plugin id, replacing any - * earlier record for the same id (an update swaps the installation pointer, - * ADR-0025 §3.8). The write lands through a temporary file and a rename, so a - * reader never sees half a file. + * Every installation recorded for this environment and project, in the order + * they were first installed. An absent file is no installations; a file that + * is not an installations file throws, naming it. */ -export function recordPluginInstallation(environmentId: string | undefined, record: PluginInstallationRecord): void { - const current = loadPluginInstallations(environmentId); - const next = current.some((r) => r.id === record.id) - ? current.map((r) => (r.id === record.id ? record : r)) - : [...current, record]; +export function loadPluginInstallations(environmentId?: string): PluginInstallationRecord[] { + return readInstallationsFile(pluginInstallationsFile(environmentId)); +} + +/** Block the thread for `ms` (the lock wait runs in synchronous commands). */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** Whether the process `pid` is alive on this host. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it exists, and belongs to someone else. + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** The holder a lock file names, and whether the lock is stale (see the module header). */ +function readLock(lock: string): { stale: boolean; holder: string } { + let ageMs: number; + try { + ageMs = Date.now() - statSync(lock).mtimeMs; + } catch { + return { stale: false, holder: 'a writer that has just released it' }; + } + let held: { pid?: unknown; at?: unknown } | null = null; + try { + held = JSON.parse(readFileSync(lock, 'utf8')) as { pid?: unknown; at?: unknown }; + } catch { + held = null; + } + if (!held || typeof held.pid !== 'number') return { stale: ageMs > 1_000, holder: 'a writer that has not yet written its pid' }; + const holder = `process ${held.pid} (since ${String(held.at)})`; + // This process does not hold it (that is answered before a lock file is + // read), so a lock naming this pid was left by a dead process that had it. + if (held.pid === process.pid) return { stale: true, holder }; + return { stale: !processAlive(held.pid) || ageMs > INSTALLATIONS_LOCK_STALE_MS, holder }; +} + +/** Lock files this process holds, and how deep: the lock is re-entrant within a process. */ +const HELD = new Map(); + +/** + * Run `fn` holding this project's installations lock for `environmentId`, and + * release the lock whatever `fn` does. Throws, naming the holder and the lock + * file, when another live writer holds it past {@link INSTALLATIONS_LOCK_WAIT_MS}. + * + * Re-entrant within a process, so a caller can hold it across its own checks + * and the write that follows them (`os plugin install` does) while the write + * takes it again. + */ +export function withPluginInstallationsLock( + environmentId: string | undefined, + fn: () => T, + options: { waitMs?: number } = {}, +): T { + const file = pluginInstallationsFile(environmentId); + const lock = `${file}.lock`; + const depth = HELD.get(lock) ?? 0; + if (depth > 0) { + HELD.set(lock, depth + 1); + try { + return fn(); + } finally { + HELD.set(lock, depth); + } + } + makeDirectorySync(dirname(file)); + const waitMs = options.waitMs ?? INSTALLATIONS_LOCK_WAIT_MS; + const deadline = Date.now() + waitMs; + for (;;) { + let fd: number | undefined; + try { + fd = openSync(lock, 'wx'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + if (fd !== undefined) { + try { + writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); + } finally { + closeSync(fd); + } + break; + } + const { stale, holder } = readLock(lock); + if (stale) { + rmSync(lock, { force: true }); + continue; + } + if (Date.now() >= deadline) { + throw new Error( + `The plugin installations file ${file} is locked by ${holder}, still running after ` + + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, + ); + } + sleepSync(50); + } + HELD.set(lock, 1); + try { + return fn(); + } finally { + HELD.delete(lock); + rmSync(lock, { force: true }); + } +} + +function writeInstallations(environmentId: string | undefined, next: PluginInstallationRecord[]): void { const file = pluginInstallationsFile(environmentId); makeDirectorySync(dirname(file)); const tmp = `${file}.${process.pid}.tmp`; writeFileSync(tmp, `${JSON.stringify({ installations: next } satisfies InstallationsFile, null, 2)}\n`, 'utf8'); renameSync(tmp, file); } + +/** + * Record `record` as the installed version of its plugin id, replacing any + * earlier record for the same id (an update swaps the installation pointer, + * ADR-0025 §3.8). Runs under the installations lock; the write lands through + * a temporary file and a rename, so a reader never sees half a file. + */ +export function recordPluginInstallation(environmentId: string | undefined, record: PluginInstallationRecord): void { + withPluginInstallationsLock(environmentId, () => { + const current = loadPluginInstallations(environmentId); + const next = current.some((r) => r.id === record.id) + ? current.map((r) => (r.id === record.id ? record : r)) + : [...current, record]; + writeInstallations(environmentId, next); + }); +} + +/** + * Mark the installation of `id` for removal at the next boot + * (`os plugin uninstall`). Answers the marked record, or `undefined` when this + * project has no installation of `id`. Runs under the installations lock. + */ +export function markPluginUninstall(environmentId: string | undefined, id: string): PluginInstallationRecord | undefined { + return withPluginInstallationsLock(environmentId, () => { + const current = loadPluginInstallations(environmentId); + const found = current.find((r) => r.id === id); + if (!found) return undefined; + const marked: PluginInstallationRecord = { ...found, status: 'uninstall-pending' }; + writeInstallations(environmentId, current.map((r) => (r.id === id ? marked : r))); + return marked; + }); +} + +/** + * Drop the record of `id` once its uninstall is finished, and forget its + * disable, so a later install of the same id starts enabled. Runs under the + * installations lock. + */ +export function dropPluginInstallation(environmentId: string | undefined, id: string): void { + withPluginInstallationsLock(environmentId, () => { + const current = loadPluginInstallations(environmentId); + writeInstallations(environmentId, current.filter((r) => r.id !== id)); + }); + if (installedPluginDisabledIds(environmentId).has(id)) setInstalledPluginDisabled(environmentId, id, false); +} + +/** + * Every install directory another project's records name in this environment, + * so an uninstall never removes a directory a second project still loads; or + * `null` when one of those files cannot be read, which keeps every directory. + */ +export function otherProjectsInstallationDirs(environmentId?: string): Set | null { + const dir = installationsDir(); + const own = pluginInstallationsFile(environmentId); + const segment = environmentSegment(environmentId); + const dirs = new Set(); + if (!existsSync(dir)) return dirs; + for (const name of readdirSync(dir)) { + if (!name.endsWith('.json')) continue; + // `..json`; the project key carries no `.`. + const stem = name.slice(0, -'.json'.length); + if (stem.slice(0, stem.lastIndexOf('.')) !== segment) continue; + const file = join(dir, name); + if (file === own) continue; + try { + for (const record of readInstallationsFile(file)) if (typeof record?.dir === 'string') dirs.add(record.dir); + } catch { + return null; + } + } + return dirs; +} + +/** + * The installed plugin ids disabled in this environment and project + * (`os plugin disable`). Kept in the package disable-state store, keyed by the + * plugin environment id ({@link DEFAULT_PLUGIN_ENVIRONMENT_ID} when unset). + */ +export function installedPluginDisabledIds(environmentId?: string): Set { + return loadDisabledPackageIds(environmentSegment(environmentId)); +} + +/** Disable or enable the installed plugin `id`; it takes effect at the next boot. */ +export function setInstalledPluginDisabled(environmentId: string | undefined, id: string, disabled: boolean): void { + setPackageDisabled(environmentSegment(environmentId), id, disabled); +} diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 77bdc0555b9..008f24fcdc1 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -282,11 +282,11 @@ } }, "runtime": { - "status": "live-elsewhere", - "verifiedAt": "2026-08-29", - "evidenceScope": "cross-repo", - "evidence": "cloud: packages/service-cloud/src/plugin-permission-audit.ts#auditPluginPermissions @15f55df + packages/service-cloud/src/routes/package-publish.ts:530 — the marketplace publish gate: an unverified publisher requesting the node tier is hard-rejected with HTTP 422 and forced to manual review (#12400 reading of 2026-08-29). Load-side dispatch here is dead by measurement; the note carries that half.", - "note": "ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD — and since #13483 the split IS the verdict: `live-elsewhere` says dead here by measurement, genuinely enforced in a sibling repo, and unlike the `dead` this row carried until then (with this note's qualifying sentence as the only guard — prose, which no check reads), it cannot be read as a licence to delete and the gate holds its criteria (foreign pointer, cross-repo scope, dated attestation with a 180d expiry — scripts/liveness/elsewhere.mts). LOCAL HALF, measured 2026-08-23 against b9e9227e3 with per-key controls: the only reads of `manifest.runtime` in objectstack are two CLI progress lines that echo the value — packages/cli/src/commands/plugin/build.ts:127 and packages/cli/src/commands/plugin/publish.ts:94, both `runtime: ${manifest.runtime ?? 'unset'}`. Nothing dispatches on the tier: there is no `runtime === 'sandbox'` branch anywhere, and the QuickJS runner (packages/runtime/src/sandbox/) is the hook/action SCRIPT-BODY sandbox, reached from body-runner factories, never from a plugin's declared tier. objectui: zero reads. So the declared capability — \"in-process full PluginContext vs QuickJS-WASM capability-gated vs out-of-process\" (ADR-0025 §3.6) — is not delivered at load, and echoing a string into a build log is not isolation. FOREIGN HALF (the evidence pointer): #12400 measured cloud origin/main @15f55df on 2026-08-29, controls held (15 manifest-property reads findable there) — `auditPluginPermissions` treats `runtime === 'node'` from an unverified publisher as a hard block, the caller turns it into HTTP 422 with forced manual review, and the tier is persisted to the version row (plugin-artifact.ts:179, package-publish.ts:531). `verifiedAt` on this row is THAT reading's date; re-attestation means someone with cloud access re-reads the enforcer and re-pins the commit — never a bare re-stamp (access is seat-dependent: `add_repo` on cloud is denied from some seats, including the #13483 seat on 2026-09-01, while the 2026-08-24 reading (commit be21955ba) and the #12400 one prove cloud-capable seats exist). WHY THIS WAS SECURITY-SHAPED RATHER THAN COSMETIC: the tombstone this ledger records one key above used to tell every upgrading author, verbatim, \"If you were relying on [loading.sandboxing] for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced\" — a redirection at a tier this repo does not enforce at load, i.e. ADR-0049 false compliance with a shipped migration message attached. Maintainer ruling 2026-08-30 (executed by commit a9ee98992) took option B — correct the text, do not retire the key — and explicitly ruled OUT retirement, because deleting the key would tear out the marketplace's trust-gate input; the tombstone, both `runtime` describes and the ADR-0087 D3 entry `plugin-manifest-loading-retired` state the publish-gate/load-side split. Adding load-side enforcement is the open half, tracked as a v18 direction, not as a removal — if it lands, the verdict here flips to `live` with local evidence." + "status": "live", + "verifiedAt": "2026-10-29", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/installed-plugin-loader.ts#dispatchPluginRuntime (the tier dispatch point of the boot-time loader for plugins `os plugin install` installed, reached from `checkRecord` after the kept artifact, its signature and the unpacked tree are re-verified: `runtime: 'node'` ⇒ the entry module is imported in process; `sandbox` / `worker` ⇒ refused by name, `runtime_not_dispatched`, no in-process fallback; undeclared ⇒ `runtime_undeclared`, default-deny); packages/cli/src/commands/plugin/install.ts#PluginInstall (the same dispatch read at install: a tier the boot will refuse is installed with a warning naming the refusal)", + "note": "ENFORCED AT LOAD SINCE #27123 (ADR-0025 §3.5 M1), AND STILL AT THE CLOUD PUBLISH GATE — so the verdict FLIPPED live-elsewhere → live on local evidence, the flip this note had named as the open half (\"if it lands, the verdict here flips to `live` with local evidence\"). LOCAL HALF, measured 2026-10-29: `os serve` (and `os dev` / `os start` through it) loads the plugins `os plugin install` installed through `loadInstalledPlugins` before the kernel bootstraps, and every one passes `dispatchPluginRuntime` before any of its code is imported. What the declared tier now DOES: `node` loads in process with a full PluginContext — T0, author code in the host process, NOT isolation; `sandbox` and `worker` are REFUSED by name until M3 routes them (the QuickJS runner under packages/runtime/src/sandbox/ is still the hook/action SCRIPT-BODY sandbox, not reached from a plugin's tier), so a declared `sandbox` is never run unconfined, it is not run at all; an installed plugin that declares no tier is refused (§3.6 default-deny). A plugin a stack's own `plugins` array names is the app's code and is not dispatched on this key. Both `runtime` describes state this, pinned in packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts, which also pins the retracted \"load-side enforcement is NOT implemented\" absent. The build/publish progress lines still echo the value. FOREIGN HALF, kept as attributed history and not re-read for this flip (cloud is out of this card's reach): #12400 measured cloud origin/main @15f55df on 2026-08-29 — `auditPluginPermissions` treats `runtime === 'node'` from an unverified publisher as a hard block, turned into HTTP 422 with forced manual review, and the tier is persisted to the version row. PROVENANCE: the row was the ledger's first `live-elsewhere` verdict (#13483) after the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took \"say it truthfully\" over retirement — retirement stays ruled OUT, because deleting the key would tear out the marketplace's trust-gate input — and `loading`'s former tombstone had redirected upgrading authors to this key as something \"which [is] enforced\" while nothing here dispatched on it (ADR-0049 false compliance with a shipped migration message attached)." }, "packaging": { "status": "live", diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index 2831343c0a4..6de4a273912 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 28 | 0 | 1 | 13 | 0 | 42 | +| `manifest` | 29 | 0 | 0 | 13 | 0 | 42 | diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 8ce1a643040..3b4139ce019 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -197,25 +197,28 @@ export type PluginEngines = z.input; * forced to manual review. That gate is a real consumer of this key, which is * why the tier is not a candidate for retirement. * - * NOT ENFORCED at load. Load-side enforcement is **not implemented** — nothing - * dispatches on the tier (its only reads are two CLI progress lines that echo - * the value), so a locally installed plugin runs in-process with a full - * PluginContext whatever tier it declares. `sandbox` and `worker` therefore - * name the isolation the publish gate assumes, not isolation the loader - * applies; a local install is not confined by this field. The QuickJS runner - * under `packages/runtime/src/sandbox/` is the hook / action SCRIPT-BODY - * sandbox and is never reached from a plugin's declared tier. + * ENFORCED at load for a plugin `os plugin install` installed (ADR-0025 §3.5 + * M1): the boot-time loader (`packages/runtime/src/installed-plugin-loader.ts`, + * `dispatchPluginRuntime`) dispatches on the tier before it imports anything. + * `node` is imported in process with a full PluginContext — author code in + * the host process, T0, NOT isolation. `sandbox` and `worker` are REFUSED by + * name until M3 routes them, with no in-process fallback, and an installed + * plugin that declares no tier is refused too (default-deny). So a declared + * `sandbox` is never run unconfined: today it is not run at all. The QuickJS + * runner under `packages/runtime/src/sandbox/` is the hook / action + * SCRIPT-BODY sandbox and is not reached from a plugin's declared tier yet. * - * Enforcing the tier at load is tracked as its own decision (v18 direction); - * do not read this field as an isolation guarantee until it lands. + * A plugin a stack's own `plugins` array names is the app's code and is not + * dispatched on this field. */ export const PluginRuntimeSchema = z .enum(['node', 'sandbox', 'worker']) .describe( 'Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud ' + 'marketplace publish gate (an unverified publisher requesting `node` is rejected with ' - + 'HTTP 422 and forced to manual review); load-side enforcement is NOT implemented, so a ' - + 'locally installed plugin is not isolated by the tier it declares', + + 'HTTP 422 and forced to manual review) and at boot for a locally installed plugin: only ' + + '`node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ' + + 'they are routed (ADR-0025 M3), never run in process instead', ); export type PluginRuntime = z.input; @@ -1016,18 +1019,19 @@ export const ManifestSchema = strictObject({ * Trust / isolation tier the plugin DECLARES (ADR-0025 §3.6). * Unset implies a pure-metadata package (no executable code). * - * ⚠️ Enforced at the cloud marketplace **publish gate** only — an unverified + * ⚠️ Enforced at the cloud marketplace **publish gate** — an unverified * publisher requesting the `node` tier is hard-rejected (HTTP 422) and forced - * to manual review. **Load-side enforcement is not implemented**: a locally - * installed plugin is not isolated by the tier it declares. See - * {@link PluginRuntimeSchema} for the full split. + * to manual review — and **at boot for a locally installed plugin**: only + * `node` is loaded (in process, not isolated); `sandbox` and `worker` are + * refused until ADR-0025 M3 routes them. See {@link PluginRuntimeSchema} for + * the full split. */ runtime: PluginRuntimeSchema.optional() .describe( 'Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud ' + 'marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual ' - + 'review); load-side enforcement is NOT implemented, so a locally installed plugin is ' - + 'not isolated by the tier it declares', + + 'review) and at boot for a locally installed plugin: only `node` is loaded (in process, ' + + 'not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them', ), /** diff --git a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts index 4af919cd523..3ecda4586ec 100644 --- a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts +++ b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts @@ -42,11 +42,24 @@ import { ManifestSchema, PluginRuntimeSchema } from './manifest.zod'; // amendment, D2 = A). `loading` is a strict unknown key now, pinned in // `plugin-loading-retirement.test.ts`; the describes below are the carriers // that remain, and the ones the reference docs render. +// +// [#27123, ADR-0025 §3.5 M1] The second half MOVED, so it is pinned as it now +// reads. The boot-time loader for plugins `os plugin install` installed +// dispatches on the tier (`dispatchPluginRuntime` in +// `packages/runtime/src/installed-plugin-loader.ts`, pinned there): only `node` +// is loaded, in process and NOT isolated, and `sandbox` / `worker` are refused +// until M3 routes them. Still a split, and still both halves: the text must +// not claim isolation (`node` is not isolation, and nothing confines +// `sandbox` yet: it is refused). The old sentence, "load-side enforcement is +// NOT implemented", is now false and is pinned absent. /** Both halves of the truthful split, as separate probes. */ const SAYS_PUBLISH_GATE_ENFORCED = /publish gate/i; const SAYS_422 = /422/; -const SAYS_LOAD_SIDE_NOT_ENFORCED = /load-side enforcement is NOT implemented/i; +const SAYS_BOOT_LOADS_ONLY_NODE = /at boot for a locally installed plugin: only `node` is loaded/i; +const SAYS_NODE_NOT_ISOLATED = /not isolated/i; +const SAYS_SANDBOX_WORKER_REFUSED = /`sandbox` and `worker` are refused until/i; +const RETRACTED_NOT_ENFORCED = /load-side enforcement is NOT implemented/i; describe('manifest.runtime trust-tier text is truthful', () => { const baseManifest = { id: 'com.example.plugin', @@ -62,7 +75,10 @@ describe('manifest.runtime trust-tier text is truthful', () => { expect(text, 'the enum must carry a describe at all').not.toBe(''); expect(text).toMatch(SAYS_PUBLISH_GATE_ENFORCED); expect(text).toMatch(SAYS_422); - expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); + expect(text).toMatch(SAYS_BOOT_LOADS_ONLY_NODE); + expect(text).toMatch(SAYS_NODE_NOT_ISOLATED); + expect(text).toMatch(SAYS_SANDBOX_WORKER_REFUSED); + expect(text).not.toMatch(RETRACTED_NOT_ENFORCED); }); it('states BOTH halves on the `manifest.runtime` field describe', () => { @@ -74,7 +90,10 @@ describe('manifest.runtime trust-tier text is truthful', () => { expect(text, 'the field must carry its own describe').not.toBe(''); expect(text).toMatch(SAYS_PUBLISH_GATE_ENFORCED); expect(text).toMatch(SAYS_422); - expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); + expect(text).toMatch(SAYS_BOOT_LOADS_ONLY_NODE); + expect(text).toMatch(SAYS_NODE_NOT_ISOLATED); + expect(text).toMatch(SAYS_SANDBOX_WORKER_REFUSED); + expect(text).not.toMatch(RETRACTED_NOT_ENFORCED); }); it('`loading` no longer carries the sentence at all — it is a strict unknown key (#25767)', () => { From af0b38885fe941d8478494385b47abbb069447ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:04:59 +0000 Subject: [PATCH 2/4] fix(cli, runtime, core, spec): review round 1 on the boot-time plugin loader (#27123) Seat review r1, contract review 6487771335, and the two CI reds on a6a7e490. - runtime: a stale installations lock is cleared only under .steal and only while it is still the file judged stale (inode + bytes); locks carry pid, thread and a fresh token, and are released only while still the holder's. The own-pid clause compares the thread. - runtime: a throw on one installation refuses it by name (install_unreadable, uninstall_failed, records_unreadable), and the boot goes on. Uninstall finishes under the lock against a re-read record (record_changed otherwise). - runtime: the installed-plugin path reads the disable-state file strictly (loadDisabledPackageIdsStrict): a corrupt file refuses each plugin disabled_state_unreadable instead of loading disabled ones, and disable / enable refuse disable_state_failed and write nothing. - runtime: a bare @objectstack/* import inside a verified install dir resolves to the host's packages (module.register resolve hook), so a plugin os plugin build produced loads. - core: readTar honours the typeflag (regular files only, directories skipped, others refused by name), joins a ustar prefix, refuses a cut-short entry and a bad size, and copies nothing. The verdict check is described as what it is, not an in-process boundary. - cli: loader_failed and kernel_refused at the serve wiring; the lock error matched by type; uninstall's stale "Marked" fixed; install size-checks the artifact before reading it. The serve e2e builds, signs, installs and boots a plugin from os plugin build. - spec: the runtime describes say "at boot for a plugin os plugin install installed"; the field TSDoc bridges the unset-tier sentences; the liveness README's account of the row; the liveness gate and lint self-tests plant the old live-elsewhere row instead of reading it from the shipped ledger. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude --- .changeset/27123-boot-time-plugin-loader.md | 26 +- .../27123-runtime-tier-enforced-at-boot.md | 4 +- .../references/api/package-api-assembled.mdx | 6 +- content/docs/references/api/package-api.mdx | 12 +- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 4 +- .../references/kernel/package-registry.mdx | 4 +- packages/cli/src/commands/plugin/disable.ts | 16 +- packages/cli/src/commands/plugin/enable.ts | 16 +- packages/cli/src/commands/plugin/install.ts | 28 +- packages/cli/src/commands/plugin/uninstall.ts | 16 +- .../cli/src/utils/installed-plugins-boot.ts | 65 ++-- ...erve-installed-plugins.integration.test.ts | 83 +++++- packages/core/src/plugin-loader.ts | 6 +- .../src/security/plugin-artifact-archive.ts | 46 ++- .../security/plugin-artifact-verdict.test.ts | 57 ++++ .../src/security/plugin-artifact-verdict.ts | 7 + .../lint/src/lint-liveness-properties.test.ts | 58 ++-- packages/runtime/src/index.ts | 2 + .../src/installed-plugin-loader.test.ts | 112 ++++++- .../runtime/src/installed-plugin-loader.ts | 279 ++++++++++++++---- packages/runtime/src/package-state-store.ts | 26 ++ .../plugin-installation-store.lock.test.ts | 85 +++++- .../runtime/src/plugin-installation-store.ts | 188 +++++++++--- packages/spec/liveness/README.md | 15 +- .../scripts/liveness/check-liveness.test.ts | 76 ++++- packages/spec/src/kernel/manifest.zod.ts | 23 +- .../plugin-runtime-tier-truthful-text.test.ts | 2 +- 28 files changed, 1052 insertions(+), 212 deletions(-) diff --git a/.changeset/27123-boot-time-plugin-loader.md b/.changeset/27123-boot-time-plugin-loader.md index 0a3b71d1919..7898702120b 100644 --- a/.changeset/27123-boot-time-plugin-loader.md +++ b/.changeset/27123-boot-time-plugin-loader.md @@ -11,17 +11,23 @@ Metadata-protocol: none The second slice of ADR-0025's local install loop (§3.5 M1). Before this, an installed plugin was unpacked and recorded, and nothing loaded it. -- **The boot-time loader** (`@objectstack/runtime`: `loadInstalledPlugins`, `verifyInstalledPlugins`). `os serve` (and `os dev` / `os start` through it) calls it after the config's own `plugins` are registered and before the kernel bootstraps, then `kernel.use`s each plugin that passed, so dependency order and health checks stay the kernel's. For each installation of this project and environment, in this order, the first failing check refuses that plugin by name and nothing of it is imported: +**Migration (`@objectstack/core`):** a host outside this repo that passes `kernel.use` a plugin object carrying a `signature` now has that plugin refused (`Plugin X carries a signature … that no host verified against its artifact`), where any well-formed signature used to pass. FROM: setting `plugin.signature` and calling `kernel.use(plugin)`. TO: verify the artifact yourself, then call `recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 })` from `@objectstack/core` before `kernel.use`; or leave `signature` unset. No plugin in this repo sets it. + +- **The boot-time loader** (`@objectstack/runtime`: `loadInstalledPlugins`, `verifyInstalledPlugins`). `os serve` (and `os dev` / `os start` through it) calls it after the config's own `plugins` are registered and before the kernel bootstraps, then `kernel.use`s each plugin that passed, so dependency order and health checks stay the kernel's. For each installation of this project and environment, in this order, the first failing check refuses that plugin by name, before anything of it is imported: - the record names the directory the store derives for its id and version (`install_dir_unexpected`); nothing is read from or removed under any other; - - a disabled id is skipped, before anything of it is read; - - the kept `artifact.osplugin` hashes to the recorded sha256 (`artifact_changed`, `install_dir_missing`); - - its detached signature, over the raw kept bytes and before anything is inflated, names the recorded key id, the key is `active` in `sys_trusted_key` with the fingerprint the install verified, and it verifies (`signature_missing`, `signature_malformed`, `signature_key_mismatch`, `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`, `trusted_key_changed`, `signature_invalid`, `trusted_keys_unreadable`). So a key revoked since install refuses the plugin at its next boot. An install admitted under `--trust-unsigned` loads with a warning on every boot; + - a disabled id is skipped, before anything of it is read. A disable-state file that is there and cannot be read refuses each plugin instead (`disabled_state_unreadable`): an unreadable disable is not taken for none; + - the kept `artifact.osplugin` hashes to the recorded sha256 (`artifact_changed`, `install_dir_missing`); a kept file larger than any artifact an install accepts is refused without being read; + - its detached signature, over the raw kept bytes and before anything is inflated, names the recorded key id, the key is `active` in `sys_trusted_key` with the fingerprint the install verified, and it verifies (`signature_missing`, `signature_malformed`, `signature_key_mismatch`, `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`, `trusted_key_changed`, `signature_invalid`, `trusted_keys_unreadable`). The signature covers the bytes only; what ties them to the record's id and version is the manifest comparison below. So a key revoked since install refuses the plugin at its next boot. An install admitted under `--trust-unsigned` loads with a warning on every boot; - the unpacked tree is byte-for-byte the artifact, with no other file and no symbolic link, and the artifact's manifest is the recorded id and version (`tree_changed`, `manifest_mismatch`, `artifact_unreadable`); - **the tier dispatch point** (`dispatchPluginRuntime`, ADR-0025 §3.6): `runtime: 'node'` imports the entry module in process; `sandbox` and `worker` are refused by name until M3 routes them (`runtime_not_dispatched`), with no in-process fallback; a manifest that declares no tier is refused (`runtime_undeclared`, default-deny); - - the entry module is a file of the artifact (`entry_missing`), it imports (`import_failed`), and it exports a plugin (`not_a_plugin`) by the rule a stack's `plugins` entry takes (a plain bundle is wrapped in `AppPlugin`). + - the entry module is a file of the artifact (`entry_missing`). + + A check that throws on one installation (a file it cannot read, one that vanished mid-walk) refuses that plugin by name too (`install_unreadable`): one installation never fails the boot. What passed is then imported by the rule a stack's `plugins` entry takes (a plain bundle is wrapped in `AppPlugin`). The refusals from here on come after the entry module's top-level code ran, so the plugin is not registered but its module was evaluated: the import throws (`import_failed`), it exports no plugin or reading its export throws (`not_a_plugin`), it declares a signature other than the verified one (`signature_conflict`), and the host's own `plugin_name_taken` / `kernel_refused`. + + A bare `@objectstack/*` import inside a verified install directory resolves to the packages the host runs on (a resolve hook, `module.register`): `os plugin build` leaves them external, and an install directory under `OS_HOME` has no `node_modules` holding them. Every other bare specifier resolves as Node resolves it from the install directory, and a file the plugin imports lazily is read when it is imported, after the check. - The environment's trusted keys are read once per boot, through the same read-only one-shot boot `os plugin install` reads them through, and only when a signed install needs them; with no installations file nothing is read at all. A refused plugin does not stop the boot: it is printed as one `✗` line naming the refusal, and the banner counts the refusals. An installed plugin never replaces a plugin the boot already registered under the same name (`plugin_name_taken`). -- **The kernel's signature check is real.** `PluginLoader` used to pass any well-formed `signature` a plugin carried. The host that verified the artifact now records its verdict against the plugin object (`recordPluginArtifactVerdict`, `@objectstack/core`), and the loader refuses a plugin whose signature no host verified. No plugin in the repo sets `signature`. -- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, and the inflate is bounded (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`. -- **`os plugin disable ID` / `os plugin enable ID`** keep an installed plugin from loading, or load it again, at the next boot (the package disable-state store, keyed by the plugin environment id). **`os plugin uninstall ID`** marks the installation; the next boot loads nothing of it, removes its directory unless another project sharing the home and environment still records it, drops the record and forgets its disable. Each refuses an id this project has not installed by name (`plugin_not_installed`); `--json` on all three. -- **The installations file takes a lock** for every read-modify-write: `.lock`, created exclusively with the writer's pid. A writer waits up to 10 s for a live holder, then refuses naming it. A lock a crashed process left is cleared by the next writer: its pid is not a live process, or it is older than 5 minutes, or it names the writer's own pid while the writer holds nothing, or it cannot be parsed and is older than a second. + The environment's trusted keys are read once per boot, through the same read-only one-shot boot `os plugin install` reads them through, and only when a signed install needs them; with no installations file nothing is read at all. A refused plugin does not stop the boot: it is printed as one `✗` line naming the refusal, and the banner counts the refusals; a loader that throws outright is one `loader_failed` line, and the boot goes on without installed plugins. An installed plugin never replaces a plugin the boot already registered under the same name (`plugin_name_taken`); a host plugin registered later under the same name replaces the installed one, with the kernel's warning. +- **The kernel's signature check is real.** `PluginLoader` used to pass any well-formed `signature` a plugin carried. The host that verified the artifact now records its verdict against the plugin object (`recordPluginArtifactVerdict`, `@objectstack/core`), and the loader refuses a plugin whose signature no host verified. This stops a `signature` field from reading as verified; it is not a boundary against code already running in the process, which can call the public `recordPluginArtifactVerdict` itself. +- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, refuses an artifact file larger than any artifact may unpack to before reading it, and bounds the inflate (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The reader takes regular files and directories only, joins a POSIX `prefix` onto the name, refuses a link, device or extension header and a cut-short entry by name, and copies neither its input nor its files. The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`. +- **`os plugin disable ID` / `os plugin enable ID`** keep an installed plugin from loading, or load it again, at the next boot (the package disable-state store, keyed by the plugin environment id). On a disable-state file they cannot read they refuse (`disable_state_failed`) and write nothing. **`os plugin uninstall ID`** marks the installation; the next boot loads nothing of it and, under the installations lock and only while the record is still the marked one (`record_changed` otherwise: an id installed again since the boot read the records keeps its fresh install), removes its directory unless another project sharing the home and environment still records it, drops the record and forgets its disable. A removal that fails is `uninstall_failed`, and a record still marked is tried again at the next boot. Each command refuses an id this project has not installed by name (`plugin_not_installed`, also when it was removed while the command ran); `--json` on all three. +- **The installations file takes a lock** for every read-modify-write: `.lock`, created exclusively with the writer's pid, thread and a fresh token. A writer waits up to 10 s for a live holder, then refuses naming it (`PluginInstallationsLockedError`, which the commands report as `installations_locked`); the commands report any other failure under their own code. A lock a crashed process left is cleared by the next writer: its pid is not a live process, or it is older than 5 minutes, or it names the writer's own pid and thread while the writer holds nothing (another thread of the same process is a live holder), or it cannot be parsed and is older than a second. Clearing holds `.lock.steal` and removes the lock only while it is still the file judged stale, so two writers that judge the same lock stale cannot both take it; a writer releases its lock only while the file is still its own. diff --git a/.changeset/27123-runtime-tier-enforced-at-boot.md b/.changeset/27123-runtime-tier-enforced-at-boot.md index 1a79bbedfff..a4f86873ec9 100644 --- a/.changeset/27123-runtime-tier-enforced-at-boot.md +++ b/.changeset/27123-runtime-tier-enforced-at-boot.md @@ -2,9 +2,9 @@ '@objectstack/spec': patch --- -docs(spec): the plugin manifest's `runtime` tier is described as enforced at boot for a locally installed plugin — only `node` is loaded, `sandbox` and `worker` are refused until ADR-0025 M3 routes them (#27123) +docs(spec): the plugin manifest's `runtime` tier is described as enforced at boot for a plugin `os plugin install` installed — only `node` is loaded, `sandbox` and `worker` are refused until ADR-0025 M3 routes them (#27123) Clause-②: no — no key or accepted value changes; `node`, `sandbox` and `worker` still parse, and an unset tier is still legal Metadata-protocol: none -The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point. +The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. The field's TSDoc also says that an unset tier, which declares a pure-metadata package, is an omission on an artifact `os plugin install` installed, and is refused there. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point, and the ledger README keeps its account as the worked example of `live-elsewhere`, which no row holds now. diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index c472485c3a8..17073084e18 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -105,7 +105,7 @@ Installed package row whose manifest is the assembled package body | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -288,7 +288,7 @@ This schema accepts one of the following structures: | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -351,7 +351,7 @@ This schema accepts one of the following structures: | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 3cb14a6cb75..4a558c76519 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -161,7 +161,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -207,7 +207,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -315,7 +315,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -432,7 +432,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -565,7 +565,7 @@ Upgrade package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -659,7 +659,7 @@ Resolve dependencies request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ebc695a6f5d..dafe7755e7b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2112,7 +2112,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 88b390d450a..568a0c98ea1 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -61,7 +61,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -207,7 +207,7 @@ Structured plugin permission grants (ADR-0025 §3.2) ## PluginRuntime -Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (an unverified publisher requesting `node` is rejected with HTTP 422 and forced to manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until they are routed (ADR-0025 M3), never run in process instead +Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (an unverified publisher requesting `node` is rejected with HTTP 422 and forced to manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until they are routed (ADR-0025 M3), never run in process instead ### Allowed Values diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 41cfe069935..8aed9cf9f0e 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -216,7 +216,7 @@ Install package request | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -314,7 +314,7 @@ Installed package with runtime lifecycle state | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | -| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a locally installed plugin: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review) and at boot for a plugin `os plugin install` installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | | **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/packages/cli/src/commands/plugin/disable.ts b/packages/cli/src/commands/plugin/disable.ts index ef6cef9f560..b0f284aec94 100644 --- a/packages/cli/src/commands/plugin/disable.ts +++ b/packages/cli/src/commands/plugin/disable.ts @@ -45,8 +45,20 @@ export default class PluginDisable extends Command { this.exit(1); return; } - const changed = !installedPluginDisabledIds(environmentId).has(args.id); - if (changed) setInstalledPluginDisabled(environmentId, args.id, true); + let changed: boolean; + try { + changed = !installedPluginDisabledIds(environmentId).has(args.id); + if (changed) setInstalledPluginDisabled(environmentId, args.id, true); + } catch (error) { + const message = `${(error as Error).message}. Nothing was changed.`; + if (json) { + await emitJson({ error: 'disable_state_failed', message }, 1, { compact: true }); + return; + } + printError(message); + this.exit(1); + return; + } if (json) { await emitJson({ id: args.id, version: found.record.version, environmentId, disabled: true, changed }, 0, { compact: true }); return; diff --git a/packages/cli/src/commands/plugin/enable.ts b/packages/cli/src/commands/plugin/enable.ts index 6d169dc4824..1defa071fe5 100644 --- a/packages/cli/src/commands/plugin/enable.ts +++ b/packages/cli/src/commands/plugin/enable.ts @@ -39,8 +39,20 @@ export default class PluginEnable extends Command { this.exit(1); return; } - const changed = installedPluginDisabledIds(environmentId).has(args.id); - if (changed) setInstalledPluginDisabled(environmentId, args.id, false); + let changed: boolean; + try { + changed = installedPluginDisabledIds(environmentId).has(args.id); + if (changed) setInstalledPluginDisabled(environmentId, args.id, false); + } catch (error) { + const message = `${(error as Error).message}. Nothing was changed.`; + if (json) { + await emitJson({ error: 'disable_state_failed', message }, 1, { compact: true }); + return; + } + printError(message); + this.exit(1); + return; + } if (json) { await emitJson({ id: args.id, version: found.record.version, environmentId, disabled: false, changed }, 0, { compact: true }); return; diff --git a/packages/cli/src/commands/plugin/install.ts b/packages/cli/src/commands/plugin/install.ts index 75e65bfa9fb..dfe129cff76 100644 --- a/packages/cli/src/commands/plugin/install.ts +++ b/packages/cli/src/commands/plugin/install.ts @@ -66,6 +66,7 @@ import { dispatchPluginRuntime, recordPluginInstallation, withPluginInstallationsLock, + PluginInstallationsLockedError, type PluginInstallationRecord, } from '@objectstack/runtime'; import { @@ -77,7 +78,14 @@ import { printSuccess, printWarningToStderr, } from '../../utils/format.js'; -import { MANIFEST_FILENAME, SIGNATURE_FILENAME, readTarGz, sha256Hex, type ArchiveFile } from '../../utils/osplugin.js'; +import { + MANIFEST_FILENAME, + MAX_UNPACKED_ARTIFACT_BYTES, + SIGNATURE_FILENAME, + readTarGz, + sha256Hex, + type ArchiveFile, +} from '../../utils/osplugin.js'; import { readTrustedKeys } from '../../utils/plugin-trust-read.js'; import { INSTALLED_ARTIFACT_FILE, @@ -148,7 +156,13 @@ export default class PluginInstall extends Command { } let bytes: Uint8Array; try { - if (!statSync(artifactPath).isFile()) throw new Error('not a regular file'); + const stat = statSync(artifactPath); + if (!stat.isFile()) throw new Error('not a regular file'); + // Read nothing larger than any artifact may unpack to: the compressed + // bytes are held whole for the signature check. + if (stat.size > MAX_UNPACKED_ARTIFACT_BYTES) { + throw new Error(`it is ${stat.size} bytes, more than the ${MAX_UNPACKED_ARTIFACT_BYTES} an .osplugin may unpack to`); + } bytes = new Uint8Array(await readFile(artifactPath)); } catch (error) { await refuse({ @@ -403,8 +417,14 @@ export default class PluginInstall extends Command { return { record, replaced }; }); } catch (error) { - // The lock itself: another live writer held it past the wait. - outcome = { refusal: { error: 'installations_locked', message: `${(error as Error).message} Nothing was installed.` } }; + // Another live writer held the lock past the wait, or the lock file + // itself could not be written. + outcome = { + refusal: { + error: error instanceof PluginInstallationsLockedError ? 'installations_locked' : 'install_failed', + message: `${(error as Error).message} Nothing was installed.`, + }, + }; } if ('refusal' in outcome) { await refuse(outcome.refusal); diff --git a/packages/cli/src/commands/plugin/uninstall.ts b/packages/cli/src/commands/plugin/uninstall.ts index df6c3a3670d..bd3f5b88081 100644 --- a/packages/cli/src/commands/plugin/uninstall.ts +++ b/packages/cli/src/commands/plugin/uninstall.ts @@ -13,7 +13,7 @@ */ import { Args, Command, Flags } from '@oclif/core'; -import { markPluginUninstall } from '@objectstack/runtime'; +import { PluginInstallationsLockedError, markPluginUninstall } from '@objectstack/runtime'; import { emitJson, printError, printHeader, printInfo, printKV, printSuccess } from '../../utils/format.js'; import { findPluginInstallation, pluginEnvironmentId } from '../../utils/plugin-install.js'; @@ -52,12 +52,22 @@ export default class PluginUninstall extends Command { const changed = found.record.status !== 'uninstall-pending'; let record = found.record; if (changed) { + let marked: typeof record | undefined; try { - record = markPluginUninstall(environmentId, args.id) ?? record; + marked = markPluginUninstall(environmentId, args.id); } catch (error) { - await refuse('installations_locked', `${(error as Error).message} Nothing was changed.`); + await refuse( + error instanceof PluginInstallationsLockedError ? 'installations_locked' : 'uninstall_failed', + `${(error as Error).message} Nothing was changed.`, + ); return; } + // Gone under the lock: another command removed it after it was found. + if (!marked) { + await refuse('plugin_not_installed', `${args.id} is not installed in the environment '${environmentId}' any more: it was removed while this command ran. Nothing was changed.`); + return; + } + record = marked; } if (json) { await emitJson({ id: args.id, version: record.version, environmentId, status: record.status, dir: record.dir, changed }, 0, { compact: true }); diff --git a/packages/cli/src/utils/installed-plugins-boot.ts b/packages/cli/src/utils/installed-plugins-boot.ts index ccca3f9b8e4..6e2fed02845 100644 --- a/packages/cli/src/utils/installed-plugins-boot.ts +++ b/packages/cli/src/utils/installed-plugins-boot.ts @@ -8,6 +8,7 @@ import { pluginInstallationsFile, type InstalledPluginRefusal, } from '@objectstack/runtime'; +import { isExitSignal } from './format.js'; import { pluginEnvironmentId } from './plugin-install.js'; import { readTrustedKeys } from './plugin-trust-read.js'; @@ -22,14 +23,22 @@ import { readTrustedKeys } from './plugin-trust-read.js'; * the read-only one-shot boot `os plugin install` reads them through * (`./plugin-trust-read.ts`, against `OS_DATABASE_URL` or the project * database, as the `os plugin` commands resolve it); `AppPlugin` for a plain - * bundle; and `kernel.use` for each plugin that passed, so dependency order - * and health checks are the kernel's. + * bundle; this file's URL, so a plugin's bare `@objectstack/*` imports resolve + * to the packages this CLI runs on; and `kernel.use` for each plugin that + * passed, so dependency order and health checks are the kernel's. * * A refused plugin does not stop the boot — the config `plugins` loop's - * precedent — but nothing of it runs and it is printed by name, one `✗` line - * each, with the count on the banner. An installed plugin never supersedes a - * plugin this boot already registered under the same name (the kernel's - * same-name rule would replace the host's): it is refused instead. + * precedent — and it is printed by name, one `✗` line each, with the count on + * the banner. A plugin refused before its import had nothing of it evaluated; + * one refused after it (`import_failed`, `not_a_plugin`, `signature_conflict`, + * `plugin_name_taken`, `kernel_refused`) had its entry module's top-level code + * run, and is not registered. An installed plugin never supersedes a plugin + * this boot already registered under the same name (the kernel's same-name + * rule would replace the host's): it is refused instead. A host plugin + * registered LATER under the same name supersedes the installed one, with the + * kernel's warning: the host's wins either way. Even a loader that throws + * outright is one `✗` line (`loader_failed`), and the boot goes on without + * installed plugins. * * No installations file for this project and environment: nothing is read, * and the boot is as it was. @@ -51,18 +60,30 @@ export async function registerInstalledPlugins(input: { const environmentId = pluginEnvironmentId(); if (!existsSync(pluginInstallationsFile(environmentId))) return null; - const result = await loadInstalledPlugins({ - environmentId, - resolveKeys: async (keyIds) => { - const read = await readTrustedKeys(keyIds, { databaseUrl: process.env.OS_DATABASE_URL, json: false }); - if (!read.ok) throw new Error(read.message); - return read.keys; - }, - wrapBundle: async (bundle) => { - const { AppPlugin } = await import('@objectstack/runtime'); - return new AppPlugin(bundle); - }, - }); + let result: Awaited>; + try { + result = await loadInstalledPlugins({ + environmentId, + resolveKeys: async (keyIds) => { + const read = await readTrustedKeys(keyIds, { databaseUrl: process.env.OS_DATABASE_URL, json: false }); + if (!read.ok) throw new Error(read.message); + return read.keys; + }, + wrapBundle: async (bundle) => { + const { AppPlugin } = await import('@objectstack/runtime'); + return new AppPlugin(bundle); + }, + resolveHostPackagesFrom: import.meta.url, + }); + } catch (error) { + if (isExitSignal(error)) throw error; + result = { + loaded: [], + removed: [], + disabled: [], + refused: [{ id: '*', version: '*', error: 'loader_failed', message: `the installed-plugin loader failed (${(error as Error).message}); no installed plugin was loaded.` }], + }; + } const out: InstalledPluginsBoot = { loaded: [], refused: [...result.refused] }; for (const { record, kept } of result.removed) { @@ -77,7 +98,13 @@ export async function registerInstalledPlugins(input: { console.error(chalk.dim(` Installed plugin ${record.id}@${record.version} is disabled (os plugin enable ${record.id})`)); } for (const { record, plugin } of result.loaded) { - const name = String((plugin as { name?: unknown }).name ?? record.id); + let name: string; + try { + name = String((plugin as { name?: unknown }).name ?? record.id); + } catch (error) { + out.refused.push({ id: record.id, version: record.version, error: 'kernel_refused', message: `its plugin name cannot be read: ${(error as Error).message}` }); + continue; + } if (input.kernel.hasPlugin(name)) { out.refused.push({ id: record.id, diff --git a/packages/cli/test/serve-installed-plugins.integration.test.ts b/packages/cli/test/serve-installed-plugins.integration.test.ts index de7d255c9f4..d722c24f25b 100644 --- a/packages/cli/test/serve-installed-plugins.integration.test.ts +++ b/packages/cli/test/serve-installed-plugins.integration.test.ts @@ -17,6 +17,14 @@ * `os plugin uninstall com.acme.loads`: the enabled one's `init` runs and the * uninstalled one's does not. * + * A sixth, `com.acme.built`, is what `os plugin build` produces from a source + * tree: its bundle leaves `@objectstack/*` external, so its import of + * `@objectstack/core` must resolve to the host's packages from an install + * directory under OS_HOME, where no `node_modules` holds them. Its `init` + * writes the artifact sha256 of the verdict the host recorded for it, read + * through that import: it matches the record only when the plugin got the + * host's own `@objectstack/core` module, not a copy. + * * Each plugin's `init` writes a marker file, so "it ran" and "nothing of it * ran" are read off the disk, not off the log. The controls: both boots exit 0 * and print the migrate path's completion line, so a boot that never reached @@ -25,7 +33,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -33,6 +41,7 @@ import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; import { loadPluginInstallations, pluginInstallDir } from '@objectstack/runtime'; import { isExitSignal } from '../src/utils/format.js'; import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../src/utils/osplugin.js'; +import PluginBuild from '../src/commands/plugin/build.js'; import PluginInstall from '../src/commands/plugin/install.js'; import PluginDisable from '../src/commands/plugin/disable.js'; import PluginEnable from '../src/commands/plugin/enable.js'; @@ -127,6 +136,45 @@ function signedArtifact(id: string, runtime = 'node'): string { return path; } +const BUILT = 'com.acme.built'; + +/** + * Build {@link BUILT} with `os plugin build` from a TypeScript source that + * imports `@objectstack/core`, then sign it: the artifact a publisher ships. + */ +async function builtArtifact(): Promise { + const src = join(root, 'built-src'); + mkdirSync(join(src, 'src'), { recursive: true }); + writeFileSync(join(src, MANIFEST_FILENAME), JSON.stringify({ + id: BUILT, + name: 'Built', + version: '1.0.0', + type: 'plugin', + runtime: 'node', + packaging: 'bundled', + main: 'src/index.ts', + })); + writeFileSync(join(src, 'src', 'index.ts'), [ + "import { writeFileSync } from 'node:fs';", + "import { pluginArtifactVerdict } from '@objectstack/core';", + '', + 'const plugin = {', + ` name: ${JSON.stringify(BUILT)},`, + " version: '1.0.0',", + ' init(): void {', + ` writeFileSync(${JSON.stringify(marker(BUILT))}, JSON.stringify({ sha256: pluginArtifactVerdict(plugin)?.artifactSha256 ?? null }));`, + ' },', + '};', + 'export default plugin;', + '', + ].join('\n')); + const path = join(root, `${BUILT}.osplugin`); + const built = await run(PluginBuild, [src, '--out', path]); + expect(built.exitCode, built.stderr.slice(-600)).toBe(0); + writeFileSync(`${path}.sig`, signPayload(new Uint8Array(readFileSync(path)), publisher.privateKeyPem, KEY_ID)); + return path; +} + interface Boot { code: number | null; output: string; @@ -165,6 +213,8 @@ let second: Boot; let ranAfterFirst: string[]; let ranInSecond: string[]; const ran = () => IDS.filter((id) => existsSync(marker(id))); +/** What {@link BUILT}'s `init` wrote in the first boot, or `null` when it did not run. */ +let builtAfterFirst: { sha256: string | null } | null; beforeAll(async () => { for (const key of ENV_KEYS) { @@ -196,12 +246,15 @@ beforeAll(async () => { const installed = await run(PluginInstall, [signedArtifact(id, runtime), ...db, '--json']); expect(installed.exitCode, `${id}: ${installed.stdout.slice(-400)}`).toBe(0); } + const builtInstall = await run(PluginInstall, [await builtArtifact(), ...db, '--json']); + expect(builtInstall.exitCode, builtInstall.stdout.slice(-400)).toBe(0); writeFileSync(join(pluginInstallDir('env_local', 'com.acme.tampered', '1.0.0'), 'dist', 'index.mjs'), 'export default { name: "evil", init() {} };\n'); expect((await run(PluginDisable, ['com.acme.disabled', '--json'])).exitCode).toBe(0); expect((await run(PluginUninstall, ['com.acme.removed', '--json'])).exitCode).toBe(0); first = await boot(); ranAfterFirst = ran(); + builtAfterFirst = existsSync(marker(BUILT)) ? JSON.parse(readFileSync(marker(BUILT), 'utf8')) : null; expect((await run(PluginEnable, ['com.acme.disabled', '--json'])).exitCode).toBe(0); expect((await run(PluginUninstall, ['com.acme.loads', '--json'])).exitCode).toBe(0); @@ -257,9 +310,37 @@ describe('[#27123] os serve loads installed plugins, verified, by tier', () => { expect(existsSync(pluginInstallDir('env_local', 'com.acme.loads', '1.0.0'))).toBe(false); expect(ranInSecond).not.toContain('com.acme.loads'); expect(loadPluginInstallations('env_local').map((r) => r.id).sort()).toEqual([ + BUILT, 'com.acme.disabled', 'com.acme.sandboxed', 'com.acme.tampered', ]); }); + + it('loads what `os plugin build` produced: its `@objectstack/core` import is the host\'s own module', () => { + expect(first.output).not.toMatch(/Refused installed plugin com\.acme\.built/); + const record = loadPluginInstallations('env_local').find((r) => r.id === BUILT); + expect(record?.artifact.sha256).toMatch(/^[0-9a-f]{64}$/); + // Read through the plugin's own import: the verdict the host's loader + // recorded, so the module is the host's, not a second copy. + expect(builtAfterFirst).toEqual({ sha256: record?.artifact.sha256 }); + }); + + // Last, because it leaves the disable-state file unreadable: an unreadable + // disable is not taken for none, so the commands refuse it and write + // nothing (the boot's side, `disabled_state_unreadable`, is in the runtime's + // loader tests). + it('disable and enable refuse a disable-state file they cannot read, and write nothing over it', async () => { + const dir = join(home, 'package-state'); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + const file = join(dir, files[0]); + writeFileSync(file, '{ "disabled": ["com.acme.disabled"'); + for (const command of [PluginDisable, PluginEnable]) { + const refused = await run(command, ['com.acme.sandboxed', '--json']); + expect(refused.exitCode).toBe(1); + expect(refused.payload).toEqual({ error: 'disable_state_failed', message: expect.stringContaining('cannot be read') }); + } + expect(readFileSync(file, 'utf8')).toBe('{ "disabled": ["com.acme.disabled"'); + }); }); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 12f3eb5c9de..5644726362c 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -526,7 +526,9 @@ export class PluginLoader { // This check reads that verdict. A plugin carrying a signature no host // verified is REFUSED: a signature string a module declares for itself // proves nothing, and passing it as well-formed would let it read as - // signed. A malformed one is refused by name first. + // signed. A malformed one is refused by name first. It is a check on + // what a plugin object carries, not a boundary against code already + // running in this process, which can record a verdict itself. const parsed = parseSignature(plugin.signature); if (!parsed) { throw new Error( @@ -538,7 +540,7 @@ export class PluginLoader { throw new Error( `Plugin ${plugin.name} carries a signature (keyId=${parsed.keyId}) that no host verified against its artifact. ` + 'A signature is verified over the .osplugin bytes when the plugin is installed and again at boot ' + - '(`os plugin install`, then the boot-time loader); a plugin object cannot vouch for itself.', + '(`os plugin install`, then the boot-time loader); a `signature` field alone does not make a plugin signed.', ); } this.logger.debug( diff --git a/packages/core/src/security/plugin-artifact-archive.ts b/packages/core/src/security/plugin-artifact-archive.ts index d7db4b27c28..1448ca09a49 100644 --- a/packages/core/src/security/plugin-artifact-archive.ts +++ b/packages/core/src/security/plugin-artifact-archive.ts @@ -18,6 +18,8 @@ * unverified — an unsigned install under `--trust-unsigned` — and for a key * whose holder ships a bomb: past {@link MAX_UNPACKED_ARTIFACT_BYTES} the * inflate stops and throws, naming the bound, instead of growing without end. + * Neither the inflate's input nor the files it answers are copied, so what a + * reader holds is the compressed bytes plus the one inflated tar. * * Pure apart from `node:zlib` / `node:crypto`; no file system. */ @@ -41,19 +43,47 @@ export function sha256Hex(bytes: Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } +/** The ustar typeflags of a regular file (`'0'`, and `'\0'` from pre-POSIX writers). */ +const REGULAR_FILE = new Set(['0', '\0']); +/** A directory entry: implied by the paths under it, so skipped. */ +const DIRECTORY = '5'; + +function field(b: Buffer, start: number, length: number, encoding: 'utf8' | 'ascii'): string { + return b.toString(encoding, start, start + length).replace(/\0.*$/s, ''); +} + /** - * Parse a ustar buffer into its files. Reads regular-file entries only; stops - * at the end-of-archive zero block. + * Parse a ustar buffer into its files. Reads regular-file entries, joining a + * POSIX `prefix` onto the name; skips directory entries; throws, naming the + * entry, on any other kind (a link, a device, a pax or GNU extension header) + * and on an entry the buffer cuts short. Stops at the end-of-archive zero + * block. Each file's `data` is a view into `buf`, not a copy. */ export function readTar(buf: Uint8Array): ArchiveFile[] { - const b = Buffer.from(buf); + const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength); const files: ArchiveFile[] = []; let off = 0; while (off + BLOCK <= b.length) { - const name = b.toString('utf8', off, off + 100).replace(/\0.*$/s, ''); - if (!name) break; // zero block → end of archive - const size = parseInt(b.toString('ascii', off + 124, off + 136).replace(/\0.*$/s, '').trim(), 8) || 0; - files.push({ path: name, data: new Uint8Array(b.subarray(off + BLOCK, off + BLOCK + size)) }); + const header = b.subarray(off, off + BLOCK); + if (header.every((byte) => byte === 0)) break; // zero block → end of archive + const name = field(b, off, 100, 'utf8'); + // POSIX ustar (magic `ustar\0`) keeps the leading part of a long path in + // `prefix`; the GNU format (`ustar `) uses those bytes for other fields. + const prefix = b.toString('ascii', off + 257, off + 263) === 'ustar\0' ? field(b, off + 345, 155, 'utf8') : ''; + const path = prefix ? `${prefix}/${name}` : name; + if (!name) throw new Error(`the archive entry at byte ${off} has no name`); + const sizeField = field(b, off + 124, 12, 'ascii').trim(); + if (!/^[0-7]+$/.test(sizeField)) throw new Error(`the archive entry ${JSON.stringify(path)} has no readable size`); + const size = parseInt(sizeField, 8); + const typeflag = b.toString('ascii', off + 156, off + 157); + if (off + BLOCK + size > b.length) { + throw new Error(`the archive entry ${JSON.stringify(path)} is cut short: it declares ${size} bytes, and ${b.length - off - BLOCK} follow`); + } + if (REGULAR_FILE.has(typeflag)) { + files.push({ path, data: new Uint8Array(b.buffer, b.byteOffset + off + BLOCK, size) }); + } else if (typeflag !== DIRECTORY) { + throw new Error(`the archive entry ${JSON.stringify(path)} is not a regular file or directory (typeflag ${JSON.stringify(typeflag)})`); + } off += BLOCK + Math.ceil(size / BLOCK) * BLOCK; } return files; @@ -68,7 +98,7 @@ export function readTarGz(blob: Uint8Array, options: { maxUnpackedBytes?: number const max = options.maxUnpackedBytes ?? MAX_UNPACKED_ARTIFACT_BYTES; let tar: Buffer; try { - tar = gunzipSync(Buffer.from(blob), { maxOutputLength: max }); + tar = gunzipSync(Buffer.from(blob.buffer, blob.byteOffset, blob.byteLength), { maxOutputLength: max }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') { throw new Error(`the artifact inflates past ${max} bytes, the most an .osplugin may unpack to`); diff --git a/packages/core/src/security/plugin-artifact-verdict.test.ts b/packages/core/src/security/plugin-artifact-verdict.test.ts index b45380030cb..fe1b4d0c3bc 100644 --- a/packages/core/src/security/plugin-artifact-verdict.test.ts +++ b/packages/core/src/security/plugin-artifact-verdict.test.ts @@ -123,3 +123,60 @@ describe('readTarGz bounds the inflate', () => { expect(() => readTarGz(new Uint8Array(Buffer.from('not gzip')))).toThrow(/incorrect header check|Z_DATA_ERROR/); }); }); + +/** One ustar header plus its padded data, with the fields a test varies. */ +function entryOf( + name: string, + data: Uint8Array, + options: { typeflag?: string; prefix?: string; magic?: string; size?: number } = {}, +): Buffer { + const h = Buffer.alloc(512, 0); + h.write(name, 0, 100, 'utf8'); + h.write((options.size ?? data.byteLength).toString(8).padStart(11, '0') + '\0', 124, 12, 'ascii'); + if (options.typeflag !== undefined) h.write(options.typeflag, 156, 1, 'ascii'); + h.write(options.magic ?? 'ustar\0', 257, 6, 'ascii'); + if (options.prefix !== undefined) h.write(options.prefix, 345, 155, 'utf8'); + const pad = (512 - (data.byteLength % 512)) % 512; + return Buffer.concat([h, Buffer.from(data), Buffer.alloc(pad)]); +} + +describe('readTar reads regular files only', () => { + const end = Buffer.alloc(1024); + const body = new Uint8Array(Buffer.from('export {};\n')); + + it('joins a POSIX prefix onto the name, skips a directory entry, and answers views', () => { + const tar = new Uint8Array(Buffer.concat([ + entryOf('dist', new Uint8Array(0), { typeflag: '5' }), + entryOf('index.mjs', body, { typeflag: '0', prefix: 'dist' }), + end, + ])); + const files = readTar(tar); + expect(files.map((f) => f.path)).toEqual(['dist/index.mjs']); + expect(Buffer.from(files[0].data).toString('utf8')).toBe('export {};\n'); + // A view into the buffer read, not a copy of it. + expect(files[0].data.buffer).toBe(tar.buffer); + }); + + it('ignores the prefix bytes of a GNU-format header', () => { + const tar = new Uint8Array(Buffer.concat([entryOf('a.txt', body, { typeflag: '0', magic: 'ustar ', prefix: 'junk' }), end])); + expect(readTar(tar).map((f) => f.path)).toEqual(['a.txt']); + }); + + it.each([ + ['a symbolic link', '2'], + ['a hard link', '1'], + ['a pax extended header', 'x'], + ['a GNU long-name header', 'L'], + ])('throws, naming the entry, on %s', (_kind, typeflag) => { + const tar = new Uint8Array(Buffer.concat([entryOf('dist/index.mjs', body, { typeflag }), end])); + expect(() => readTar(tar)).toThrow(`the archive entry "dist/index.mjs" is not a regular file or directory (typeflag "${typeflag}")`); + }); + + it('throws on an entry the buffer cuts short, and on a size it cannot read', () => { + const short = new Uint8Array(entryOf('big.bin', body, { typeflag: '0', size: 4096 })); + expect(() => readTar(short)).toThrow('the archive entry "big.bin" is cut short: it declares 4096 bytes, and 512 follow'); + const bad = Buffer.concat([entryOf('a.txt', body, { typeflag: '0' }), end]); + bad.write('zz\0', 124, 3, 'ascii'); + expect(() => readTar(new Uint8Array(bad))).toThrow('the archive entry "a.txt" has no readable size'); + }); +}); diff --git a/packages/core/src/security/plugin-artifact-verdict.ts b/packages/core/src/security/plugin-artifact-verdict.ts index 68881032929..1c74fe739cb 100644 --- a/packages/core/src/security/plugin-artifact-verdict.ts +++ b/packages/core/src/security/plugin-artifact-verdict.ts @@ -14,6 +14,13 @@ * * Kept in a module-private `WeakMap`, so the verdict follows the object, adds * no property to it, and goes with it. + * + * What this stops is a `signature` FIELD reading as verified. It is not a + * boundary against code already running in the host's process: + * {@link recordPluginArtifactVerdict} is a public export, and in-process code + * (a `node`-tier plugin's, which the host trusted by importing it) can call + * it. The tiers that do not run in process are where that boundary is + * (ADR-0025 §3.6). */ /** One host verification of an installed plugin's artifact. */ diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index b4218f21118..96bfc5bd9f2 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1461,9 +1461,29 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct, // file, so seeing the status proves the file was read. The end-to-end half // of this verdict — and why `os lint` still cannot produce it — is the // `#16094` block at the bottom of this file. - it('REAL LEDGER: the live-elsewhere row exists and is warned without an opt-in (the fuse, lit)', () => { - expect(shippedLedgerStatuses().has('live-elsewhere')).toBe(true); - expect(authorWarnedProperties('manifest').has('runtime')).toBe(true); + // + // #27123 emptied that population: `manifest.runtime` flipped to `live` when + // the boot-time loader for installed plugins began dispatching on the tier, + // and no other row carries the verdict. So the shipped half now pins the + // flip (the row is `live` and silent), and the fuse is shown lit on a + // PLANTED row instead: a `live-elsewhere` row with no `authorWarn`, in a copy + // of the shipped ledgers, read by the walk itself. + it('REAL LEDGER: manifest.runtime left the verdict on #27123 — live, and not warned', () => { + const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'manifest.json'), 'utf8')); + expect(ledger.props.runtime.status).toBe('live'); + // Anti-vacuity: the manifest ledger was read, so its `dead` rows warn. + expect(authorWarnedProperties('manifest').size).toBeGreaterThan(0); + expect(authorWarnedProperties('manifest').has('runtime')).toBe(false); + }); + + it('PLANTED: a live-elsewhere row with no opt-in is warned, under its own rule id (the fuse, lit)', () => { + const dir = ledgerDirWith((d) => writeLedger(d, 'field', { props: { synthElsewhereSlot: { status: 'live-elsewhere' } } })); + expect(ledgerStatusesIn(dir).has('live-elsewhere')).toBe(true); + const findings = lintLivenessPropertiesFromLedgerDir(dir, { + objects: [{ name: 'widget', fields: [{ name: 'a', synthElsewhereSlot: true }] }], + }); + expect(findings.map((f) => f.rule)).toEqual([LIVENESS_LIVE_ELSEWHERE_PROPERTY]); + expect(findings[0].message).not.toContain('liveness: dead'); }); // ── COVERAGE (#14057): describe() answers for every status the ledgers ship. @@ -1480,11 +1500,14 @@ describe('dead / experimental / planned / live-elsewhere verdicts are distinct, // a user error)". A mistake in our own shipped data is exactly what the // sentinel is for, so `live` is asserted LOUD here rather than handled. it('COVERAGE: every status the shipped ledgers carry is answered by describe() — or is loud by design', () => { - const statuses = [...shippedLedgerStatuses()].sort(); + const shipped = shippedLedgerStatuses(); // Anti-vacuity: an unreadable ledger dir returns the empty set, which would // pass every assertion below without measuring anything. - expect(statuses.length).toBeGreaterThanOrEqual(4); - expect(statuses).toContain('live-elsewhere'); + expect(shipped.size).toBeGreaterThanOrEqual(4); + // The verdicts `describe()` grades ride along with the shipped census: one + // no shipped row carries today (`live-elsewhere`, since #27123) is still + // answered here, so the day a row takes it again it cannot crash an author. + const statuses = [...new Set([...shipped, 'dead', 'experimental', 'planned', 'live-elsewhere'])].sort(); const rulesByStatus = new Map(); for (const status of statuses) { @@ -1927,14 +1950,13 @@ describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () = expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('list.filterableFields'); }); - // `manifest.runtime` is the one `live-elsewhere` row. The verdict now admits - // it and `describe()` maps it to its own rule id with a keep-it reading — both - // asserted on the SHIPPED row, read from the directory the rule itself reads. - it('the shipped live-elsewhere row is admitted and graded liveness-live-elsewhere-property', () => { - const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), 'manifest.json'), 'utf8')); - const row = ledger.props.runtime; - expect(row.status).toBe('live-elsewhere'); - expect(authorWarnedProperties('manifest').has('runtime')).toBe(true); + // `manifest.runtime` was the one `live-elsewhere` row until #27123 flipped + // it to `live` (pinned in the #14057 block above, beside the PLANTED row + // that shows the verdict admitted without an opt-in). `describe()` mapping + // the verdict to its own rule id with a keep-it reading is asserted here on + // a row of the shape that row had. + it('a live-elsewhere row is graded liveness-live-elsewhere-property', () => { + const row = { status: 'live-elsewhere' }; const findings = checkItemAgainstWarnMap('manifest', { runtime: 'sandbox' }, 'manifest', [['runtime', row]]); expect(findings.map((f) => f.rule)).toEqual([LIVENESS_LIVE_ELSEWHERE_PROPERTY]); expect(findings[0].message).not.toContain('liveness: dead'); @@ -2010,10 +2032,12 @@ describe('the hint a warned row shows an author (#16094, #21096)', () => { const triggered = shippedRows().filter( (r) => r.entry.authorWarn !== true && (r.entry.status === 'dead' || r.entry.status === 'live-elsewhere'), ); - // Anti-vacuity: both verdicts are present, and at least one of these notes - // DOES carry a tracker id — so the leak this pin forbids is a real one. + // Anti-vacuity: the `dead` verdict is present, and at least one of these + // notes DOES carry a tracker id — so the leak this pin forbids is a real + // one. No shipped row is `live-elsewhere` since #27123; such a row joins + // this loop the day one ships, and the verdict's hint precedence is pinned + // SYNTHETIC in the #14057 block. expect(triggered.some((r) => r.entry.status === 'dead')).toBe(true); - expect(triggered.some((r) => r.entry.status === 'live-elsewhere')).toBe(true); expect(triggered.some((r) => typeof r.entry.note === 'string' && TRACKER_ID.test(r.entry.note))).toBe(true); for (const row of triggered) { const hint = hintFor(row); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 9ab3b45df6d..0a7d6737dfb 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -49,6 +49,8 @@ export { INSTALLED_SIGNATURE_FILE, INSTALLATIONS_LOCK_WAIT_MS, INSTALLATIONS_LOCK_STALE_MS, + INSTALLATIONS_LOCK_STEAL_STALE_MS, + PluginInstallationsLockedError, pluginsHome, pluginInstallDir, pluginInstallationsFile, diff --git a/packages/runtime/src/installed-plugin-loader.test.ts b/packages/runtime/src/installed-plugin-loader.test.ts index 843d48bddf9..7eb04eb54c4 100644 --- a/packages/runtime/src/installed-plugin-loader.test.ts +++ b/packages/runtime/src/installed-plugin-loader.test.ts @@ -14,8 +14,8 @@ * and only `runtime: 'node'` is ever imported. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createPublicKey } from 'node:crypto'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -27,6 +27,7 @@ import { dispatchPluginRuntime, loadInstalledPlugins, verifyInstalledPlugins } f import { INSTALLED_ARTIFACT_FILE, INSTALLED_SIGNATURE_FILE, + installationsLockTestHooks, loadPluginInstallations, markPluginUninstall, pluginInstallDir, @@ -38,6 +39,27 @@ import { import { packageStateFileName } from './package-state-store.js'; import { publicKeyFingerprint, type TrustedKeyResolution } from './plugin-trusted-keys.js'; +/** + * A file-system call the test makes throw, by its operation and path; every + * other call is the real one. + */ +const fsFaults = vi.hoisted(() => ({ fail: null as ((op: string, path: string) => string | null) | null })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + const faulty = unknown>(op: string, real: F): F => + ((...args: Parameters) => { + const code = fsFaults.fail?.(op, String(args[0])); + if (code) throw Object.assign(new Error(`${code}: ${op} refused by the test, '${String(args[0])}'`), { code }); + return real(...args); + }) as F; + return { + ...actual, + readdirSync: faulty('readdir', actual.readdirSync), + readFileSync: faulty('readFile', actual.readFileSync), + rmSync: faulty('rm', actual.rmSync), + }; +}); + const ENV = 'env_local'; let home: string; @@ -49,6 +71,8 @@ beforeEach(() => { }); afterEach(() => { + fsFaults.fail = null; + delete installationsLockTestHooks.waitMs; if (saved.OS_HOME === undefined) delete process.env.OS_HOME; else process.env.OS_HOME = saved.OS_HOME; rmSync(home, { recursive: true, force: true }); @@ -393,6 +417,7 @@ describe('what the import answers', () => { ['the module exports no plugin', 'not_a_plugin', 'export default 42;\n'], ['the plugin declares another signature', 'signature_conflict', 'export default { name: "x", init() {}, signature: "ed25519:other:AAAA" };\n'], ['the plugin object is frozen', 'signature_conflict', 'export default Object.freeze({ name: "x", init() {} });\n'], + ['reading the export throws', 'not_a_plugin', 'export default { name: "x", init() {}, get signature() { throw new Error("getter"); } };\n'], ])('%s → %s', async (_label, error, entry) => { install({ entry }); const out = await load(); @@ -474,3 +499,86 @@ describe('verifyInstalledPlugins changes nothing', () => { expect(loadPluginInstallations(ENV)).toHaveLength(2); }); }); + +describe('a throw on one installation refuses it by name, and the boot goes on', () => { + it('an install directory that cannot be listed → install_unreadable; the next plugin still loads', async () => { + const locked = install({ id: 'com.acme.locked' }); + install({ id: 'com.acme.fine' }); + fsFaults.fail = (op, path) => (op === 'readdir' && path === locked.dir ? 'EACCES' : null); + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: 'com.acme.locked', error: 'install_unreadable' })]); + expect(out.refused[0].message).toContain('EACCES'); + expect(out.loaded.map((l) => l.record.id)).toEqual(['com.acme.fine']); + expect(out.imported).toHaveLength(1); + }); + + it('a file that vanishes between the walk and its read → install_unreadable, nothing imported', async () => { + const r = install(); + fsFaults.fail = (op, path) => (op === 'readFile' && path === join(r.dir, 'dist', 'index.mjs') ? 'ENOENT' : null); + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: r.id, error: 'install_unreadable' })]); + expect(out.imported).toEqual([]); + }); + + it('a disable-state file that cannot be parsed → disabled_state_unreadable for each plugin, none loaded', async () => { + const a = install({ id: 'com.acme.a' }); + const b = install({ id: 'com.acme.b' }); + setInstalledPluginDisabled(ENV, a.id, true); + const stateDir = join(home, 'package-state'); + const [stateFile] = readdirSync(stateDir); + writeFileSync(join(stateDir, stateFile), '{ not json'); + const out = await load(); + expect(out.refused.map((r) => [r.id, r.error])).toEqual([[a.id, 'disabled_state_unreadable'], [b.id, 'disabled_state_unreadable']]); + expect(out.imported).toEqual([]); + // And a disable or enable writes nothing over the file it could not read. + expect(() => setInstalledPluginDisabled(ENV, b.id, true)).toThrow(/cannot be read/); + expect(readFileSync(join(stateDir, stateFile), 'utf8')).toBe('{ not json'); + }); + + it('an uninstall whose directory cannot be removed → uninstall_failed; the record stays marked and the next boot finishes it', async () => { + const r = install(); + markPluginUninstall(ENV, r.id); + fsFaults.fail = (op, path) => (op === 'rm' && path === r.dir ? 'EBUSY' : null); + const out = await load(); + expect(out.removed).toEqual([]); + expect(out.refused).toEqual([expect.objectContaining({ id: r.id, error: 'uninstall_failed' })]); + expect(loadPluginInstallations(ENV)).toEqual([expect.objectContaining({ id: r.id, status: 'uninstall-pending' })]); + fsFaults.fail = null; + const again = await load(); + expect(again.removed).toEqual([expect.objectContaining({ kept: false })]); + expect(existsSync(r.dir)).toBe(false); + }); + + it('an uninstall the installations lock is held over past the wait → uninstall_failed, nothing removed', async () => { + const r = install(); + markPluginUninstall(ENV, r.id); + const lock = `${pluginInstallationsFile(ENV)}.lock`; + writeFileSync(lock, JSON.stringify({ pid: process.ppid, thread: 0, token: 'another-writer', at: new Date().toISOString() })); + installationsLockTestHooks.waitMs = 200; + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: r.id, error: 'uninstall_failed' })]); + expect(out.refused[0].message).toMatch(/locked by process/); + expect(existsSync(r.dir)).toBe(true); + expect(readFileSync(lock, 'utf8')).toContain('another-writer'); + rmSync(lock); + }); + + it('an id installed again after this boot read the records keeps its fresh install → record_changed', async () => { + const gone = install({ id: 'com.acme.gone' }); + markPluginUninstall(ENV, gone.id); + install({ id: 'com.acme.signed' }); + const keys = activeKeys(); + // The trusted-key read happens after the records were read and before + // any uninstall is finished: the window an install can land in. + const out = await load(async (ids) => { + install({ id: 'com.acme.gone' }); + return keys.resolveKeys(ids); + }); + expect(out.removed).toEqual([]); + expect(out.refused).toEqual([expect.objectContaining({ id: gone.id, error: 'record_changed' })]); + expect(existsSync(join(gone.dir, INSTALLED_ARTIFACT_FILE))).toBe(true); + expect(loadPluginInstallations(ENV).find((r) => r.id === gone.id)?.status).toBe('installed'); + // The next boot reads the fresh install and loads it. + expect((await load()).loaded.map((l) => l.record.id).sort()).toEqual(['com.acme.gone', 'com.acme.signed']); + }); +}); diff --git a/packages/runtime/src/installed-plugin-loader.ts b/packages/runtime/src/installed-plugin-loader.ts index fb6fe667942..5d2e7963c45 100644 --- a/packages/runtime/src/installed-plugin-loader.ts +++ b/packages/runtime/src/installed-plugin-loader.ts @@ -8,7 +8,9 @@ * installation records ({@link loadPluginInstallations}) and hands each one * through {@link verifyInstalledPlugins}. A plugin is imported only when every * check below passes, in this order; the first that fails refuses that plugin - * by name and nothing of it runs: + * by name and nothing of it is imported. A check that throws (a file it cannot + * read, one that vanished mid-walk, an unreadable disable-state file) refuses + * that plugin by name too: one installation never fails the whole boot. * * 0. **The record names its own directory.** `dir` must be the one the * store derives for the record's environment, id and version @@ -22,8 +24,10 @@ * 3. **The signature, over the raw kept bytes, before anything is inflated.** * The detached `artifact.osplugin.sig` names a key id; it must be the * record's, the key must be `active` in `sys_trusted_key` with the - * fingerprint the record kept, and `verifyPluginArtifact` must verify it. - * So a key revoked since install refuses the plugin at its next boot. An + * fingerprint the record kept, and `verifyPluginArtifact` must verify it + * over the bytes. The signature covers the bytes only; what ties them to + * the record's id and version is check 4's manifest comparison. So a key + * revoked since install refuses the plugin at its next boot. An * install admitted unsigned under `--trust-unsigned` loads with a warning * on every boot. * 4. **The tree is the artifact.** The artifact is read back (bounded, @@ -38,15 +42,35 @@ * * {@link loadInstalledPlugins} runs the same checks, finishes uninstalls * (`os plugin uninstall` marks a record; the directory goes here, at the next - * boot, unless another project's record still names it), and imports what - * passed. Registering the plugins is the caller's: it `kernel.use`s each, so - * dependency ordering and health checks are the kernel's `PluginLoader`'s. + * boot, under the installations lock and only while the record is still the + * marked one, unless another project's record still names it), and imports + * what passed. Registering the plugins is the caller's: it `kernel.use`s each, + * so dependency ordering and health checks are the kernel's `PluginLoader`'s. + * + * The refusals that come AFTER the import (`import_failed`, `not_a_plugin`, + * `signature_conflict`, and the host's `plugin_name_taken` / `kernel_refused`) + * are made once the entry module's top-level code has run: the plugin is not + * registered, but its module was evaluated. + * + * ## What the import resolves + * + * `os plugin build` leaves `@objectstack/*` external: the host provides them. + * The install directory sits under `OS_HOME`, where no `node_modules` holds + * them, so a host passes `resolveHostPackagesFrom` (a file URL of its own) and + * a bare `@objectstack/*` specifier imported from inside a verified install + * directory resolves as if that host file imported it: the same module + * instances the host's kernel runs on. Any other bare specifier resolves as + * Node resolves it from the install directory. The verified tree is what the + * import reads first; a file the plugin imports later, lazily, is read from + * disk when it is imported, after this check. */ -import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs'; +import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync } from 'node:fs'; +import { register } from 'node:module'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { + MAX_UNPACKED_ARTIFACT_BYTES, materializeStackPlugin, parseSignature, readTarGz, @@ -64,6 +88,7 @@ import { loadPluginInstallations, otherProjectsInstallationDirs, pluginInstallDir, + withPluginInstallationsLock, type PluginInstallationRecord, } from './plugin-installation-store.js'; import type { TrustedKeyResolution } from './plugin-trusted-keys.js'; @@ -76,6 +101,10 @@ const DEFAULT_ENTRY = 'dist/index.mjs'; /** Why an installed plugin did not load. */ export type InstalledPluginRefusalCode = | 'records_unreadable' + | 'disabled_state_unreadable' + | 'install_unreadable' + | 'uninstall_failed' + | 'record_changed' | 'install_dir_unexpected' | 'install_dir_missing' | 'artifact_changed' @@ -100,7 +129,8 @@ export type InstalledPluginRefusalCode = | 'signature_conflict' // Raised by the host that registers what loaded (`os serve`): | 'plugin_name_taken' - | 'kernel_refused'; + | 'kernel_refused' + | 'loader_failed'; export interface InstalledPluginRefusal { id: string; @@ -230,6 +260,14 @@ function readKept(record: PluginInstallationRecord): } let artifact: Uint8Array; try { + const size = statSync(join(dir, INSTALLED_ARTIFACT_FILE)).size; + if (size > MAX_UNPACKED_ARTIFACT_BYTES) { + return { + ok: false, + error: 'artifact_changed', + message: `${join(dir, INSTALLED_ARTIFACT_FILE)} is ${size} bytes, more than any artifact an install accepts (${MAX_UNPACKED_ARTIFACT_BYTES}); it was not read.`, + }; + } artifact = new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE))); } catch (error) { return { ok: false, error: 'artifact_changed', message: `${join(dir, INSTALLED_ARTIFACT_FILE)} cannot be read (${(error as Error).message}).` }; @@ -355,6 +393,11 @@ async function checkRecord( return { ok: true, value: { record, entry: join(record.dir, ...main.split('/')), signature } }; } +/** A check that threw on the install directory (unreadable, or changing while it was read). */ +function unreadableInstall(record: PluginInstallationRecord, error: unknown): string { + return `reading its install directory ${record.dir} failed (${(error as Error).message}), so it could not be verified and is not loaded.`; +} + /** * Every check of this module's header, for every installation this project * recorded in `environmentId`; imports nothing and changes nothing. An @@ -371,9 +414,21 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) return out; } if (records.length === 0) return out; - const disabled = installedPluginDisabledIds(input.environmentId); + // An unreadable disable-state file leaves "is it disabled?" unanswered, so + // nothing that could be disabled is loaded: each is refused by name. + let disabled = new Set(); + let disabledUnreadable: string | null = null; + try { + disabled = installedPluginDisabledIds(input.environmentId); + } catch (error) { + disabledUnreadable = (error as Error).message; + } const pending: Array<{ record: PluginInstallationRecord; kept: { artifact: Uint8Array; signature: string | null; keyId: string | null } }> = []; for (const record of records) { + if (!record || typeof record !== 'object') { + out.refused.push({ id: '?', version: '?', error: 'records_unreadable', message: `an entry of this project's installations file is not a record (${JSON.stringify(record)}).` }); + continue; + } if (expectedDir(input.environmentId, record) === null) { out.refused.push({ id: String(record.id), @@ -387,11 +442,26 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) out.uninstallPending.push(record); continue; } + if (disabledUnreadable !== null) { + out.refused.push({ + id: record.id, + version: record.version, + error: 'disabled_state_unreadable', + message: `this environment's disable-state file cannot be read (${disabledUnreadable}), so whether it is disabled is not known, and it is not loaded.`, + }); + continue; + } if (disabled.has(record.id)) { out.disabled.push(record); continue; } - const kept = readKept(record); + let kept: ReturnType; + try { + kept = readKept(record); + } catch (error) { + out.refused.push({ id: record.id, version: record.version, error: 'install_unreadable', message: unreadableInstall(record, error) }); + continue; + } if (!kept.ok) { out.refused.push({ id: record.id, version: record.version, error: kept.error, message: kept.message }); continue; @@ -415,7 +485,13 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) } } for (const { record, kept } of toCheck) { - const checked = await checkRecord(record, kept, keys); + let checked: Check; + try { + checked = await checkRecord(record, kept, keys); + } catch (error) { + out.refused.push({ id: record.id, version: record.version, error: 'install_unreadable', message: unreadableInstall(record, error) }); + continue; + } if (checked.ok) out.verified.push(checked.value); else out.refused.push({ id: record.id, version: record.version, error: checked.error, message: checked.message }); } @@ -441,6 +517,110 @@ export interface LoadInstalledPluginsInput extends VerifyInstalledPluginsInput { wrapBundle(bundle: Record): unknown | Promise; /** The dynamic import, injectable for tests. */ importModule?: (url: string) => Promise; + /** + * A file URL of the host's own: a bare `@objectstack/*` specifier imported + * from inside a verified install directory resolves as if this file imported + * it (see the module header). Unset, the import resolves as Node resolves it + * from the install directory. + */ + resolveHostPackagesFrom?: string; +} + +/** + * The resolve hook behind `resolveHostPackagesFrom`, registered with + * `module.register` as a `data:` module, so it needs no file of its own on disk. + */ +const HOST_PACKAGE_HOOKS = ` +let roots = []; +let host = ''; +export async function initialize(data) { roots = data.roots; host = data.host; } +export async function resolve(specifier, context, next) { + if (specifier.startsWith('@objectstack/') && typeof context.parentURL === 'string' + && roots.some((root) => context.parentURL.startsWith(root))) { + return next(specifier, { ...context, parentURL: host }); + } + return next(specifier, context); +} +`; + +/** The (host, install directory) pairs this process already registered the hook for. */ +const HOST_RESOLUTION_REGISTERED = new Set(); + +function resolveHostPackagesFor(dirs: readonly string[], host: string): void { + const roots = dirs + .map((dir) => `${pathToFileURL(dir).href}/`) + .filter((root) => !HOST_RESOLUTION_REGISTERED.has(`${host} ${root}`)); + if (roots.length === 0) return; + register(`data:text/javascript,${encodeURIComponent(HOST_PACKAGE_HOOKS)}`, { data: { roots, host } }); + for (const root of roots) HOST_RESOLUTION_REGISTERED.add(`${host} ${root}`); +} + +/** + * Finish the uninstall of `snapshot` under the installations lock, and only + * while the record is still that marked one: an id installed again since this + * boot read the records keeps its fresh directory and record. Answers `null` + * when the record changed. + */ +function finishUninstall(environmentId: string | undefined, snapshot: PluginInstallationRecord): { kept: boolean } | null { + return withPluginInstallationsLock(environmentId, () => { + const current = loadPluginInstallations(environmentId).find((r) => r.id === snapshot.id); + if ( + !current + || current.status !== 'uninstall-pending' + || current.dir !== snapshot.dir + || current.artifact?.sha256 !== snapshot.artifact?.sha256 + ) { + return null; + } + const sharedDirs = otherProjectsInstallationDirs(environmentId); + const kept = sharedDirs === null || sharedDirs.has(current.dir); + if (!kept) { + rmSync(current.dir, { recursive: true, force: true }); + // The `` directory goes with its last version. + try { + rmdirSync(dirname(current.dir)); + } catch { + // not empty, or already gone + } + } + dropPluginInstallation(environmentId, current.id); + return { kept }; + }); +} + +/** + * The checks on what an entry module exported, and the verdict recorded for + * the kernel's loader; answers the refusal, or `null` when the plugin is ready. + */ +function inspectImported(record: PluginInstallationRecord, item: VerifiedInstalledPlugin, plugin: unknown): InstalledPluginRefusal | null { + const refusal = (error: InstalledPluginRefusalCode, message: string): InstalledPluginRefusal => ({ id: record.id, version: record.version, error, message }); + if (!plugin || typeof plugin !== 'object' || typeof (plugin as { init?: unknown }).init !== 'function') { + return refusal('not_a_plugin', `${item.entry} does not export a plugin (an object with \`init\`).`); + } + // The signature the artifact verified under rides on the plugin, and the + // verdict that verified it is recorded for the kernel's loader, which + // refuses a plugin that carries a signature nothing verified. + const carried = (plugin as { signature?: unknown }).signature; + if (item.signature === null && carried !== undefined) { + return refusal('signature_conflict', 'the plugin declares a signature, and its artifact was installed unsigned (--trust-unsigned).'); + } + if (item.signature !== null) { + if (carried !== undefined && carried !== item.signature) { + return refusal('signature_conflict', 'the plugin declares a signature other than the one its artifact verified under.'); + } + try { + (plugin as { signature?: string }).signature = item.signature; + } catch { + return refusal('signature_conflict', 'the plugin object cannot carry its verified signature (it is frozen).'); + } + } + recordPluginArtifactVerdict(plugin as object, { + signature: item.signature, + keyId: record.signature?.keyId ?? null, + fingerprint: record.signature?.fingerprint ?? null, + artifactSha256: record.artifact.sha256, + }); + return null; } /** @@ -454,22 +634,39 @@ export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Pr const out: InstalledPluginsLoad = { loaded: [], refused: [...verdict.refused], disabled: verdict.disabled, removed: [] }; for (const record of verdict.uninstallPending) { - const sharedDirs = otherProjectsInstallationDirs(input.environmentId); - const kept = sharedDirs === null || sharedDirs.has(record.dir); - if (!kept) { - rmSync(record.dir, { recursive: true, force: true }); - // The `` directory goes with its last version. - try { - rmdirSync(dirname(record.dir)); - } catch { - // not empty, or already gone - } + let finished: { kept: boolean } | null; + try { + finished = finishUninstall(input.environmentId, record); + } catch (error) { + out.refused.push({ + id: record.id, + version: record.version, + error: 'uninstall_failed', + message: `finishing its uninstall failed (${(error as Error).message}). Nothing of it was loaded; if its record is still marked, the next boot tries again.`, + }); + continue; } - dropPluginInstallation(input.environmentId, record.id); - out.removed.push({ record, kept }); + if (finished === null) { + out.refused.push({ + id: record.id, + version: record.version, + error: 'record_changed', + message: 'its record changed while this boot read it (installed again, or its uninstall finished elsewhere). Nothing was removed or loaded; the next boot reads it again.', + }); + continue; + } + out.removed.push({ record, kept: finished.kept }); } const importModule = input.importModule ?? ((url: string) => import(/* webpackIgnore: true */ url)); + if (input.resolveHostPackagesFrom && verdict.verified.length > 0) { + try { + resolveHostPackagesFor(verdict.verified.map((v) => v.record.dir), input.resolveHostPackagesFrom); + } catch { + // No resolve hook (a runtime without `module.register`): a plugin that + // imports `@objectstack/*` then fails its import, by name, below. + } + } for (const item of verdict.verified) { const { record } = item; let plugin: unknown; @@ -482,36 +679,18 @@ export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Pr out.refused.push({ id: record.id, version: record.version, error: 'import_failed', message: `importing ${item.entry} failed: ${(error as Error).message}` }); continue; } - if (!plugin || typeof plugin !== 'object' || typeof (plugin as { init?: unknown }).init !== 'function') { - out.refused.push({ id: record.id, version: record.version, error: 'not_a_plugin', message: `${item.entry} does not export a plugin (an object with \`init\`).` }); - continue; + // The module's top-level code has run from here on. Reading what it + // exported can still throw (a getter), which refuses it like a bad export. + let refusal: InstalledPluginRefusal | null; + try { + refusal = inspectImported(record, item, plugin); + } catch (error) { + refusal = { id: record.id, version: record.version, error: 'not_a_plugin', message: `reading what ${item.entry} exports threw: ${(error as Error).message}` }; } - // The signature the artifact verified under rides on the plugin, and the - // verdict that verified it is recorded for the kernel's loader, which - // refuses a plugin that carries a signature nothing verified. - const carried = (plugin as { signature?: unknown }).signature; - if (item.signature === null && carried !== undefined) { - out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin declares a signature, and its artifact was installed unsigned (--trust-unsigned).' }); + if (refusal) { + out.refused.push(refusal); continue; } - if (item.signature !== null) { - if (carried !== undefined && carried !== item.signature) { - out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin declares a signature other than the one its artifact verified under.' }); - continue; - } - try { - (plugin as { signature?: string }).signature = item.signature; - } catch { - out.refused.push({ id: record.id, version: record.version, error: 'signature_conflict', message: 'the plugin object cannot carry its verified signature (it is frozen).' }); - continue; - } - } - recordPluginArtifactVerdict(plugin as object, { - signature: item.signature, - keyId: record.signature?.keyId ?? null, - fingerprint: record.signature?.fingerprint ?? null, - artifactSha256: record.artifact.sha256, - }); out.loaded.push({ record, plugin }); } return out; diff --git a/packages/runtime/src/package-state-store.ts b/packages/runtime/src/package-state-store.ts index 133439349f4..eb7e5d1a869 100644 --- a/packages/runtime/src/package-state-store.ts +++ b/packages/runtime/src/package-state-store.ts @@ -164,6 +164,32 @@ export function loadDisabledPackageIds(environmentId?: string): Set { return new Set(Array.isArray(disabled) ? disabled.filter((id) => typeof id === 'string') : []); } +/** + * {@link loadDisabledPackageIds} for a caller that must not take an unreadable + * file for "nothing is disabled": throws, naming the file, when the file that + * read would use is there and cannot be read or parsed. The installed-plugin + * commands and the boot-time loader read it so (ADR-0025 §3.5 M1): a disable + * that cannot be read keeps a plugin from loading instead of loading it. + */ +export function loadDisabledPackageIdsStrict(environmentId?: string): Set { + const file = [stateFilePath(environmentId), legacyStateFilePath(environmentId)].find((f) => existsSync(f)); + if (file === undefined) return new Set(); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + throw new Error(`the disable-state file ${file} cannot be read: ${(error as Error).message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`the disable-state file ${file} is not a JSON object`); + } + const disabled = (parsed as PackageStateFile).disabled; + if (disabled !== undefined && !Array.isArray(disabled)) { + throw new Error(`the disable-state file ${file} holds a \`disabled\` that is not a list`); + } + return new Set((disabled ?? []).filter((id): id is string => typeof id === 'string')); +} + /** * Persist the disabled/enabled state of a single package. Best-effort: failures * are surfaced to the caller so the HTTP layer can log, but disabling already diff --git a/packages/runtime/src/plugin-installation-store.lock.test.ts b/packages/runtime/src/plugin-installation-store.lock.test.ts index 01fe51d9f9f..f81c3fb22b7 100644 --- a/packages/runtime/src/plugin-installation-store.lock.test.ts +++ b/packages/runtime/src/plugin-installation-store.lock.test.ts @@ -7,10 +7,14 @@ * * Pinned: a writer waits for a live holder and then refuses, naming it and the * lock file; a stale lock (dead pid, too old, an unparseable leftover, this - * process's own pid while it holds nothing) is cleared by the next writer; the - * lock is re-entrant within a process and always released; two processes - * writing at once both land every record; and another project's records keep - * their directories out of an uninstall. + * thread's own pid and thread while it holds nothing) is cleared by the next + * writer, and another thread of this process holding it is live; a lock judged + * stale and replaced by a live writer before it is cleared is left alone (the + * two-writer steal race), and a steal lock a crashed writer left is cleared; + * the lock is re-entrant and always released, and a release never removes a + * lock that is no longer the holder's; two processes writing at once both land + * every record; and another project's records keep their directories out of + * an uninstall. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -19,9 +23,13 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, w import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { threadId } from 'node:worker_threads'; import { INSTALLATIONS_LOCK_STALE_MS, + INSTALLATIONS_LOCK_STEAL_STALE_MS, + PluginInstallationsLockedError, + installationsLockTestHooks, dropPluginInstallation, installedPluginDisabledIds, loadPluginInstallations, @@ -96,7 +104,7 @@ describe('the installations lock', () => { it.each([ ['a dead pid', () => JSON.stringify({ pid: deadPid(), at: 'then' }), 0], - ['this process\'s own pid while it holds nothing', () => JSON.stringify({ pid: process.pid, at: 'then' }), 0], + ['this thread\'s own pid and thread while it holds nothing', () => JSON.stringify({ pid: process.pid, thread: threadId, at: 'then' }), 0], ['a live pid older than the stale bound', () => JSON.stringify({ pid: process.ppid, at: 'then' }), INSTALLATIONS_LOCK_STALE_MS + 60_000], ['an unparseable leftover older than a second', () => '{"pid":', 5_000], ])('clears a stale lock: %s', (_label, body, ageMs) => { @@ -118,6 +126,73 @@ describe('the installations lock', () => { expect(existsSync(lock)).toBe(true); }); + it('treats a lock another thread of this process holds as live', () => { + const lock = lockFile(); + writeFileSync(lock, JSON.stringify({ pid: process.pid, thread: threadId + 1, at: '2026-10-29T00:00:00.000Z' })); + expect(() => withPluginInstallationsLock('env_local', () => 'never', { waitMs: 200 })) + .toThrow(new RegExp(`locked by process ${process.pid} thread ${threadId + 1} `)); + expect(existsSync(lock)).toBe(true); + }); + + it('refuses with a PluginInstallationsLockedError, so a caller can name it apart from other failures', () => { + writeFileSync(lockFile(), JSON.stringify({ pid: process.ppid, at: 'now' })); + let thrown: unknown; + try { + withPluginInstallationsLock('env_local', () => 'never', { waitMs: 100 }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(PluginInstallationsLockedError); + expect((thrown as Error).name).toBe('PluginInstallationsLockedError'); + }); + + it('leaves a stale lock alone once a live writer has replaced it since the judgement (the steal race)', () => { + // Writer B judges a dead holder's lock stale. Before B clears it, writer A + // (the hook) clears the same stale lock and takes a fresh one. B must not + // remove A's lock: it waits for A instead, and refuses naming A. + const lock = lockFile(); + writeFileSync(lock, JSON.stringify({ pid: deadPid(), at: 'then' })); + const writerA = JSON.stringify({ pid: process.ppid, token: 'writer-a', at: '2026-10-29T00:00:00.000Z' }); + let ranB = false; + installationsLockTestHooks.afterStaleJudged = (path) => { + installationsLockTestHooks.afterStaleJudged = undefined; + rmSync(path); + writeFileSync(path, writerA, { flag: 'wx' }); + }; + try { + expect(() => withPluginInstallationsLock('env_local', () => { ranB = true; }, { waitMs: 300 })) + .toThrow(new RegExp(`locked by process ${process.ppid} `)); + } finally { + installationsLockTestHooks.afterStaleJudged = undefined; + } + expect(ranB).toBe(false); + expect(readFileSync(lock, 'utf8')).toBe(writerA); + expect(existsSync(`${lock}.steal`)).toBe(false); + }); + + it('clears a steal lock a writer left when it died clearing a stale lock', () => { + const lock = lockFile(); + writeFileSync(lock, JSON.stringify({ pid: deadPid(), at: 'then' })); + writeFileSync(`${lock}.steal`, JSON.stringify({ pid: deadPid(), at: 'then' })); + const then = new Date(Date.now() - INSTALLATIONS_LOCK_STEAL_STALE_MS - 5_000); + utimesSync(`${lock}.steal`, then, then); + expect(withPluginInstallationsLock('env_local', () => 'ran', { waitMs: 1_000 })).toBe('ran'); + expect(existsSync(lock)).toBe(false); + expect(existsSync(`${lock}.steal`)).toBe(false); + }); + + it('never removes, on release, a lock that is no longer the holder\'s', () => { + const lock = lockFile(); + const other = JSON.stringify({ pid: process.ppid, token: 'other', at: 'now' }); + withPluginInstallationsLock('env_local', () => { + // Another writer took the lock over (as one would a lock aged past the + // stale bound) while this one still ran. + rmSync(lock); + writeFileSync(lock, other); + }); + expect(readFileSync(lock, 'utf8')).toBe(other); + }); + it('is re-entrant within a process, and released when the outer holder returns or throws', () => { const lock = `${pluginInstallationsFile('env_local')}.lock`; const inner = withPluginInstallationsLock('env_local', () => { diff --git a/packages/runtime/src/plugin-installation-store.ts b/packages/runtime/src/plugin-installation-store.ts index 53469ac32ed..1e5856fcedc 100644 --- a/packages/runtime/src/plugin-installation-store.ts +++ b/packages/runtime/src/plugin-installation-store.ts @@ -49,24 +49,36 @@ * to {@link INSTALLATIONS_LOCK_WAIT_MS}, and then refuses, naming the holder. * * A lock a crashed process left behind is cleared by the next writer, never by - * hand in the ordinary case: the lock is stale, and is removed, when its pid - * is no longer a live process on this host, or when it is older than + * hand in the ordinary case. The lock is stale when its pid is no longer a + * live process on this host, or when it is older than * {@link INSTALLATIONS_LOCK_STALE_MS} (a write takes milliseconds, so an older * lock is a pid the operating system reused), or when it names this process's - * own pid while this process does not hold it (a dead process that had the pid - * before), or when it cannot be parsed and is older than a second (a writer - * that died between creating it and writing its pid). Only when none of these - * holds does the refusal ask the operator to remove the lock file, by its - * path. + * own pid and thread while this thread does not hold it (a dead process that + * had the pid before; another thread of this process holding it is live), or + * when it cannot be parsed and is older than a second (a writer that died + * between creating it and writing its pid). Only when none of these holds does + * the refusal ask the operator to remove the lock file, by its path. + * + * Clearing a stale lock is itself serialized, so two writers that judged the + * same lock stale cannot both proceed: a writer clears it only while holding + * `.lock.steal` (created `wx`, held for a few system calls), and only if + * the lock file is still the very file it judged — the same inode and the same + * bytes, which carry a per-acquisition token. A writer that finds the lock + * replaced by a live one since its judgement leaves it and waits. A holder + * removes the lock on release only while it is still its own. A steal lock a + * crashed writer left is cleared the same way once it is older than + * {@link INSTALLATIONS_LOCK_STEAL_STALE_MS}. * * Exported from the package index for the CLI, which writes it; the runtime's * boot-time loader is its reader. */ +import { randomUUID } from 'node:crypto'; import { closeSync, existsSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import { threadId } from 'node:worker_threads'; import { makeDirectorySync } from './make-directory.js'; -import { loadDisabledPackageIds, packageStateFileName, setPackageDisabled } from './package-state-store.js'; +import { loadDisabledPackageIdsStrict, packageStateFileName, setPackageDisabled } from './package-state-store.js'; import { resolveObjectStackHome } from './standalone-stack.js'; /** The environment id `os serve` stamps when `OS_ENVIRONMENT_ID` is unset. */ @@ -85,6 +97,21 @@ export const INSTALLED_SIGNATURE_FILE = 'artifact.osplugin.sig'; export const INSTALLATIONS_LOCK_WAIT_MS = 10_000; /** A lock older than this is stale whatever its pid answers. */ export const INSTALLATIONS_LOCK_STALE_MS = 5 * 60_000; +/** A steal lock older than this was left by a writer that died clearing a stale lock. */ +export const INSTALLATIONS_LOCK_STEAL_STALE_MS = 2_000; + +/** + * Thrown by {@link withPluginInstallationsLock} when another live writer holds + * the lock past the wait, so a caller can name that refusal apart from any + * other failure (`instanceof`; the `os plugin` commands report it as + * `installations_locked` in their own refusal vocabulary). + */ +export class PluginInstallationsLockedError extends Error { + constructor(message: string) { + super(message); + this.name = 'PluginInstallationsLockedError'; + } +} /** One installed plugin version, as `os plugin install` recorded it. */ export interface PluginInstallationRecord { @@ -212,37 +239,107 @@ function processAlive(pid: number): boolean { } } -/** The holder a lock file names, and whether the lock is stale (see the module header). */ -function readLock(lock: string): { stale: boolean; holder: string } { - let ageMs: number; +/** A lock file as read: its inode and bytes, which name it exactly (the bytes carry a per-acquisition token). */ +interface LockFile { + ino: number; + raw: string; +} + +/** The lock file at `path` now, or `null` when there is none (or it vanished while being read). */ +function readLockFile(path: string): (LockFile & { ageMs: number }) | null { try { - ageMs = Date.now() - statSync(lock).mtimeMs; + const stat = statSync(path); + return { ino: stat.ino, raw: readFileSync(path, 'utf8'), ageMs: Date.now() - stat.mtimeMs }; } catch { - return { stale: false, holder: 'a writer that has just released it' }; + return null; } - let held: { pid?: unknown; at?: unknown } | null = null; +} + +/** Remove `path` only while it is still `expected`; answers whether it did. */ +function removeIfUnchanged(path: string, expected: LockFile): boolean { + const now = readLockFile(path); + if (!now || now.ino !== expected.ino || now.raw !== expected.raw) return false; + rmSync(path, { force: true }); + return true; +} + +/** Create `path` exclusively holding a fresh stamp; `null` when it already exists. */ +function createLockFile(path: string): LockFile | null { + let fd: number; + try { + fd = openSync(path, 'wx'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return null; + throw error; + } + const raw = JSON.stringify({ pid: process.pid, thread: threadId, token: randomUUID(), at: new Date().toISOString() }); try { - held = JSON.parse(readFileSync(lock, 'utf8')) as { pid?: unknown; at?: unknown }; + writeSync(fd, raw); + } finally { + closeSync(fd); + } + return { ino: statSync(path).ino, raw }; +} + +/** The holder a lock file names, and whether it is stale (see the module header). */ +function judgeLock(file: LockFile & { ageMs: number }): { stale: boolean; holder: string } { + let held: { pid?: unknown; thread?: unknown; at?: unknown } | null = null; + try { + held = JSON.parse(file.raw) as { pid?: unknown; thread?: unknown; at?: unknown }; } catch { held = null; } - if (!held || typeof held.pid !== 'number') return { stale: ageMs > 1_000, holder: 'a writer that has not yet written its pid' }; - const holder = `process ${held.pid} (since ${String(held.at)})`; - // This process does not hold it (that is answered before a lock file is - // read), so a lock naming this pid was left by a dead process that had it. - if (held.pid === process.pid) return { stale: true, holder }; - return { stale: !processAlive(held.pid) || ageMs > INSTALLATIONS_LOCK_STALE_MS, holder }; + if (!held || typeof held.pid !== 'number') return { stale: file.ageMs > 1_000, holder: 'a writer that has not yet written its pid' }; + const thread = typeof held.thread === 'number' ? held.thread : 0; + const holder = `process ${held.pid}${thread === 0 ? '' : ` thread ${thread}`} (since ${String(held.at)})`; + if (file.ageMs > INSTALLATIONS_LOCK_STALE_MS) return { stale: true, holder }; + if (held.pid === process.pid) { + // This thread does not hold it (that is answered before a lock file is + // read), so a lock naming this thread was left by a dead process that had + // the pid; one naming another thread of this process is that thread's. + return { stale: thread === threadId, holder }; + } + return { stale: !processAlive(held.pid), holder }; +} + +/** + * Seams for the lock's tests: `afterStaleJudged` runs between a writer judging + * the lock stale and clearing it, the window two writers could once both pass; + * `waitMs` replaces the default wait, so a test sees a timeout without waiting + * it out. Not exported from the package index. + */ +export const installationsLockTestHooks: { afterStaleJudged?: (lock: string) => void; waitMs?: number } = {}; + +/** + * Clear the lock judged stale, holding the steal lock so no other writer + * clears at the same time, and only while it is still that file. A steal lock + * held by another writer means it is clearing now: the caller tries again. + */ +function clearStaleLock(lock: string, judged: LockFile): void { + const steal = `${lock}.steal`; + const mine = createLockFile(steal); + if (!mine) { + const left = readLockFile(steal); + if (left && left.ageMs > INSTALLATIONS_LOCK_STEAL_STALE_MS) removeIfUnchanged(steal, left); + return; + } + try { + removeIfUnchanged(lock, judged); + } finally { + removeIfUnchanged(steal, mine); + } } -/** Lock files this process holds, and how deep: the lock is re-entrant within a process. */ +/** Lock files this thread holds, and how deep: the lock is re-entrant within a thread. */ const HELD = new Map(); /** * Run `fn` holding this project's installations lock for `environmentId`, and - * release the lock whatever `fn` does. Throws, naming the holder and the lock - * file, when another live writer holds it past {@link INSTALLATIONS_LOCK_WAIT_MS}. + * release the lock whatever `fn` does. Throws {@link PluginInstallationsLockedError}, + * naming the holder and the lock file, when another live writer holds it past + * {@link INSTALLATIONS_LOCK_WAIT_MS}. * - * Re-entrant within a process, so a caller can hold it across its own checks + * Re-entrant within a thread, so a caller can hold it across its own checks * and the write that follows them (`os plugin install` does) while the write * takes it again. */ @@ -263,30 +360,22 @@ export function withPluginInstallationsLock( } } makeDirectorySync(dirname(file)); - const waitMs = options.waitMs ?? INSTALLATIONS_LOCK_WAIT_MS; + const waitMs = options.waitMs ?? installationsLockTestHooks.waitMs ?? INSTALLATIONS_LOCK_WAIT_MS; const deadline = Date.now() + waitMs; + let mine: LockFile | null = null; for (;;) { - let fd: number | undefined; - try { - fd = openSync(lock, 'wx'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - } - if (fd !== undefined) { - try { - writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); - } finally { - closeSync(fd); - } - break; - } - const { stale, holder } = readLock(lock); + mine = createLockFile(lock); + if (mine) break; + const present = readLockFile(lock); + if (!present) continue; // released between the two calls + const { stale, holder } = judgeLock(present); if (stale) { - rmSync(lock, { force: true }); + installationsLockTestHooks.afterStaleJudged?.(lock); + clearStaleLock(lock, present); continue; } if (Date.now() >= deadline) { - throw new Error( + throw new PluginInstallationsLockedError( `The plugin installations file ${file} is locked by ${holder}, still running after ` + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, ); @@ -298,7 +387,7 @@ export function withPluginInstallationsLock( return fn(); } finally { HELD.delete(lock); - rmSync(lock, { force: true }); + removeIfUnchanged(lock, mine); } } @@ -386,12 +475,19 @@ export function otherProjectsInstallationDirs(environmentId?: string): Set { - return loadDisabledPackageIds(environmentSegment(environmentId)); + return loadDisabledPackageIdsStrict(environmentSegment(environmentId)); } -/** Disable or enable the installed plugin `id`; it takes effect at the next boot. */ +/** + * Disable or enable the installed plugin `id`; it takes effect at the next + * boot. Throws, writing nothing, when the disable-state file cannot be read, + * so the write never replaces the disables it could not read. + */ export function setInstalledPluginDisabled(environmentId: string | undefined, id: string, disabled: boolean): void { + loadDisabledPackageIdsStrict(environmentSegment(environmentId)); setPackageDisabled(environmentSegment(environmentId), id, disabled); } diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index c2a531ecf66..74682c7ab9c 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -76,7 +76,7 @@ registry to fold it back onto — the override *is* its governance. | `live` | Has a runtime consumer. Cite it in `evidence`, preferably anchored to the consuming symbol — `file#symbol` — with `file:line` as an optional convenience; the renderer is in this repo, cited `ui/packages/app-shell/…#Symbol`; for another repo's path, prefix the realm — `cloud: packages/…` (see below). | | `experimental` / `planned` | Declared, intentionally not enforced yet. Also read from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. | | `dead` | Parsed, no consumer **anywhere the census looked**. Tracked for **enforce-or-remove** (ADR-0049). A key that is dead here but enforced in a sibling repo is NOT `dead` — it is `live-elsewhere`, below. | -| `live-elsewhere` | Dead here by measurement, **genuinely enforced in a sibling repo** (#13483). Not deletable. The template is `manifest.runtime`, "ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD" — its load side belongs to this repo's loader. A key whose consumer is the closed cloud runtime is `live` with a `cloud`-attributed pointer instead (`agent.json`'s `_note`). Carries its own gate-executable criteria — see the section below. Ledger-entry-only (no `.describe()` marker). | +| `live-elsewhere` | Dead here by measurement, **genuinely enforced in a sibling repo** (#13483). Not deletable. The worked example is `manifest.runtime` as it was graded until #27123, "ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD": its load side belonged to this repo's loader, and when #27123 wired that loader (the boot-time tier dispatch for plugins `os plugin install` installed) the row flipped to `live`. No row is `live-elsewhere` today; the status and its criteria stand for the next key that fits. A key whose consumer is the closed cloud runtime is `live` with a `cloud`-attributed pointer instead (`agent.json`'s `_note`). Carries its own gate-executable criteria — see the section below. Ledger-entry-only (no `.describe()` marker). | Resolution per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**. Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`, @@ -299,7 +299,9 @@ constraint the `live-elsewhere` discipline below is built around. ### `live-elsewhere` — dead here, enforced in a sibling repo (#13483) The verdict for a key with **zero local dispatch and a real foreign enforcer**. -The measured template is `manifest.runtime`: two CLI echo lines here, and the +No row holds it today. The account below is the worked example, kept as it was +measured: the template was `manifest.runtime` until #27123 flipped that row to +`live` (see the end of this account): two CLI echo lines here, and the cloud marketplace publish gate hard-rejecting (HTTP 422) an unverified publisher requesting the `node` tier (#12400). `dead` lies about that key — read alone it licenses deleting the marketplace's trust-gate input, which the @@ -313,7 +315,12 @@ property whose consumer is the renderer or the closed cloud runtime" (the bounda stays `live` — `agent.json`'s `_note`: "These props are `live` because that cloud runtime consumes them; the OPEN framework edition does not". Until #13483 the truth lived in a qualifying sentence inside the row's `note`; prose is the weakest protection this ledger -knows. +knows. **#27123 flipped the row to `live`** on local evidence: the boot-time loader for +plugins `os plugin install` installed dispatches on the tier +(`packages/runtime/src/installed-plugin-loader.ts#dispatchPluginRuntime`: `node` loads in +process, `sandbox` / `worker` are refused by name), which is the load side the verdict +waited on. The criteria below, and the gate's checks of them, stand for the next key that +is dead here and enforced only in a sibling repo. The gate cannot resolve another repo's file (deliberate — see the realm-marker boundary above), so a `live-elsewhere` row is held to criteria the gate CAN @@ -1007,7 +1014,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | | capability | seeded 2026-08-08 (#5961; commit ae31a1912) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — commit ae31a1912 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and commit ae31a1912 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowEnvironmentOverlay: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | -| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 19 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions` and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity`, the security-shaped key that stood `dead` beside them, is `live` since #27123**: the publisher computes the map at build and self-checks it at the `os plugin publish` preflight (#13464), and `os plugin install` re-verifies it at unpack (ADR-0025 §3.5 step 5), refusing the install by name before it writes anything. `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete. `permissions` stays `live` on corrected evidence after its legacy flat-list arm was retired (ADR-0049, conversion `manifest-permissions-string-list-removed`): the block is the structured ADR-0025 one alone, its measured consumer is objectui's install-consent disclosure (it refuses nothing at load), and the `collectDeclaredSuggestions` evidence it used to cite was the assembled-stage `PermissionSet[]` reader, which only reports an authoring-stage value as skipped | +| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 19 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions` and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity`, the security-shaped key that stood `dead` beside them, is `live` since #27123**: the publisher computes the map at build and self-checks it at the `os plugin publish` preflight (#13464), and `os plugin install` re-verifies it at unpack (ADR-0025 §3.5 step 5), refusing the install by name before it writes anything. `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — was the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. **#27123 flipped it to `live`**: the boot-time loader for plugins `os plugin install` installed dispatches on the tier before it imports anything (`node` loads in process and is not isolation; `sandbox` / `worker` are refused by name until ADR-0025 M3; an undeclared tier is refused), both `runtime` describes say so, and the row cites that consumer. The account above is kept as the worked example of `live-elsewhere`, which no row holds today. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete. `permissions` stays `live` on corrected evidence after its legacy flat-list arm was retired (ADR-0049, conversion `manifest-permissions-string-list-removed`): the block is the structured ADR-0025 one alone, its measured consumer is objectui's install-consent disclosure (it refuses nothing at load), and the `collectDeclaredSuggestions` evidence it used to cite was the assembled-stage `PermissionSet[]` reader, which only reports an authoring-stage value as skipped | | crud_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 5 = `objectParamStyle` (every CRUD route takes the object name as a PATH segment; `'query'` is validated against the enum and mounts exactly what `'path'` mounts) and the four members of `patterns` (custom URL patterns are normalized and never read — every route is mounted from hard-coded method/path pairs in `registerCrudEndpoints`). Live 6 = the five `operations.*` switches, each gating a route mount, and `dataPrefix`, which has five independent consumers and moves the mounted paths and the advertised discovery document together. A different question was open about `patterns` (its `z.record` input type demands all five operations) — a declaration defect, not a liveness one, not re-derived here **Commit b3a63d32c RETIRED the dead set (2026-09-03, ADR-0049 enforce-or-remove)**: `patterns` and `objectParamStyle` are `retiredKey()` tombstones (the schema is a non-strict `z.object`, so the rows STAY `dead` with a REMOVED note — the rls.priority precedent) and the four `patterns.*` child rows collapse into the one `patterns` row because the value def `CrudEndpointPattern` left the shape with its key (RETIRED_DEFS_BY_MAJOR[18]). REMOVE, not ENFORCE, because the mounted CRUD paths are the contract the client SDK, the discovery document and /openapi.json all describe — a per-operation pattern knob could only make them lie — and an endpoint on a custom path is a declarative `api` endpoint. `evidenceScope` widened to `cross-repo` on the cloud reading (#14796 @9b6abe0f2fd5: zero, structural). The `z.partialRecord` question closes with the record | | metadata_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `cacheTtl` and `endpoints.schema`. `enableCache` is live and `cacheTtl` is not, which is the pair worth reading together: the cached branch delegates to the protocol's `getMetaItemCached`, whose signature takes no TTL, and no cache header anywhere is built from this value. Its negative-bound observation travels in that row by triage ruling rather than as a separate defect — the schema declares `z.number().int()` with no lower bound, so `-1` is accepted, and #11984 pins it as accepted because that is what the contract says. `endpoints.schema` is the sharpest case in the family for per-key rows: its three siblings each gate a route mount and it gates nothing, because `GET /meta/:type/:name/schema` does not exist — `packages/rest/src` mounts no path ending in `/schema` at all **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `cacheTtl` and `endpoints.schema` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. The negative-bound observation dies with `cacheTtl` (its #11984 acceptance pin is reversed to a refusal pin); `endpoints.schema` had no route to gate, so there was nothing to enforce. `evidenceScope` widened to `cross-repo` (#14796) | | batch_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index f562637750f..366835cf0fd 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -579,6 +579,12 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { // `--ledger-root`, for the #5623 reason each block above states, and every // mutation targets `manifest/runtime` — the row the status shipped with — so // each run has exactly one cause for its verdict. +// +// #27123 flipped that row to `live` (the boot-time loader for installed +// plugins dispatches on the tier), and no shipped row carries the verdict +// since. So each case PLANTS it: the copy's `manifest/runtime` is put back as +// the `live-elsewhere` row it was, attested today, with the copy's count shard +// moved by the same one row, and the case's mutation lands on that. describe('check:liveness — the live-elsewhere criteria (#13483)', () => { let tmp: string; @@ -587,10 +593,44 @@ describe('check:liveness — the live-elsewhere criteria (#13483)', () => { }); afterAll(() => rmSync(tmp, { recursive: true, force: true })); - /** Copy the real ledgers and rewrite fields of one property in the copy. */ - function withProp(name: string, type: string, prop: string, edit: (entry: any) => void): string { + /** The `manifest/runtime` row as it stood before #27123, attested today so it is inside the window. */ + const plantedRow = () => ({ + status: 'live-elsewhere', + verifiedAt: new Date().toISOString().slice(0, 10), + evidenceScope: 'cross-repo', + evidence: + 'cloud: packages/service-cloud/src/plugin-permission-audit.ts#auditPluginPermissions @15f55df + ' + + 'packages/service-cloud/src/routes/package-publish.ts:530 — the marketplace publish gate (planted by the self-test)', + note: 'PLANTED by check-liveness.test.ts: the live-elsewhere row manifest.runtime was until #27123.', + }); + + /** + * Copy the real ledgers, plant {@link plantedRow} on `manifest/runtime`, and + * move the copy's `manifest` count shard by that one row (`live` → `elsewhere`), + * so the planting is the copy's only difference the gate can see. + */ + function planted(name: string): string { const root = path.join(tmp, name); cpSync(LEDGERS, root, { recursive: true }); + const file = path.join(root, 'manifest.json'); + const ledger = JSON.parse(readFileSync(file, 'utf8')); + expect(ledger.props.runtime.status, 'the planting starts from the shipped `live` row').toBe('live'); + ledger.props.runtime = plantedRow(); + writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); + const shard = path.join(root, 'state-counts', 'manifest.md'); + const counts = readFileSync(shard, 'utf8'); + const moved = counts.replace( + /^\| `manifest` \| (\d+) \| (\d+) \| (\d+) \| /m, + (_row, live, exp, elsewhere) => `| \`manifest\` | ${Number(live) - 1} | ${exp} | ${Number(elsewhere) + 1} | `, + ); + expect(moved, 'the manifest count shard has its row').not.toBe(counts); + writeFileSync(shard, moved); + return root; + } + + /** {@link planted}, then rewrite fields of one property in the copy. */ + function withProp(name: string, type: string, prop: string, edit: (entry: any) => void): string { + const root = planted(name); const file = path.join(root, `${type}.json`); const ledger = JSON.parse(readFileSync(file, 'utf8')); edit(ledger.props[prop]); @@ -598,19 +638,35 @@ describe('check:liveness — the live-elsewhere criteria (#13483)', () => { return root; } - // The control, and the population line. The shipped `manifest.runtime` row is - // the day-one population, so a green run must show it counted AND satisfied — - // "no findings" over zero rows is also what a check wired to nothing prints. + /** The gate's `live-elsewhere:` population line, parsed. */ + function population(output: string): { line: string; carry: number; foreign: number } { + const line = output.split('\n').find((l) => l.startsWith('live-elsewhere:')) ?? ''; + const m = /live-elsewhere: (\d+) entr\(ies\) carry the verdict .*?, (\d+) with a foreign pointer/.exec(line); + expect(m, line).not.toBeNull(); + return { line, carry: Number(m![1]), foreign: Number(m![2]) }; + } + + // The controls, and the population line. A green run must show the planted + // row counted AND satisfied — "no findings" over zero rows is also what a + // check wired to nothing prints — and the shipped ledgers, which hold no + // such row today, must be green and print the line all the same. it('is green on the shipped ledgers and publishes the population beside the verdict', () => { const root = path.join(tmp, 'verbatim'); cpSync(LEDGERS, root, { recursive: true }); const { status, output } = runGate(root); expect(status, output).toBe(0); - const line = output.split('\n').find((l) => l.startsWith('live-elsewhere:')) ?? ''; - const m = /live-elsewhere: (\d+) entr\(ies\) carry the verdict .*?, (\d+) with a foreign pointer/.exec(line); - expect(m, line).not.toBeNull(); - expect(Number(m![1])).toBeGreaterThanOrEqual(1); - expect(m![2]).toBe(m![1]); + const { line, carry, foreign } = population(output); + expect(foreign).toBe(carry); + expect(line).not.toContain('MALFORMED'); + expect(line).not.toContain('EXPIRED'); + }); + + it('is green on a planted live-elsewhere row and counts it as carrying the verdict with a foreign pointer', () => { + const { status, output } = runGate(planted('planted')); + expect(status, output).toBe(0); + const { line, carry, foreign } = population(output); + expect(carry).toBeGreaterThanOrEqual(1); + expect(foreign).toBe(carry); expect(line).not.toContain('MALFORMED'); expect(line).not.toContain('EXPIRED'); }); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 3b4139ce019..787856f5433 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -216,9 +216,9 @@ export const PluginRuntimeSchema = z .describe( 'Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud ' + 'marketplace publish gate (an unverified publisher requesting `node` is rejected with ' - + 'HTTP 422 and forced to manual review) and at boot for a locally installed plugin: only ' - + '`node` is loaded (in process, not isolated); `sandbox` and `worker` are refused until ' - + 'they are routed (ADR-0025 M3), never run in process instead', + + 'HTTP 422 and forced to manual review) and at boot for a plugin `os plugin install` ' + + 'installed: only `node` is loaded (in process, not isolated); `sandbox` and `worker` are ' + + 'refused until they are routed (ADR-0025 M3), never run in process instead', ); export type PluginRuntime = z.input; @@ -1017,21 +1017,24 @@ export const ManifestSchema = strictObject({ /** * Trust / isolation tier the plugin DECLARES (ADR-0025 §3.6). - * Unset implies a pure-metadata package (no executable code). + * Unset implies a pure-metadata package (no executable code). An artifact + * `os plugin install` installs carries code, so on one an unset tier is an + * omission, not that declaration: the boot-time loader refuses it + * (`runtime_undeclared`, default-deny). * * ⚠️ Enforced at the cloud marketplace **publish gate** — an unverified * publisher requesting the `node` tier is hard-rejected (HTTP 422) and forced - * to manual review — and **at boot for a locally installed plugin**: only - * `node` is loaded (in process, not isolated); `sandbox` and `worker` are - * refused until ADR-0025 M3 routes them. See {@link PluginRuntimeSchema} for - * the full split. + * to manual review — and **at boot for a plugin `os plugin install` + * installed**: only `node` is loaded (in process, not isolated); `sandbox` + * and `worker` are refused until ADR-0025 M3 routes them. See + * {@link PluginRuntimeSchema} for the full split. */ runtime: PluginRuntimeSchema.optional() .describe( 'Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud ' + 'marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual ' - + 'review) and at boot for a locally installed plugin: only `node` is loaded (in process, ' - + 'not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them', + + 'review) and at boot for a plugin `os plugin install` installed: only `node` is loaded ' + + '(in process, not isolated); `sandbox` and `worker` are refused until ADR-0025 M3 routes them', ), /** diff --git a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts index 3ecda4586ec..f97ff886e3b 100644 --- a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts +++ b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts @@ -56,7 +56,7 @@ import { ManifestSchema, PluginRuntimeSchema } from './manifest.zod'; /** Both halves of the truthful split, as separate probes. */ const SAYS_PUBLISH_GATE_ENFORCED = /publish gate/i; const SAYS_422 = /422/; -const SAYS_BOOT_LOADS_ONLY_NODE = /at boot for a locally installed plugin: only `node` is loaded/i; +const SAYS_BOOT_LOADS_ONLY_NODE = /at boot for a plugin `os plugin install` installed: only `node` is loaded/i; const SAYS_NODE_NOT_ISOLATED = /not isolated/i; const SAYS_SANDBOX_WORKER_REFUSED = /`sandbox` and `worker` are refused until/i; const RETRACTED_NOT_ENFORCED = /load-side enforcement is NOT implemented/i; From 766b96ff253ec0cbabb4b707fc4ed2edf3141c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:43:09 +0000 Subject: [PATCH 3/4] fix(cli, runtime, core, spec): review round 2 on the boot-time plugin loader (#27123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seat review r2 and contract review 6490914228 on af0b3888. - runtime: a lock path that is there and cannot be read (a directory, EACCES) is refused by name at once instead of retried forever, which hung os serve at boot with an uninstall pending. A steal lock is judged by its pid like the lock, and a writer that finds one held sleeps between tries instead of spinning. - runtime: the host-package resolve hook matches install directories by their real path too, so an OS_HOME reached through a symbolic link resolves, and redirects only a specifier naming a package under @objectstack/ (no empty, . or .. segment, no % or backslash). - runtime, cli: a throw of any value (undefined, null) from a plugin is refused by name; it no longer rejects the whole loader. - core: the narrowing of kernel.use's signature check is declared: Clause-② no (narrowing), the BREAKING banner, and an ADR-0087 runtime-interface-only disposition; the signature field's TSDoc and content/docs/plugins/index.mdx no longer teach setting it by hand. - spec: the runtime field TSDoc names os plugin build as what makes an artifact carry code. - tests: the unreadable lock paths, the steal lock's judgement, the symlinked OS_HOME and the climbing specifier in a real Node process, throw undefined, the artifact size caps at install and boot, loader_failed and kernel_refused at the serve wiring, and uninstall's record-gone race; the serve e2e carries its measured test-budget. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude --- .changeset/27123-boot-time-plugin-loader.md | 6 +- .../27123-runtime-tier-enforced-at-boot.md | 2 +- content/docs/plugins/index.mdx | 26 ++-- .../plugin/install.integration.test.ts | 15 +- .../cli/src/commands/plugin/uninstall.test.ts | 91 +++++++++++++ .../src/utils/installed-plugins-boot.test.ts | 128 ++++++++++++++++++ .../cli/src/utils/installed-plugins-boot.ts | 19 ++- ...erve-installed-plugins.integration.test.ts | 1 + packages/core/src/plugin-loader.ts | 8 +- packages/runtime/src/index.ts | 1 - .../src/installed-plugin-loader.test.ts | 114 +++++++++++++++- .../runtime/src/installed-plugin-loader.ts | 68 +++++++--- .../plugin-installation-store.lock.test.ts | 78 +++++++++-- .../runtime/src/plugin-installation-store.ts | 55 +++++--- packages/spec/src/kernel/manifest.zod.ts | 7 +- 15 files changed, 548 insertions(+), 71 deletions(-) create mode 100644 packages/cli/src/commands/plugin/uninstall.test.ts create mode 100644 packages/cli/src/utils/installed-plugins-boot.test.ts diff --git a/.changeset/27123-boot-time-plugin-loader.md b/.changeset/27123-boot-time-plugin-loader.md index 7898702120b..391907e626a 100644 --- a/.changeset/27123-boot-time-plugin-loader.md +++ b/.changeset/27123-boot-time-plugin-loader.md @@ -6,9 +6,13 @@ feat(cli, runtime, core): `os serve` loads the plugins `os plugin install` installed, re-verifying each before any of its code is imported, and dispatches on the declared `runtime` tier; `os plugin disable | enable | uninstall` (#27123) -Clause-②: no — no key, accepted value or export of `@objectstack/spec` changes; the loader, the commands and the new exports are platform code +Clause-②: no (narrowing) — `@objectstack/core`'s `PluginLoader` (so every `kernel.use`) refuses a plugin object whose `signature` no host recorded a verdict for, where any well-formed signature passed. Nothing of `@objectstack/spec` changes; the rest (the boot loader, the commands, the new exports) widens Metadata-protocol: none + + +**BREAKING** accept-set narrowing at `kernel.use` (`@objectstack/core`): a plugin object carrying a `signature` is refused unless the host that verified its artifact recorded that verdict against the object. + The second slice of ADR-0025's local install loop (§3.5 M1). Before this, an installed plugin was unpacked and recorded, and nothing loaded it. **Migration (`@objectstack/core`):** a host outside this repo that passes `kernel.use` a plugin object carrying a `signature` now has that plugin refused (`Plugin X carries a signature … that no host verified against its artifact`), where any well-formed signature used to pass. FROM: setting `plugin.signature` and calling `kernel.use(plugin)`. TO: verify the artifact yourself, then call `recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 })` from `@objectstack/core` before `kernel.use`; or leave `signature` unset. No plugin in this repo sets it. diff --git a/.changeset/27123-runtime-tier-enforced-at-boot.md b/.changeset/27123-runtime-tier-enforced-at-boot.md index a4f86873ec9..51d05dddbc3 100644 --- a/.changeset/27123-runtime-tier-enforced-at-boot.md +++ b/.changeset/27123-runtime-tier-enforced-at-boot.md @@ -7,4 +7,4 @@ docs(spec): the plugin manifest's `runtime` tier is described as enforced at boo Clause-②: no — no key or accepted value changes; `node`, `sandbox` and `worker` still parse, and an unset tier is still legal Metadata-protocol: none -The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. The field's TSDoc also says that an unset tier, which declares a pure-metadata package, is an omission on an artifact `os plugin install` installed, and is refused there. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point, and the ledger README keeps its account as the worked example of `live-elsewhere`, which no row holds now. +The `.describe()` text and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field said load-side enforcement was not implemented. The boot-time loader for plugins `os plugin install` installed now dispatches on the tier before importing anything: `node` loads in process (not isolated), `sandbox` and `worker` are refused by name, and an installed plugin that declares no tier is refused. The text says that, both halves still stated (the cloud marketplace publish gate's HTTP 422 for an unverified publisher requesting `node` is unchanged), and the retracted sentence is pinned absent. The field's TSDoc also says that an unset tier, which declares a pure-metadata package, is an omission on an artifact `os plugin build` produced (which carries code), and that the boot-time loader refuses an installed plugin with no tier either way. A plugin a stack's own `plugins` array names is not dispatched on this key. The liveness ledger's `runtime` row moves from `live-elsewhere` to `live` on the local dispatch point, and the ledger README keeps its account as the worked example of `live-elsewhere`, which no row holds now. diff --git a/content/docs/plugins/index.mdx b/content/docs/plugins/index.mdx index 6f1bd08d0dc..f7c52f7ce7c 100644 --- a/content/docs/plugins/index.mdx +++ b/content/docs/plugins/index.mdx @@ -312,25 +312,29 @@ const dbPlugin: PluginMetadata = { ObjectStack supports plugin security features: -- **Signature Verification**: Cryptographic signatures for plugin integrity +- **Signature Verification**: a plugin `os plugin install` installed is verified against the environment's trusted keys at install and again at every boot, before any of its code is imported. - **Permission Enforcement**: Fine-grained access control for plugin operations +A `signature` field on a plugin object is not a verification by itself. The +kernel's loader refuses a plugin that carries one unless the host that verified +its artifact recorded that verdict against the object, so leave `signature` +unset on a plugin you register yourself. A host that verifies artifacts of its +own records the verdict before `kernel.use`: + +```typescript +import { recordPluginArtifactVerdict } from '@objectstack/core'; + +// After this host verified the artifact's bytes against a trusted key: +recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 }); +await kernel.use(plugin); // refused without the verdict +``` + Plugin configuration is the plugin's own concern: take it as a constructor or factory argument and parse it at your own seam (a Zod `schema.parse` in the factory, constructor or `init`). The kernel-side `configSchema` metadata field was retired under ADR-0049 — the kernel never received a plugin's config, so the field could not validate anything. -```typescript -const securePlugin: PluginMetadata = { - name: 'com.example.secure', - version: '1.0.0', - signature: 'ed25519:key-1:', - - async init(ctx) { /* ... */ }, -}; -``` - ### Startup Timeout Plugins can configure a custom startup timeout (default: 30 seconds): diff --git a/packages/cli/src/commands/plugin/install.integration.test.ts b/packages/cli/src/commands/plugin/install.integration.test.ts index c64b2f6d446..5230c560970 100644 --- a/packages/cli/src/commands/plugin/install.integration.test.ts +++ b/packages/cli/src/commands/plugin/install.integration.test.ts @@ -31,12 +31,12 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SqlDriver } from '@objectstack/driver-sql'; -import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; +import { MAX_UNPACKED_ARTIFACT_BYTES, generateEd25519KeyPair, signPayload } from '@objectstack/core'; import { loadPluginInstallations, pluginInstallDir, pluginInstallationsFile, readPublisherPublicKey } from '@objectstack/runtime'; import { isExitSignal } from '../../utils/format.js'; import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../../utils/osplugin.js'; @@ -280,6 +280,17 @@ describe('os plugin install (ADR-0025 §3.5 M1)', () => { expect(existsSync(join(home, 'plugins'))).toBe(false); }, CASE_TIMEOUT_MS); + it('[slice 2] refuses, unread, an artifact file larger than any artifact may unpack to', async () => { + const path = join(project, 'too-large.osplugin'); + writeFileSync(path, ''); + truncateSync(path, MAX_UNPACKED_ARTIFACT_BYTES + 1); // sparse: no bytes are written + const result = await run(PluginInstall, [path, '--trust-unsigned', ...db(), '--json']); + expect(result.payload, result.stdout.slice(-400)).toMatchObject({ error: 'artifact_unreadable' }); + expect(result.payload.message).toContain(`it is ${MAX_UNPACKED_ARTIFACT_BYTES + 1} bytes, more than the ${MAX_UNPACKED_ARTIFACT_BYTES}`); + expect(result.exitCode).toBe(1); + expect(existsSync(join(home, 'plugins'))).toBe(false); + }, CASE_TIMEOUT_MS); + it('refuses a malformed signature by name', async () => { const path = artifact('malformed', { id: 'com.acme.malformed' }); writeFileSync(`${path}.sig`, 'not-a-signature'); diff --git a/packages/cli/src/commands/plugin/uninstall.test.ts b/packages/cli/src/commands/plugin/uninstall.test.ts new file mode 100644 index 00000000000..8f30dfa3acd --- /dev/null +++ b/packages/cli/src/commands/plugin/uninstall.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] `os plugin uninstall`'s answers when the record moves under + * it, against a stand-in store: the record and the lock themselves are pinned + * in `@objectstack/runtime`'s suite, and the command end to end in + * `install.integration.test.ts`. + * + * Pinned: a record found before the lock and gone under it is + * `plugin_not_installed` ("removed while this command ran"), never "Marked"; + * a busy lock is `installations_locked`; any other failure of the mark is + * `uninstall_failed`; each writes nothing and exits 1. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Config } from '@oclif/core'; + +const stub = vi.hoisted(() => { + class PluginInstallationsLockedError extends Error {} + return { + PluginInstallationsLockedError, + mark: null as null | (() => unknown), + emitted: [] as Array<{ payload: unknown; exitCode: number }>, + }; +}); + +vi.mock('@objectstack/runtime', () => ({ + PluginInstallationsLockedError: stub.PluginInstallationsLockedError, + markPluginUninstall: () => (stub.mark as () => unknown)(), +})); +vi.mock('../../utils/plugin-install.js', () => ({ + pluginEnvironmentId: () => 'env_local', + findPluginInstallation: (_env: string, id: string) => ({ + ok: true, + record: { id, version: '1.0.0', status: 'installed', dir: `/home/os/plugins/env_local/${id}/1.0.0` }, + }), +})); +vi.mock('../../utils/format.js', () => ({ + emitJson: async (payload: unknown, exitCode = 0) => { + stub.emitted.push({ payload, exitCode }); + }, + printError: () => {}, + printHeader: () => {}, + printInfo: () => {}, + printKV: () => {}, + printSuccess: () => {}, +})); + +import PluginUninstall from './uninstall.js'; + +beforeEach(() => { + stub.emitted = []; +}); + +afterEach(() => { + stub.mark = null; +}); + +/** + * The command on its own: its body and flags are what is under test, so it + * runs without loading the CLI's oclif config (seconds per run, for nothing + * this file reads). + */ +async function uninstall(): Promise<{ payload: unknown; exitCode: number }> { + const config = { runHook: async () => ({ successes: [], failures: [] }) } as unknown as Config; + await new PluginUninstall(['com.acme.raced', '--json'], config).run(); + expect(stub.emitted).toHaveLength(1); + return stub.emitted[0]; +} + +describe('os plugin uninstall when the record moves under it', () => { + it('a record removed between the read and the lock is plugin_not_installed, not "Marked"', async () => { + stub.mark = () => undefined; + expect(await uninstall()).toEqual({ + exitCode: 1, + payload: { + error: 'plugin_not_installed', + message: expect.stringMatching(/^com\.acme\.raced is not installed in the environment 'env_local' any more: it was removed while this command ran\. Nothing was changed\.$/), + }, + }); + }); + + it.each([ + ['a lock another writer holds', 'installations_locked', () => new stub.PluginInstallationsLockedError('locked by process 7.')], + ['any other failure of the mark', 'uninstall_failed', () => new Error('the records file cannot be written.')], + ])('%s is %s, and nothing was changed', async (_label, code, error) => { + stub.mark = () => { throw error(); }; + const out = await uninstall(); + expect(out).toEqual({ exitCode: 1, payload: { error: code, message: expect.stringMatching(/ Nothing was changed\.$/) } }); + }); +}); diff --git a/packages/cli/src/utils/installed-plugins-boot.test.ts b/packages/cli/src/utils/installed-plugins-boot.test.ts new file mode 100644 index 00000000000..8143a7ab932 --- /dev/null +++ b/packages/cli/src/utils/installed-plugins-boot.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27123 slice 2] `os serve`'s half of the installed-plugin boot, against a + * stand-in loader and kernel: what the loader verifies is pinned in + * `@objectstack/runtime`'s own suite, and the real boot in + * `test/serve-installed-plugins.integration.test.ts`. + * + * Pinned: a loader that throws outright, whatever it throws, is one + * `loader_failed` refusal and the boot goes on with no installed plugin; a + * plugin whose name cannot be read, or that `kernel.use` refuses, is refused by + * name whatever was thrown (`undefined` and `null` included), and the next + * plugin is still registered; a name the boot already holds is not replaced. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const stub = vi.hoisted(() => ({ + installationsFile: '', + load: null as null | (() => Promise), +})); + +vi.mock('@objectstack/runtime', () => ({ + pluginInstallationsFile: () => stub.installationsFile, + loadInstalledPlugins: () => (stub.load as () => Promise)(), + AppPlugin: class {}, +})); +vi.mock('./plugin-install.js', () => ({ pluginEnvironmentId: () => 'env_local' })); +vi.mock('./plugin-trust-read.js', () => ({ readTrustedKeys: async () => ({ ok: true, keys: new Map() }) })); +vi.mock('./format.js', () => ({ isExitSignal: () => false })); + +import { registerInstalledPlugins } from './installed-plugins-boot.js'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-installed-plugins-boot-')); + stub.installationsFile = join(dir, 'installations.json'); + writeFileSync(stub.installationsFile, '{"installations":[]}'); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + stub.load = null; + rmSync(dir, { recursive: true, force: true }); +}); + +function record(id: string) { + return { id, version: '1.0.0', trustUnsigned: false }; +} + +function kernel(opts: { use?: (plugin: unknown) => Promise; taken?: string[] } = {}) { + const used: unknown[] = []; + return { + used, + use: async (plugin: unknown) => { + if (opts.use) await opts.use(plugin); + used.push(plugin); + }, + hasPlugin: (name: string) => (opts.taken ?? []).includes(name), + }; +} + +const boot = (k: ReturnType) => registerInstalledPlugins({ kernel: k as never, track: () => {} }); + +describe('the serve wiring of installed plugins', () => { + it.each([ + ['an Error', new Error('the records walk broke'), 'the records walk broke'], + ['undefined', undefined, 'undefined'], + ['null', null, 'null'], + ])('a loader that throws %s is one loader_failed refusal, and nothing is registered', async (_label, thrown, said) => { + stub.load = async () => { throw thrown; }; + const k = kernel(); + const out = await boot(k); + expect(out).toEqual({ + loaded: [], + refused: [{ id: '*', version: '*', error: 'loader_failed', message: expect.stringContaining(`(${said})`) }], + }); + expect(k.used).toEqual([]); + }); + + it('a plugin whose name cannot be read, or that kernel.use refuses with null, is refused by name; the next one is registered', async () => { + const nameless = { init() {}, get name(): string { throw undefined; } }; + const refusedByKernel = { name: 'refused-by-kernel', init() {} }; + const fine = { name: 'fine', init() {} }; + stub.load = async () => ({ + loaded: [ + { record: record('com.acme.nameless'), plugin: nameless }, + { record: record('com.acme.refused'), plugin: refusedByKernel }, + { record: record('com.acme.fine'), plugin: fine }, + ], + refused: [], + disabled: [], + removed: [], + }); + const k = kernel({ use: async (plugin) => { if (plugin === refusedByKernel) throw null; } }); + const out = await boot(k); + expect(out?.refused).toEqual([ + { id: 'com.acme.nameless', version: '1.0.0', error: 'kernel_refused', message: 'its plugin name cannot be read: undefined' }, + { id: 'com.acme.refused', version: '1.0.0', error: 'kernel_refused', message: 'null' }, + ]); + expect(out?.loaded).toEqual(['com.acme.fine@1.0.0']); + expect(k.used).toEqual([fine]); + }); + + it('an installed plugin never replaces one the boot already registered under its name', async () => { + stub.load = async () => ({ + loaded: [{ record: record('com.acme.dup'), plugin: { name: 'host-plugin', init() {} } }], + refused: [], + disabled: [], + removed: [], + }); + const k = kernel({ taken: ['host-plugin'] }); + const out = await boot(k); + expect(out?.refused).toEqual([expect.objectContaining({ id: 'com.acme.dup', error: 'plugin_name_taken' })]); + expect(k.used).toEqual([]); + }); + + it('reads nothing when this project has no installations file', async () => { + rmSync(stub.installationsFile); + stub.load = async () => { throw new Error('the loader ran'); }; + expect(await boot(kernel())).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/installed-plugins-boot.ts b/packages/cli/src/utils/installed-plugins-boot.ts index 6e2fed02845..59abb8d5632 100644 --- a/packages/cli/src/utils/installed-plugins-boot.ts +++ b/packages/cli/src/utils/installed-plugins-boot.ts @@ -53,6 +53,19 @@ interface BootKernel { hasPlugin(name: string): boolean; } +/** + * What a throw says, whatever was thrown: a plugin's getter or `init` can throw + * anything, `undefined` and `null` included, and reading `.message` off that + * would throw in the catch meant to name it. + */ +function thrownMessage(error: unknown): string { + try { + return error instanceof Error ? error.message : String(error); + } catch { + return 'a value that cannot be printed'; + } +} + export async function registerInstalledPlugins(input: { kernel: BootKernel; track(name: string): void; @@ -81,7 +94,7 @@ export async function registerInstalledPlugins(input: { loaded: [], removed: [], disabled: [], - refused: [{ id: '*', version: '*', error: 'loader_failed', message: `the installed-plugin loader failed (${(error as Error).message}); no installed plugin was loaded.` }], + refused: [{ id: '*', version: '*', error: 'loader_failed', message: `the installed-plugin loader failed (${thrownMessage(error)}); no installed plugin was loaded.` }], }; } @@ -102,7 +115,7 @@ export async function registerInstalledPlugins(input: { try { name = String((plugin as { name?: unknown }).name ?? record.id); } catch (error) { - out.refused.push({ id: record.id, version: record.version, error: 'kernel_refused', message: `its plugin name cannot be read: ${(error as Error).message}` }); + out.refused.push({ id: record.id, version: record.version, error: 'kernel_refused', message: `its plugin name cannot be read: ${thrownMessage(error)}` }); continue; } if (input.kernel.hasPlugin(name)) { @@ -121,7 +134,7 @@ export async function registerInstalledPlugins(input: { id: record.id, version: record.version, error: 'kernel_refused', - message: (error as Error).message, + message: thrownMessage(error), }); continue; } diff --git a/packages/cli/test/serve-installed-plugins.integration.test.ts b/packages/cli/test/serve-installed-plugins.integration.test.ts index d722c24f25b..46db45b525d 100644 --- a/packages/cli/test/serve-installed-plugins.integration.test.ts +++ b/packages/cli/test/serve-installed-plugins.integration.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 40 — spawns the real producer once per single-cause fixture: two boots of the real `os serve --no-server` (OS_MIGRATE_AND_EXIT=1), each over the installs it pins (the first over six, the second after one enable and one uninstall), and the build, installs and lifecycle commands run in process; CI reading 27.08 s (Test Core 5/8, job 123319866423, head af0b3888) x 1.25, rounded up /** * [#27123 slice 2] `os serve` loads what `os plugin install` installed diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 5644726362c..6ae521ebab6 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -52,7 +52,13 @@ export interface PluginMetadata extends Plugin { // config-validation surface is a fresh decision for the day the ADR-0025 // distribution layer lands. - /** Plugin signature for security verification */ + /** + * The detached signature its artifact verified under. Not a verification + * by itself: the loader refuses a plugin carrying one unless the host that + * verified the artifact recorded that verdict against this object + * (`recordPluginArtifactVerdict`). Leave it unset on a plugin you register + * yourself. + */ signature?: string; /** Plugin health check function */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 0a7d6737dfb..b9da74e2249 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -49,7 +49,6 @@ export { INSTALLED_SIGNATURE_FILE, INSTALLATIONS_LOCK_WAIT_MS, INSTALLATIONS_LOCK_STALE_MS, - INSTALLATIONS_LOCK_STEAL_STALE_MS, PluginInstallationsLockedError, pluginsHome, pluginInstallDir, diff --git a/packages/runtime/src/installed-plugin-loader.test.ts b/packages/runtime/src/installed-plugin-loader.test.ts index 7eb04eb54c4..f62480f1d00 100644 --- a/packages/runtime/src/installed-plugin-loader.test.ts +++ b/packages/runtime/src/installed-plugin-loader.test.ts @@ -11,17 +11,22 @@ * host verdict the kernel's loader reads; each change is refused by its own * name and nothing of that plugin is imported; a disabled install is skipped; * an uninstall removes the directory unless another project still records it; - * and only `runtime: 'node'` is ever imported. + * and only `runtime: 'node'` is ever imported. A throw on one installation + * refuses that one by name, whatever was thrown. In a real Node process, a + * bare `@objectstack/*` import resolves to the host's package, also through a + * symbolic link, and a specifier that climbs out of `@objectstack/` does not. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, truncateSync, writeFileSync } from 'node:fs'; import { createPublicKey } from 'node:crypto'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { gzipSync } from 'node:zlib'; -import { generateEd25519KeyPair, pluginArtifactVerdict, sha256Hex, signPayload } from '@objectstack/core'; +import { MAX_UNPACKED_ARTIFACT_BYTES, generateEd25519KeyPair, pluginArtifactVerdict, sha256Hex, signPayload } from '@objectstack/core'; import { dispatchPluginRuntime, loadInstalledPlugins, verifyInstalledPlugins } from './installed-plugin-loader.js'; import { @@ -418,6 +423,8 @@ describe('what the import answers', () => { ['the plugin declares another signature', 'signature_conflict', 'export default { name: "x", init() {}, signature: "ed25519:other:AAAA" };\n'], ['the plugin object is frozen', 'signature_conflict', 'export default Object.freeze({ name: "x", init() {} });\n'], ['reading the export throws', 'not_a_plugin', 'export default { name: "x", init() {}, get signature() { throw new Error("getter"); } };\n'], + ['the import throws undefined', 'import_failed', 'throw undefined;\n'], + ['reading the export throws null', 'not_a_plugin', 'export default { name: "x", init() {}, get signature() { throw null; } };\n'], ])('%s → %s', async (_label, error, entry) => { install({ entry }); const out = await load(); @@ -581,4 +588,105 @@ describe('a throw on one installation refuses it by name, and the boot goes on', // The next boot reads the fresh install and loads it. expect((await load()).loaded.map((l) => l.record.id).sort()).toEqual(['com.acme.gone', 'com.acme.signed']); }); + + it('a module whose top level throws undefined is refused by name, and the next plugin still loads', async () => { + install({ id: 'com.acme.throws', entry: 'throw undefined;\n' }); + install({ id: 'com.acme.fine' }); + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: 'com.acme.throws', error: 'import_failed' })]); + expect(out.refused[0].message).toMatch(/failed: undefined$/); + expect(out.loaded.map((l) => l.record.id)).toEqual(['com.acme.fine']); + }); + + it('an uninstall whose installations lock cannot be read → uninstall_failed naming it, at once; the next plugin still loads', async () => { + const r = install({ id: 'com.acme.gone' }); + markPluginUninstall(ENV, r.id); + install({ id: 'com.acme.fine' }); + const lock = `${pluginInstallationsFile(ENV)}.lock`; + writeFileSync(lock, JSON.stringify({ pid: process.ppid, thread: 0, token: 'unreadable', at: new Date().toISOString() })); + fsFaults.fail = (op, path) => (op === 'readFile' && path === lock ? 'EACCES' : null); + const t0 = Date.now(); + const out = await load(); + expect(Date.now() - t0).toBeLessThan(5_000); + expect(out.refused).toEqual([expect.objectContaining({ id: r.id, error: 'uninstall_failed' })]); + expect(out.refused[0].message).toMatch(/installations lock .* cannot be read \(EACCES/); + expect(out.loaded.map((l) => l.record.id)).toEqual(['com.acme.fine']); + expect(existsSync(r.dir)).toBe(true); + }); + + it('a kept artifact larger than any artifact an install accepts → artifact_changed, and it is never read', async () => { + const r = install(); + const kept = join(r.dir, INSTALLED_ARTIFACT_FILE); + truncateSync(kept, MAX_UNPACKED_ARTIFACT_BYTES + 1); // sparse: no bytes are written + let reads = 0; + fsFaults.fail = (op, path) => { + if (op === 'readFile' && path === kept) reads += 1; + return null; + }; + const out = await load(); + expect(out.refused).toEqual([expect.objectContaining({ id: r.id, error: 'artifact_changed' })]); + expect(out.refused[0].message).toContain('more than any artifact an install accepts'); + expect(reads).toBe(0); + expect(out.imported).toEqual([]); + }); +}); + +describe('what an installed plugin imports resolves to, in a real Node process', () => { + // `module.register`'s resolve hook acts on Node's own loader, which this test + // runner's module graph does not go through: a child process boots instead. + const subject = pathToFileURL(join(dirname(fileURLToPath(import.meta.url)), 'installed-plugin-loader.ts')).href; + // The host file: `@objectstack/core` and `zod` both resolve from this package. + const host = subject; + const script = [ + 'const [subject, home, host] = process.argv.slice(1);', + 'process.env.OS_HOME = home;', + 'const { loadInstalledPlugins } = await import(subject);', + 'const out = await loadInstalledPlugins({ environmentId: "env_local", resolveKeys: async () => new Map(),', + ' wrapBundle: (bundle) => bundle, resolveHostPackagesFrom: host });', + 'console.log(JSON.stringify({ loaded: out.loaded.map((l) => [l.record.id, l.plugin.seen]),', + ' refused: out.refused.map((r) => [r.id, r.error, r.message]) }));', + ].join('\n'); + + function bootInChild(osHome: string): { loaded: [string, unknown][]; refused: [string, string, string][] } { + const child = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script, subject, osHome, host], { + cwd: process.cwd(), + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '', HOME: osHome, ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}) }, + timeout: 90_000, + }); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout.trim().split('\n').pop() as string); + } + + it('a bare @objectstack/* import resolves to the host\'s package, also when OS_HOME is reached through a symbolic link', () => { + // Node names an importing module by its real path; the hook's roots must + // match it, not only the path the install directory was reached by. + const link = `${home}-link`; + symlinkSync(home, link); + try { + process.env.OS_HOME = link; + install({ + id: 'com.acme.core-user', + signed: false, + entry: 'import { sha256Hex } from "@objectstack/core";\nexport default { name: "core-user", init() {}, seen: sha256Hex(new Uint8Array([1])).length };\n', + }); + expect(bootInChild(link)).toEqual({ loaded: [['com.acme.core-user', 64]], refused: [] }); + } finally { + process.env.OS_HOME = home; + rmSync(link); + } + }, 120_000); + + it('a specifier that climbs out of @objectstack/ is not redirected to the host', () => { + // From the host, `@objectstack/../zod/package.json` is this package's + // `node_modules/zod/package.json`: redirected, it would load. + install({ + id: 'com.acme.climber', + signed: false, + entry: 'import pkg from "@objectstack/../zod/package.json" with { type: "json" };\nexport default { name: "climber", init() {}, seen: pkg.name };\n', + }); + const out = bootInChild(home); + expect(out.loaded).toEqual([]); + expect(out.refused).toEqual([['com.acme.climber', 'import_failed', expect.stringContaining('@objectstack/..')]]); + }, 120_000); }); diff --git a/packages/runtime/src/installed-plugin-loader.ts b/packages/runtime/src/installed-plugin-loader.ts index 5d2e7963c45..1c7d0f3ab98 100644 --- a/packages/runtime/src/installed-plugin-loader.ts +++ b/packages/runtime/src/installed-plugin-loader.ts @@ -59,12 +59,18 @@ * them, so a host passes `resolveHostPackagesFrom` (a file URL of its own) and * a bare `@objectstack/*` specifier imported from inside a verified install * directory resolves as if that host file imported it: the same module - * instances the host's kernel runs on. Any other bare specifier resolves as - * Node resolves it from the install directory. The verified tree is what the - * import reads first; a file the plugin imports later, lazily, is read from - * disk when it is imported, after this check. + * instances the host's kernel runs on. A directory is matched by its real + * path too, which is the one Node names an importing module by, so an + * `OS_HOME` reached through a symbolic link (the macOS temp dir is one) is + * matched. Only a specifier that names a package under `@objectstack/` is + * redirected: one with an empty, `.` or `..` segment, a `%` or a backslash + * (which could climb out of `@objectstack/` to any file the host can resolve) + * is not. Any other bare specifier resolves as Node resolves it from the + * install directory. The verified tree is what the import reads first; a file + * the plugin imports later, lazily, is read from disk when it is imported, + * after this check. */ -import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync } from 'node:fs'; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'; import { register } from 'node:module'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -245,7 +251,7 @@ function unexpectedDir(environmentId: string | undefined, record: PluginInstalla try { derived = pluginInstallDir(environmentId, record.id, record.version); } catch (error) { - derived = `none (${(error as Error).message})`; + derived = `none (${thrownMessage(error)})`; } return `its record names the directory ${JSON.stringify(record.dir)}, and the install directory for ${record.id}@${record.version} is ${derived}. Nothing was read from or removed under it. Install it again.`; } @@ -270,7 +276,7 @@ function readKept(record: PluginInstallationRecord): } artifact = new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE))); } catch (error) { - return { ok: false, error: 'artifact_changed', message: `${join(dir, INSTALLED_ARTIFACT_FILE)} cannot be read (${(error as Error).message}).` }; + return { ok: false, error: 'artifact_changed', message: `${join(dir, INSTALLED_ARTIFACT_FILE)} cannot be read (${thrownMessage(error)}).` }; } const sha = sha256Hex(artifact); if (sha !== record.artifact?.sha256) { @@ -346,7 +352,7 @@ async function checkRecord( try { files = readTarGz(artifact); } catch (error) { - return refuse('artifact_unreadable', `its kept artifact cannot be read: ${(error as Error).message}.`); + return refuse('artifact_unreadable', `its kept artifact cannot be read: ${thrownMessage(error)}.`); } const unsafe = files.find((f) => !safeEntryPath(f.path)); if (unsafe) return refuse('artifact_unreadable', `its kept artifact holds the entry ${JSON.stringify(unsafe.path)}, which cannot sit in an install directory.`); @@ -358,7 +364,7 @@ async function checkRecord( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILE} is not a JSON object`); manifest = parsed as Record; } catch (error) { - return refuse('artifact_unreadable', `its kept artifact's manifest cannot be read: ${(error as Error).message}.`); + return refuse('artifact_unreadable', `its kept artifact's manifest cannot be read: ${thrownMessage(error)}.`); } if (manifest.id !== record.id || manifest.version !== record.version) { return refuse( @@ -395,7 +401,7 @@ async function checkRecord( /** A check that threw on the install directory (unreadable, or changing while it was read). */ function unreadableInstall(record: PluginInstallationRecord, error: unknown): string { - return `reading its install directory ${record.dir} failed (${(error as Error).message}), so it could not be verified and is not loaded.`; + return `reading its install directory ${record.dir} failed (${thrownMessage(error)}), so it could not be verified and is not loaded.`; } /** @@ -410,7 +416,7 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) try { records = loadPluginInstallations(input.environmentId); } catch (error) { - out.refused.push({ id: '*', version: '*', error: 'records_unreadable', message: (error as Error).message }); + out.refused.push({ id: '*', version: '*', error: 'records_unreadable', message: thrownMessage(error) }); return out; } if (records.length === 0) return out; @@ -421,7 +427,7 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) try { disabled = installedPluginDisabledIds(input.environmentId); } catch (error) { - disabledUnreadable = (error as Error).message; + disabledUnreadable = thrownMessage(error); } const pending: Array<{ record: PluginInstallationRecord; kept: { artifact: Uint8Array; signature: string | null; keyId: string | null } }> = []; for (const record of records) { @@ -477,7 +483,7 @@ export async function verifyInstalledPlugins(input: VerifyInstalledPluginsInput) } catch (error) { // No key could be read, so no signed install is verified: each is // refused by name, and an unsigned one is still checked below. - const message = `this environment's trusted keys cannot be read: ${(error as Error).message}`; + const message = `this environment's trusted keys cannot be read: ${thrownMessage(error)}`; for (const { record, kept } of pending) { if (kept.keyId !== null) out.refused.push({ id: record.id, version: record.version, error: 'trusted_keys_unreadable', message }); } @@ -534,8 +540,12 @@ const HOST_PACKAGE_HOOKS = ` let roots = []; let host = ''; export async function initialize(data) { roots = data.roots; host = data.host; } +function namesHostPackage(specifier) { + if (!specifier.startsWith('@objectstack/') || /[%\\\\]/.test(specifier)) return false; + return specifier.slice('@objectstack/'.length).split('/').every((s) => s !== '' && s !== '.' && s !== '..'); +} export async function resolve(specifier, context, next) { - if (specifier.startsWith('@objectstack/') && typeof context.parentURL === 'string' + if (namesHostPackage(specifier) && typeof context.parentURL === 'string' && roots.some((root) => context.parentURL.startsWith(root))) { return next(specifier, { ...context, parentURL: host }); } @@ -543,11 +553,33 @@ export async function resolve(specifier, context, next) { } `; +/** + * What a throw says, whatever was thrown: a plugin's top-level code or getter + * can throw anything, `undefined` and `null` included, and reading `.message` + * off that would throw in the catch meant to name it. + */ +function thrownMessage(error: unknown): string { + try { + return error instanceof Error ? error.message : String(error); + } catch { + return 'a value that cannot be printed'; + } +} + +/** `dir`'s real path, or `dir` itself when it has none to read. */ +function realPathOr(dir: string): string { + try { + return realpathSync(dir); + } catch { + return dir; + } +} + /** The (host, install directory) pairs this process already registered the hook for. */ const HOST_RESOLUTION_REGISTERED = new Set(); function resolveHostPackagesFor(dirs: readonly string[], host: string): void { - const roots = dirs + const roots = [...new Set(dirs.flatMap((dir) => [dir, realPathOr(dir)]))] .map((dir) => `${pathToFileURL(dir).href}/`) .filter((root) => !HOST_RESOLUTION_REGISTERED.has(`${host} ${root}`)); if (roots.length === 0) return; @@ -642,7 +674,7 @@ export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Pr id: record.id, version: record.version, error: 'uninstall_failed', - message: `finishing its uninstall failed (${(error as Error).message}). Nothing of it was loaded; if its record is still marked, the next boot tries again.`, + message: `finishing its uninstall failed (${thrownMessage(error)}). Nothing of it was loaded; if its record is still marked, the next boot tries again.`, }); continue; } @@ -676,7 +708,7 @@ export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Pr wrapBundle: input.wrapBundle, }); } catch (error) { - out.refused.push({ id: record.id, version: record.version, error: 'import_failed', message: `importing ${item.entry} failed: ${(error as Error).message}` }); + out.refused.push({ id: record.id, version: record.version, error: 'import_failed', message: `importing ${item.entry} failed: ${thrownMessage(error)}` }); continue; } // The module's top-level code has run from here on. Reading what it @@ -685,7 +717,7 @@ export async function loadInstalledPlugins(input: LoadInstalledPluginsInput): Pr try { refusal = inspectImported(record, item, plugin); } catch (error) { - refusal = { id: record.id, version: record.version, error: 'not_a_plugin', message: `reading what ${item.entry} exports threw: ${(error as Error).message}` }; + refusal = { id: record.id, version: record.version, error: 'not_a_plugin', message: `reading what ${item.entry} exports threw: ${thrownMessage(error)}` }; } if (refusal) { out.refused.push(refusal); diff --git a/packages/runtime/src/plugin-installation-store.lock.test.ts b/packages/runtime/src/plugin-installation-store.lock.test.ts index f81c3fb22b7..7276d2cc690 100644 --- a/packages/runtime/src/plugin-installation-store.lock.test.ts +++ b/packages/runtime/src/plugin-installation-store.lock.test.ts @@ -10,16 +10,19 @@ * thread's own pid and thread while it holds nothing) is cleared by the next * writer, and another thread of this process holding it is live; a lock judged * stale and replaced by a live writer before it is cleared is left alone (the - * two-writer steal race), and a steal lock a crashed writer left is cleared; + * two-writer steal race), and a steal lock a crashed writer left is cleared + * while a live writer's is kept, however long it has been held, and waited + * for without spinning; a lock path that is there and cannot be read (a + * directory, an unreadable file) is refused by name at once, never retried; * the lock is re-entrant and always released, and a release never removes a * lock that is no longer the holder's; two processes writing at once both land * every record; and another project's records keep their directories out of * an uninstall. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -27,7 +30,6 @@ import { threadId } from 'node:worker_threads'; import { INSTALLATIONS_LOCK_STALE_MS, - INSTALLATIONS_LOCK_STEAL_STALE_MS, PluginInstallationsLockedError, installationsLockTestHooks, dropPluginInstallation, @@ -46,6 +48,22 @@ import { loadDisabledPackageIds, packageStateFileName } from './package-state-st const HERE = dirname(fileURLToPath(import.meta.url)); +/** A file read the test makes throw, by its path; every other call is the real one. */ +const fsFaults = vi.hoisted(() => ({ failRead: null as ((path: string) => string | null) | null })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: ((...args: Parameters) => { + const code = fsFaults.failRead?.(String(args[0])); + if (code) throw Object.assign(new Error(`${code}: read refused by the test, '${String(args[0])}'`), { code }); + return actual.readFileSync(...args); + }) as typeof actual.readFileSync, + }; +}); + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + let home: string; const saved = { OS_HOME: process.env.OS_HOME }; @@ -55,6 +73,8 @@ beforeEach(() => { }); afterEach(() => { + fsFaults.failRead = null; + installationsLockTestHooks.afterStaleJudged = undefined; if (saved.OS_HOME === undefined) delete process.env.OS_HOME; else process.env.OS_HOME = saved.OS_HOME; rmSync(home, { recursive: true, force: true }); @@ -96,7 +116,7 @@ describe('the installations lock', () => { writeFileSync(lock, JSON.stringify({ pid: process.ppid, at: '2026-10-29T00:00:00.000Z' })); const t0 = Date.now(); expect(() => withPluginInstallationsLock('env_local', () => 'never', { waitMs: 300 })) - .toThrow(new RegExp(`locked by process ${process.ppid} .*remove ${lock.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + .toThrow(new RegExp(`locked by process ${process.ppid} .*remove ${escapeRe(lock)}`)); expect(Date.now() - t0).toBeGreaterThanOrEqual(300); // The holder's lock is left alone. expect(JSON.parse(readFileSync(lock, 'utf8')).pid).toBe(process.ppid); @@ -170,17 +190,57 @@ describe('the installations lock', () => { expect(existsSync(`${lock}.steal`)).toBe(false); }); - it('clears a steal lock a writer left when it died clearing a stale lock', () => { + it('clears a steal lock a writer left when it died clearing a stale lock, judged by its pid', () => { const lock = lockFile(); writeFileSync(lock, JSON.stringify({ pid: deadPid(), at: 'then' })); - writeFileSync(`${lock}.steal`, JSON.stringify({ pid: deadPid(), at: 'then' })); - const then = new Date(Date.now() - INSTALLATIONS_LOCK_STEAL_STALE_MS - 5_000); - utimesSync(`${lock}.steal`, then, then); + // Fresh, so only its dead pid makes it stale. + writeFileSync(`${lock}.steal`, JSON.stringify({ pid: deadPid(), at: 'now' })); expect(withPluginInstallationsLock('env_local', () => 'ran', { waitMs: 1_000 })).toBe('ran'); expect(existsSync(lock)).toBe(false); expect(existsSync(`${lock}.steal`)).toBe(false); }); + it('keeps a steal lock a live writer holds, however long it has held it, and waits for it without spinning', () => { + const lock = lockFile(); + const stale = JSON.stringify({ pid: deadPid(), at: 'then' }); + const clearing = JSON.stringify({ pid: process.ppid, token: 'slow-writer', at: 'then' }); + writeFileSync(lock, stale); + writeFileSync(`${lock}.steal`, clearing); + // Held for a minute: age alone does not make a live writer's steal lock stale. + const then = new Date(Date.now() - 60_000); + utimesSync(`${lock}.steal`, then, then); + let judged = 0; + installationsLockTestHooks.afterStaleJudged = () => { judged += 1; }; + const t0 = Date.now(); + expect(() => withPluginInstallationsLock('env_local', () => 'never', { waitMs: 300 })) + .toThrow(new RegExp(`stale lock .*another writer was still clearing after 0\\.3s.*remove ${escapeRe(lock)} and ${escapeRe(lock)}\\.steal`)); + expect(Date.now() - t0).toBeGreaterThanOrEqual(300); + // A sleep between judgements, not a spin: about one per 50 ms. + expect(judged).toBeLessThan(20); + expect(readFileSync(lock, 'utf8')).toBe(stale); + expect(readFileSync(`${lock}.steal`, 'utf8')).toBe(clearing); + }); + + it.each([ + ['a directory', (lock: string) => mkdirSync(lock), 'EISDIR'], + ['a file this user may not read', (lock: string) => { + writeFileSync(lock, JSON.stringify({ pid: process.ppid, at: 'now' })); + fsFaults.failRead = (path) => (path === lock ? 'EACCES' : null); + }, 'EACCES'], + ])('refuses a lock path that is there and cannot be read, naming it, at once and without retrying: %s', (_label, plant, code) => { + const lock = lockFile(); + plant(lock); + let ran = false; + const t0 = Date.now(); + expect(() => withPluginInstallationsLock('env_local', () => { ran = true; }, { waitMs: 5_000 })) + .toThrow(new RegExp(`installations lock ${escapeRe(lock)} cannot be read \\(${code}.*remove it`)); + expect(Date.now() - t0).toBeLessThan(2_000); + expect(ran).toBe(false); + fsFaults.failRead = null; + expect(existsSync(lock)).toBe(true); + expect(statSync(lock).isDirectory()).toBe(code === 'EISDIR'); + }); + it('never removes, on release, a lock that is no longer the holder\'s', () => { const lock = lockFile(); const other = JSON.stringify({ pid: process.ppid, token: 'other', at: 'now' }); diff --git a/packages/runtime/src/plugin-installation-store.ts b/packages/runtime/src/plugin-installation-store.ts index 1e5856fcedc..386fe8a40c1 100644 --- a/packages/runtime/src/plugin-installation-store.ts +++ b/packages/runtime/src/plugin-installation-store.ts @@ -66,8 +66,12 @@ * bytes, which carry a per-acquisition token. A writer that finds the lock * replaced by a live one since its judgement leaves it and waits. A holder * removes the lock on release only while it is still its own. A steal lock a - * crashed writer left is cleared the same way once it is older than - * {@link INSTALLATIONS_LOCK_STEAL_STALE_MS}. + * crashed writer left is judged as the lock is (its pid, its age) and cleared + * the same way; a writer that finds a live one waits, as for the lock. + * + * A lock file that is there and cannot be read (a directory, or a file this + * user may not read) is never taken for none: the writer refuses, naming it, + * rather than trying again. * * Exported from the package index for the CLI, which writes it; the runtime's * boot-time loader is its reader. @@ -97,9 +101,6 @@ export const INSTALLED_SIGNATURE_FILE = 'artifact.osplugin.sig'; export const INSTALLATIONS_LOCK_WAIT_MS = 10_000; /** A lock older than this is stale whatever its pid answers. */ export const INSTALLATIONS_LOCK_STALE_MS = 5 * 60_000; -/** A steal lock older than this was left by a writer that died clearing a stale lock. */ -export const INSTALLATIONS_LOCK_STEAL_STALE_MS = 2_000; - /** * Thrown by {@link withPluginInstallationsLock} when another live writer holds * the lock past the wait, so a caller can name that refusal apart from any @@ -245,13 +246,21 @@ interface LockFile { raw: string; } -/** The lock file at `path` now, or `null` when there is none (or it vanished while being read). */ +/** + * The lock file at `path` now, or `null` when there is none (or it vanished + * while being read). One that is there and cannot be read throws, naming it: + * taken for none, the writer would retry it forever. + */ function readLockFile(path: string): (LockFile & { ageMs: number }) | null { try { const stat = statSync(path); return { ino: stat.ino, raw: readFileSync(path, 'utf8'), ageMs: Date.now() - stat.mtimeMs }; - } catch { - return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw new Error( + `The plugin installations lock ${path} cannot be read (${(error as Error).message}). ` + + 'If no `os plugin` command is running, remove it.', + ); } } @@ -312,22 +321,25 @@ export const installationsLockTestHooks: { afterStaleJudged?: (lock: string) => /** * Clear the lock judged stale, holding the steal lock so no other writer - * clears at the same time, and only while it is still that file. A steal lock - * held by another writer means it is clearing now: the caller tries again. + * clears at the same time, and only while it is still that file. Answers + * `false` when another writer holds the steal lock: it is clearing now, and + * the caller waits. A steal lock a dead writer left is cleared here, judged as + * the lock is, so a live writer slow inside its clear keeps it. */ -function clearStaleLock(lock: string, judged: LockFile): void { +function clearStaleLock(lock: string, judged: LockFile): boolean { const steal = `${lock}.steal`; const mine = createLockFile(steal); if (!mine) { const left = readLockFile(steal); - if (left && left.ageMs > INSTALLATIONS_LOCK_STEAL_STALE_MS) removeIfUnchanged(steal, left); - return; + if (left && judgeLock(left).stale) removeIfUnchanged(steal, left); + return false; } try { removeIfUnchanged(lock, judged); } finally { removeIfUnchanged(steal, mine); } + return true; } /** Lock files this thread holds, and how deep: the lock is re-entrant within a thread. */ @@ -371,13 +383,15 @@ export function withPluginInstallationsLock( const { stale, holder } = judgeLock(present); if (stale) { installationsLockTestHooks.afterStaleJudged?.(lock); - clearStaleLock(lock, present); - continue; + if (clearStaleLock(lock, present)) continue; } if (Date.now() >= deadline) { throw new PluginInstallationsLockedError( - `The plugin installations file ${file} is locked by ${holder}, still running after ` - + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, + stale + ? `The plugin installations file ${file} has a stale lock (${holder}) that another writer was still ` + + `clearing after ${waitMs / 1000}s. If no \`os plugin\` command is running, remove ${lock} and ${lock}.steal.` + : `The plugin installations file ${file} is locked by ${holder}, still running after ` + + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, ); } sleepSync(50); @@ -387,7 +401,12 @@ export function withPluginInstallationsLock( return fn(); } finally { HELD.delete(lock); - removeIfUnchanged(lock, mine); + try { + removeIfUnchanged(lock, mine); + } catch { + // A lock this writer can no longer read may not be its own, so it is + // left; the next writer names it. What `fn` answered stands. + } } } diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 787856f5433..d3bfd7c00e8 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -1018,9 +1018,10 @@ export const ManifestSchema = strictObject({ /** * Trust / isolation tier the plugin DECLARES (ADR-0025 §3.6). * Unset implies a pure-metadata package (no executable code). An artifact - * `os plugin install` installs carries code, so on one an unset tier is an - * omission, not that declaration: the boot-time loader refuses it - * (`runtime_undeclared`, default-deny). + * `os plugin build` produces carries code, so on one an unset tier is an + * omission, not that declaration; the boot-time loader refuses an installed + * plugin with no tier either way (`runtime_undeclared`, default-deny), and + * one with no entry module too. * * ⚠️ Enforced at the cloud marketplace **publish gate** — an unverified * publisher requesting the `node` tier is hard-rejected (HTTP 422) and forced From 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:37:46 +0000 Subject: [PATCH 4/4] fix(runtime, cli): review round 3 on the boot-time plugin loader (#27123) Seat review r3 and contract review on 766b96ff. - runtime: the installations lock is read by lstat, so a path that is not a regular file (a directory, a symbolic link, dangling or not) is refused by name at once. A dangling link made the exclusive create fail and the followed read find nothing, and the writer spun forever. A lock gone when read now waits under the same deadline and pause as any other try, and refuses naming the lock. - runtime, cli: a thrown Error whose message is not a string (a Symbol) is converted, so the plugin is still refused by name. - docs: a --trust-unsigned install is checked by hash, not by key. - changeset: os plugin publish reads artifacts through core's reader too, so its preflight now refuses what install's does (an inflate past 256 MiB, a non-regular or cut-short entry); the reader sentence says so. - tests: the dangling link, the lock gone at every read, a live pid's steal lock past the stale bound, and a Symbol message at the loader and the serve wiring. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude --- .changeset/27123-boot-time-plugin-loader.md | 2 +- content/docs/plugins/index.mdx | 2 +- .../src/utils/installed-plugins-boot.test.ts | 1 + .../cli/src/utils/installed-plugins-boot.ts | 2 +- .../src/installed-plugin-loader.test.ts | 2 + .../runtime/src/installed-plugin-loader.ts | 2 +- .../plugin-installation-store.lock.test.ts | 75 +++++++++++++++---- .../runtime/src/plugin-installation-store.ts | 58 +++++++++----- 8 files changed, 107 insertions(+), 37 deletions(-) diff --git a/.changeset/27123-boot-time-plugin-loader.md b/.changeset/27123-boot-time-plugin-loader.md index 391907e626a..cfc0109635a 100644 --- a/.changeset/27123-boot-time-plugin-loader.md +++ b/.changeset/27123-boot-time-plugin-loader.md @@ -32,6 +32,6 @@ The second slice of ADR-0025's local install loop (§3.5 M1). Before this, an in The environment's trusted keys are read once per boot, through the same read-only one-shot boot `os plugin install` reads them through, and only when a signed install needs them; with no installations file nothing is read at all. A refused plugin does not stop the boot: it is printed as one `✗` line naming the refusal, and the banner counts the refusals; a loader that throws outright is one `loader_failed` line, and the boot goes on without installed plugins. An installed plugin never replaces a plugin the boot already registered under the same name (`plugin_name_taken`); a host plugin registered later under the same name replaces the installed one, with the kernel's warning. - **The kernel's signature check is real.** `PluginLoader` used to pass any well-formed `signature` a plugin carried. The host that verified the artifact now records its verdict against the plugin object (`recordPluginArtifactVerdict`, `@objectstack/core`), and the loader refuses a plugin whose signature no host verified. This stops a `signature` field from reading as verified; it is not a boundary against code already running in the process, which can call the public `recordPluginArtifactVerdict` itself. -- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, refuses an artifact file larger than any artifact may unpack to before reading it, and bounds the inflate (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The reader takes regular files and directories only, joins a POSIX `prefix` onto the name, refuses a link, device or extension header and a cut-short entry by name, and copies neither its input nor its files. The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`. +- **`os plugin install`** verifies the signature over the raw bytes before it inflates the archive, refuses an artifact file larger than any artifact may unpack to before reading it, and bounds the inflate (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB; the reader moved to `@objectstack/core` as `readTarGz`, re-exported where the CLI read it). The reader takes regular files and directories only, joins a POSIX `prefix` onto the name, refuses a link, device or extension header and a cut-short entry by name, and copies neither its input nor its files. **`os plugin publish`** reads an artifact through the same reader, so its preflight, beside `install`'s, now refuses an artifact that inflates past 256 MiB or carries a link, device, extension-header or cut-short entry, where the old reader inflated without bound and read every entry as a file; `os plugin build` writes regular files only. The checks, the write and the record now run under the installations lock. An artifact whose tier the boot does not dispatch installs with a warning naming the refusal, and `--json` carries `boot: { loads, error }`. - **`os plugin disable ID` / `os plugin enable ID`** keep an installed plugin from loading, or load it again, at the next boot (the package disable-state store, keyed by the plugin environment id). On a disable-state file they cannot read they refuse (`disable_state_failed`) and write nothing. **`os plugin uninstall ID`** marks the installation; the next boot loads nothing of it and, under the installations lock and only while the record is still the marked one (`record_changed` otherwise: an id installed again since the boot read the records keeps its fresh install), removes its directory unless another project sharing the home and environment still records it, drops the record and forgets its disable. A removal that fails is `uninstall_failed`, and a record still marked is tried again at the next boot. Each command refuses an id this project has not installed by name (`plugin_not_installed`, also when it was removed while the command ran); `--json` on all three. - **The installations file takes a lock** for every read-modify-write: `.lock`, created exclusively with the writer's pid, thread and a fresh token. A writer waits up to 10 s for a live holder, then refuses naming it (`PluginInstallationsLockedError`, which the commands report as `installations_locked`); the commands report any other failure under their own code. A lock a crashed process left is cleared by the next writer: its pid is not a live process, or it is older than 5 minutes, or it names the writer's own pid and thread while the writer holds nothing (another thread of the same process is a live holder), or it cannot be parsed and is older than a second. Clearing holds `.lock.steal` and removes the lock only while it is still the file judged stale, so two writers that judge the same lock stale cannot both take it; a writer releases its lock only while the file is still its own. diff --git a/content/docs/plugins/index.mdx b/content/docs/plugins/index.mdx index f7c52f7ce7c..44f1a725184 100644 --- a/content/docs/plugins/index.mdx +++ b/content/docs/plugins/index.mdx @@ -312,7 +312,7 @@ const dbPlugin: PluginMetadata = { ObjectStack supports plugin security features: -- **Signature Verification**: a plugin `os plugin install` installed is verified against the environment's trusted keys at install and again at every boot, before any of its code is imported. +- **Signature Verification**: a signed plugin `os plugin install` installed is verified against the environment's trusted keys at install and again at every boot, before any of its code is imported. One installed with `--trust-unsigned` has no signature to verify: at boot its kept artifact is checked against the hash recorded at install, and its files against that artifact. - **Permission Enforcement**: Fine-grained access control for plugin operations A `signature` field on a plugin object is not a verification by itself. The diff --git a/packages/cli/src/utils/installed-plugins-boot.test.ts b/packages/cli/src/utils/installed-plugins-boot.test.ts index 8143a7ab932..e7ee1b1d7c5 100644 --- a/packages/cli/src/utils/installed-plugins-boot.test.ts +++ b/packages/cli/src/utils/installed-plugins-boot.test.ts @@ -72,6 +72,7 @@ describe('the serve wiring of installed plugins', () => { ['an Error', new Error('the records walk broke'), 'the records walk broke'], ['undefined', undefined, 'undefined'], ['null', null, 'null'], + ['an Error whose message is a Symbol', Object.assign(new Error(), { message: Symbol('odd') as unknown as string }), 'Symbol(odd)'], ])('a loader that throws %s is one loader_failed refusal, and nothing is registered', async (_label, thrown, said) => { stub.load = async () => { throw thrown; }; const k = kernel(); diff --git a/packages/cli/src/utils/installed-plugins-boot.ts b/packages/cli/src/utils/installed-plugins-boot.ts index 59abb8d5632..9ba53867b0a 100644 --- a/packages/cli/src/utils/installed-plugins-boot.ts +++ b/packages/cli/src/utils/installed-plugins-boot.ts @@ -60,7 +60,7 @@ interface BootKernel { */ function thrownMessage(error: unknown): string { try { - return error instanceof Error ? error.message : String(error); + return String(error instanceof Error ? error.message : error); } catch { return 'a value that cannot be printed'; } diff --git a/packages/runtime/src/installed-plugin-loader.test.ts b/packages/runtime/src/installed-plugin-loader.test.ts index f62480f1d00..963e2fe80dc 100644 --- a/packages/runtime/src/installed-plugin-loader.test.ts +++ b/packages/runtime/src/installed-plugin-loader.test.ts @@ -425,6 +425,8 @@ describe('what the import answers', () => { ['reading the export throws', 'not_a_plugin', 'export default { name: "x", init() {}, get signature() { throw new Error("getter"); } };\n'], ['the import throws undefined', 'import_failed', 'throw undefined;\n'], ['reading the export throws null', 'not_a_plugin', 'export default { name: "x", init() {}, get signature() { throw null; } };\n'], + // A template literal throws on a Symbol, so the message is converted too. + ['the import throws an Error whose message is a Symbol', 'import_failed', 'throw Object.assign(new Error(), { message: Symbol("odd") });\n'], ])('%s → %s', async (_label, error, entry) => { install({ entry }); const out = await load(); diff --git a/packages/runtime/src/installed-plugin-loader.ts b/packages/runtime/src/installed-plugin-loader.ts index 1c7d0f3ab98..d5c7f504849 100644 --- a/packages/runtime/src/installed-plugin-loader.ts +++ b/packages/runtime/src/installed-plugin-loader.ts @@ -560,7 +560,7 @@ export async function resolve(specifier, context, next) { */ function thrownMessage(error: unknown): string { try { - return error instanceof Error ? error.message : String(error); + return String(error instanceof Error ? error.message : error); } catch { return 'a value that cannot be printed'; } diff --git a/packages/runtime/src/plugin-installation-store.lock.test.ts b/packages/runtime/src/plugin-installation-store.lock.test.ts index 7276d2cc690..ebf090e39df 100644 --- a/packages/runtime/src/plugin-installation-store.lock.test.ts +++ b/packages/runtime/src/plugin-installation-store.lock.test.ts @@ -11,9 +11,11 @@ * writer, and another thread of this process holding it is live; a lock judged * stale and replaced by a live writer before it is cleared is left alone (the * two-writer steal race), and a steal lock a crashed writer left is cleared - * while a live writer's is kept, however long it has been held, and waited - * for without spinning; a lock path that is there and cannot be read (a - * directory, an unreadable file) is refused by name at once, never retried; + * while a live writer's is kept up to the stale bound, and waited for without + * spinning; a lock path that is there and is not a lock file (a directory, a + * dangling symbolic link) or cannot be read is refused by name at once, never + * retried; a lock gone each time it is read is waited out and refused at the + * deadline, with a pause between tries; * the lock is re-entrant and always released, and a release never removes a * lock that is no longer the holder's; two processes writing at once both land * every record; and another project's records keep their directories out of @@ -22,7 +24,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -48,8 +50,11 @@ import { loadDisabledPackageIds, packageStateFileName } from './package-state-st const HERE = dirname(fileURLToPath(import.meta.url)); -/** A file read the test makes throw, by its path; every other call is the real one. */ -const fsFaults = vi.hoisted(() => ({ failRead: null as ((path: string) => string | null) | null })); +/** A file read or `lstat` the test makes throw, by its path; every other call is the real one. */ +const fsFaults = vi.hoisted(() => ({ + failRead: null as ((path: string) => string | null) | null, + failLstat: null as ((path: string) => string | null) | null, +})); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -59,6 +64,11 @@ vi.mock('node:fs', async (importOriginal) => { if (code) throw Object.assign(new Error(`${code}: read refused by the test, '${String(args[0])}'`), { code }); return actual.readFileSync(...args); }) as typeof actual.readFileSync, + lstatSync: ((...args: Parameters) => { + const code = fsFaults.failLstat?.(String(args[0])); + if (code) throw Object.assign(new Error(`${code}: lstat refused by the test, '${String(args[0])}'`), { code }); + return actual.lstatSync(...args); + }) as typeof actual.lstatSync, }; }); @@ -74,6 +84,7 @@ beforeEach(() => { afterEach(() => { fsFaults.failRead = null; + fsFaults.failLstat = null; installationsLockTestHooks.afterStaleJudged = undefined; if (saved.OS_HOME === undefined) delete process.env.OS_HOME; else process.env.OS_HOME = saved.OS_HOME; @@ -200,13 +211,14 @@ describe('the installations lock', () => { expect(existsSync(`${lock}.steal`)).toBe(false); }); - it('keeps a steal lock a live writer holds, however long it has held it, and waits for it without spinning', () => { + it('keeps a steal lock a live writer holds, inside the stale bound, and waits for it without spinning', () => { const lock = lockFile(); const stale = JSON.stringify({ pid: deadPid(), at: 'then' }); const clearing = JSON.stringify({ pid: process.ppid, token: 'slow-writer', at: 'then' }); writeFileSync(lock, stale); writeFileSync(`${lock}.steal`, clearing); - // Held for a minute: age alone does not make a live writer's steal lock stale. + // A minute old, well inside the stale bound: a live writer's steal lock + // is not stale by its age until it passes that bound. const then = new Date(Date.now() - 60_000); utimesSync(`${lock}.steal`, then, then); let judged = 0; @@ -221,24 +233,61 @@ describe('the installations lock', () => { expect(readFileSync(`${lock}.steal`, 'utf8')).toBe(clearing); }); + it('clears a steal lock a live pid holds once it is older than the stale bound', () => { + const lock = lockFile(); + writeFileSync(lock, JSON.stringify({ pid: deadPid(), at: 'then' })); + // The pid is live, so only the age makes it stale: a pid the operating + // system reused after the writer that took it died. + writeFileSync(`${lock}.steal`, JSON.stringify({ pid: process.ppid, token: 'reused-pid', at: 'then' })); + const then = new Date(Date.now() - INSTALLATIONS_LOCK_STALE_MS - 60_000); + utimesSync(`${lock}.steal`, then, then); + expect(withPluginInstallationsLock('env_local', () => 'ran', { waitMs: 1_000 })).toBe('ran'); + expect(existsSync(lock)).toBe(false); + expect(existsSync(`${lock}.steal`)).toBe(false); + }); + it.each([ - ['a directory', (lock: string) => mkdirSync(lock), 'EISDIR'], + ['a directory', (lock: string) => mkdirSync(lock), 'it is a directory', 'directory'], + // The exclusive create fails on the link (EEXIST) and a followed read + // finds nothing (ENOENT), so taken for "released" it was retried forever. + ['a dangling symbolic link', (lock: string) => symlinkSync(join(dirname(lock), 'nowhere'), lock), 'it is a symbolic link', 'link'], ['a file this user may not read', (lock: string) => { writeFileSync(lock, JSON.stringify({ pid: process.ppid, at: 'now' })); fsFaults.failRead = (path) => (path === lock ? 'EACCES' : null); - }, 'EACCES'], - ])('refuses a lock path that is there and cannot be read, naming it, at once and without retrying: %s', (_label, plant, code) => { + }, 'EACCES', 'file'], + ] as const)('refuses a lock path that is there and cannot be read, naming it, at once and without retrying: %s', (_label, plant, why, kind) => { const lock = lockFile(); plant(lock); let ran = false; const t0 = Date.now(); expect(() => withPluginInstallationsLock('env_local', () => { ran = true; }, { waitMs: 5_000 })) - .toThrow(new RegExp(`installations lock ${escapeRe(lock)} cannot be read \\(${code}.*remove it`)); + .toThrow(new RegExp(`installations lock ${escapeRe(lock)} cannot be read \\(${why}.*remove it`)); expect(Date.now() - t0).toBeLessThan(2_000); expect(ran).toBe(false); fsFaults.failRead = null; + const left = lstatSync(lock); + expect({ directory: left.isDirectory(), link: left.isSymbolicLink(), file: left.isFile() }[kind]).toBe(true); + }); + + it('waits out, with a pause between tries, a lock that is there to create and gone each time it is read, then refuses naming it', () => { + const lock = lockFile(); + writeFileSync(lock, JSON.stringify({ pid: process.ppid, at: 'now' })); + let reads = 0; + fsFaults.failLstat = (path) => { + if (path !== lock) return null; + reads += 1; + return 'ENOENT'; + }; + let ran = false; + const t0 = Date.now(); + expect(() => withPluginInstallationsLock('env_local', () => { ran = true; }, { waitMs: 300 })) + .toThrow(new RegExp(`could not be locked in 0\\.3s: ${escapeRe(lock)} was there .*remove ${escapeRe(lock)}\\.$`)); + expect(Date.now() - t0).toBeGreaterThanOrEqual(300); + // About one read per 50 ms pause; a spin makes thousands. + expect(reads).toBeLessThan(20); + expect(ran).toBe(false); + fsFaults.failLstat = null; expect(existsSync(lock)).toBe(true); - expect(statSync(lock).isDirectory()).toBe(code === 'EISDIR'); }); it('never removes, on release, a lock that is no longer the holder\'s', () => { diff --git a/packages/runtime/src/plugin-installation-store.ts b/packages/runtime/src/plugin-installation-store.ts index 386fe8a40c1..66d0d57e27a 100644 --- a/packages/runtime/src/plugin-installation-store.ts +++ b/packages/runtime/src/plugin-installation-store.ts @@ -69,15 +69,17 @@ * crashed writer left is judged as the lock is (its pid, its age) and cleared * the same way; a writer that finds a live one waits, as for the lock. * - * A lock file that is there and cannot be read (a directory, or a file this - * user may not read) is never taken for none: the writer refuses, naming it, - * rather than trying again. + * A lock path that is there and is not a lock file this store could have made + * (a directory, a symbolic link, dangling or not), or a file this user may not + * read, is never taken for none: the writer refuses, naming it, rather than + * trying again. A lock that is gone by the time it is read was released, and + * is tried again under the same wait and with the same pause between tries. * * Exported from the package index for the CLI, which writes it; the runtime's * boot-time loader is its reader. */ import { randomUUID } from 'node:crypto'; -import { closeSync, existsSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs'; +import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, type Stats } from 'node:fs'; import { dirname, join } from 'node:path'; import { threadId } from 'node:worker_threads'; @@ -248,19 +250,30 @@ interface LockFile { /** * The lock file at `path` now, or `null` when there is none (or it vanished - * while being read). One that is there and cannot be read throws, naming it: - * taken for none, the writer would retry it forever. + * while being read). A path that is there and is not a regular file, or cannot + * be read, throws, naming it: taken for none, the writer would retry it + * forever. It is judged by `lstat`, so a symbolic link is the link itself: one + * that dangles makes the exclusive create fail and a followed read find + * nothing, every time. */ function readLockFile(path: string): (LockFile & { ageMs: number }) | null { + const unreadable = (why: string) => + new Error(`The plugin installations lock ${path} cannot be read (${why}). If no \`os plugin\` command is running, remove it.`); + let stat: Stats; + try { + stat = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw unreadable((error as Error).message); + } + if (!stat.isFile()) { + throw unreadable(stat.isDirectory() ? 'it is a directory, not a lock file' : stat.isSymbolicLink() ? 'it is a symbolic link, not a lock file' : 'it is not a regular file'); + } try { - const stat = statSync(path); return { ino: stat.ino, raw: readFileSync(path, 'utf8'), ageMs: Date.now() - stat.mtimeMs }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw new Error( - `The plugin installations lock ${path} cannot be read (${(error as Error).message}). ` - + 'If no `os plugin` command is running, remove it.', - ); + throw unreadable((error as Error).message); } } @@ -324,7 +337,8 @@ export const installationsLockTestHooks: { afterStaleJudged?: (lock: string) => * clears at the same time, and only while it is still that file. Answers * `false` when another writer holds the steal lock: it is clearing now, and * the caller waits. A steal lock a dead writer left is cleared here, judged as - * the lock is, so a live writer slow inside its clear keeps it. + * the lock is, so a live writer slow inside its clear keeps it, up to the + * lock's stale bound. */ function clearStaleLock(lock: string, judged: LockFile): boolean { const steal = `${lock}.steal`; @@ -378,20 +392,24 @@ export function withPluginInstallationsLock( for (;;) { mine = createLockFile(lock); if (mine) break; + // Absent: released between the two calls, and tried again like any other + // wait, under the deadline and after the pause. const present = readLockFile(lock); - if (!present) continue; // released between the two calls - const { stale, holder } = judgeLock(present); - if (stale) { + const judged = present ? judgeLock(present) : null; + if (present && judged?.stale) { installationsLockTestHooks.afterStaleJudged?.(lock); if (clearStaleLock(lock, present)) continue; } if (Date.now() >= deadline) { throw new PluginInstallationsLockedError( - stale - ? `The plugin installations file ${file} has a stale lock (${holder}) that another writer was still ` - + `clearing after ${waitMs / 1000}s. If no \`os plugin\` command is running, remove ${lock} and ${lock}.steal.` - : `The plugin installations file ${file} is locked by ${holder}, still running after ` - + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, + !judged + ? `The plugin installations file ${file} could not be locked in ${waitMs / 1000}s: ${lock} was there when ` + + `this writer tried to create it and gone when it read it. If no \`os plugin\` command is running, remove ${lock}.` + : judged.stale + ? `The plugin installations file ${file} has a stale lock (${judged.holder}) that another writer was still ` + + `clearing after ${waitMs / 1000}s. If no \`os plugin\` command is running, remove ${lock} and ${lock}.steal.` + : `The plugin installations file ${file} is locked by ${judged.holder}, still running after ` + + `${waitMs / 1000}s. Let that command finish; if no \`os plugin\` command is running, remove ${lock}.`, ); } sleepSync(50);