Skip to content

Commit d4ff1a0

Browse files
feat(cli, runtime, core): os serve loads the plugins os plugin install installed, re-verified and dispatched by runtime tier; os plugin disable | enable | uninstall (#27123, slice 2 of 3) (#27374)
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Part of #27123 Ruling-ref: 6467932167 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. No key or accepted value of `@objectstack/spec` changes: it changes only the `runtime` tier's `.describe()` text and TSDoc (the generated reference pages carrying it regenerated), its text pin, the liveness `runtime` row, the liveness README's account of that row, and the liveness gate's self-test Metadata-protocol: none Skills-docs: updated here — `content/docs/plugins/index.mdx`, the one page that taught setting `signature` on a plugin object by hand, now shows `recordPluginArtifactVerdict` before `kernel.use`; no skill under `skills/**` teaches the field ## 维护者速读 - **改了什么:** `os serve`(以及经它的 `os dev` / `os start`)启动时会加载 `os plugin install` 装好的插件。每个插件在导入任何代码之前都要重新复核:目录是否正确、所存制品的 sha256、签名及其公钥现在是否仍受信、解包后的文件是否与制品逐字节一致、清单的 id 和版本。复核通过后再按 `runtime` 分派:只有 `node` 在进程内加载,`sandbox`、`worker` 和未声明的一律按名拒绝。插件里对 `@objectstack/*` 的裸导入解析到宿主自己的包。另外:新增 `os plugin disable | enable | uninstall`;安装记录文件加了锁;install 改为先验签名再解压,且解压有上限;core 的 `PluginLoader` 签名检查不再只看格式,必须有宿主记录的验证结论。 - **为什么改:** 这是 #27123 裁决 B′ 的第 2 片(ADR-0025 §3.5 M1)。第 1 片之后插件能装上,但没有任何东西加载它;core 对插件对象自带的 `signature` 只检查格式,等于让插件自己给自己担保。 - **风险与回滚:** 项目没有安装记录文件时,启动路径完全不变:不读库,也不多打印一行。有记录时,任何一个插件出问题(包括读文件抛错、卸载失败、锁超时)都只拒绝那一个,打印 `✗` 行并在横幅上计数,不会中断启动。core 的签名检查对"带签名但没有宿主结论"的插件从放行改成拒绝,已声明为 `Clause-②: no (narrowing)`,带 BREAKING 横幅和 ADR-0087 处置,级别仍是 patch;仓库里没有任何插件设置 `signature`,仓库外的宿主按 changeset 里的迁移说明改。回滚 = revert 本 PR。 - **先看:** `packages/runtime/src/installed-plugin-loader.ts`(复核顺序、拒绝码、宿主包解析);`packages/runtime/src/plugin-installation-store.ts`(锁,以及陈旧锁怎么安全地清);`packages/core/src/plugin-loader.ts` 的 `verifyPluginSignature`;`packages/cli/src/utils/installed-plugins-boot.ts`(serve 的接入点)。 - **两分钟验证:** 运行 `pnpm --filter @objectstack/cli exec vitest run --project integration test/serve-installed-plugins.integration.test.ts`,预期 7 个全绿。测试会启动真实的 `os serve --no-server` 子进程:已签名的 node 插件代码运行了;用 `os plugin build` 从源码构建、导入 `@objectstack/core` 的插件也加载了,且拿到的是宿主那份 core;sandbox 层和被篡改的插件按名拒绝,代码没跑;已禁用的跳过;已卸载的目录被删除。 - **需要你做的:** 需要你批准合并。本 PR 改动 4711 行,超过 3000 行的人工合并线,按 Tier H 落地。上一版留给你定的 liveness README 三处(第 79、302、1010 行),按契约审查的意见已在本 PR 里改掉,不再需要你裁决。core 签名检查的收窄,契约审查判定属于 Clause-② 意义上的收窄,本 PR 已照此声明(理由在 Acceptance notes),请你知悉。 ## Review round 2: what changed Head `af0b3888` → `766b96ff`, one push. Each seat-review item (r2, CHANGES) and each contract-review item (FAIL `6490914228`). **Seat review, must-fix** 1. **A lock path that cannot be read was retried forever.** The lock file reader took any stat or read error for "no lock", so a `<installations>.lock` that was a directory (EEXIST on create, EISDIR on read) or unreadable (EACCES, for example one left by a `sudo os plugin install` under umask 077) made the writer loop with no sleep and no deadline. `os serve` hung at boot while an uninstall was pending, and `install` / `uninstall` hung too. - Now only ENOENT reads as "no lock". Any other failure refuses at once, naming the path: "The plugin installations lock X cannot be read (EISDIR …). If no `os plugin` command is running, remove it." At boot that is `uninstall_failed` for the one plugin, and the next plugin still loads; the commands report it as `install_failed` / `uninstall_failed`. - A release whose own lock can no longer be read leaves it, and what the command did stands. - Tests: a directory and an EACCES lock (the second injected through the `node:fs` seam, since these tests may run as root) are each refused naming the lock in under 2 s, with the function never run and the path left as it was. At boot, an EACCES lock makes the uninstall `uninstall_failed` naming it, and the next plugin loads. **Anti-vacuity:** with the old reader restored, the directory case hangs until it is killed (`timeout 90` → exit 143). **Seat review, should-fix** 2. **The hook's roots are matched by real path.** Node names an importing module by its real path, so an `OS_HOME` reached through a symbolic link (the macOS temp dir is one) never matched a root built with `pathToFileURL(dir)`, and every built plugin failed `import_failed`. The roots now hold both `dir` and `realpathSync(dir)`. 3. **A specifier that climbs out of `@objectstack/` is not redirected.** `@objectstack/../zod/package.json` from the host is any file the host can resolve (measured: it resolves to `node_modules/zod/package.json`). The hook now redirects only a specifier that names a package under `@objectstack/`: no empty, `.` or `..` segment, no `%` and no backslash. Anything else resolves as Node resolves it from the install directory. Tests for 2 and 3 boot the loader in a real Node child process (`node --import tsx`), because the hook acts on Node's own loader, which the test runner's module graph does not go through. An unsigned plugin importing `@objectstack/core`, installed under an `OS_HOME` that is a symbolic link, loads and calls `sha256Hex`. A plugin importing `@objectstack/../zod/package.json` is refused `import_failed`. **Anti-vacuity:** without the real path, the first test fails; with the old `startsWith('@objectstack/')` match, the second plugin loads. 4. **A throw of any value is named.** A module whose top level does `throw undefined` (or a getter that throws `null`) made `(error as Error).message` throw inside the catch, which rejected the whole loader as `loader_failed` and dropped every installed plugin. The loader and the serve wiring now read what was thrown through one guarded helper (`error instanceof Error ? error.message : String(error)`, itself guarded), so the plugin is refused by name and the next one loads. Tests: two `it.each` rows (`throw undefined` → `import_failed`, a getter throwing `null` → `not_a_plugin`), a boot where the next plugin still loads, and, at the serve wiring, a loader throwing an `Error`, `undefined` or `null` (one `loader_failed` line each), a name getter throwing `undefined` and a `kernel.use` throwing `null` (`kernel_refused` each, the next plugin registered). **Anti-vacuity:** with the old message read, the two loader tests fail. **Seat review, nits.** All taken. 5. **The steal lock is judged by its pid**, as the lock is, not by age alone (`INSTALLATIONS_LOCK_STEAL_STALE_MS` is gone). A live writer slow inside its clear keeps its steal lock however long it holds it; one a dead writer left is cleared at once. Tests: a fresh steal lock naming a dead pid is cleared; one naming a live pid and a minute old is kept. 6. **A writer that finds the steal lock held sleeps** between tries (50 ms, the lock's own poll), and refuses at the deadline with its own message: "… has a stale lock (…) that another writer was still clearing after 10s. If no `os plugin` command is running, remove X.lock and X.lock.steal." The live-steal test above counts the judgements in a 300 ms wait (fewer than 20; a spin would make thousands). 7. **The missing pins:** - the artifact size caps: a sparse kept artifact one byte over `MAX_UNPACKED_ARTIFACT_BYTES` is refused `artifact_changed` at boot with no read of it (counted through the `node:fs` seam), and a sparse artifact file of that size is refused `artifact_unreadable` by `os plugin install`; - `loader_failed` at the serve wiring (above), in a new unit test against a stand-in loader and kernel (`installed-plugins-boot.test.ts`, 6 tests, under a second); - `os plugin uninstall`'s record-gone race (`uninstall.test.ts`, 3 tests): a record found before the lock and gone under it is `plugin_not_installed` ("removed while this command ran"), not "Marked"; a busy lock is `installations_locked`, any other failure `uninstall_failed`. The command runs on its own without loading the CLI's oclif config, which cost seconds per run for nothing the test reads. **Contract review (FAIL `6490914228`)** - **② must-fix: the core narrowing is declared.** The changeset and this body now carry `Clause-②: no (narrowing)` at the start of the line, the changeset body gains `**BREAKING** accept-set narrowing at \`kernel.use\` (\`@objectstack/core\`)` and one disposition, `adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader)` with its reason. The Migration paragraph stays; the level stays `patch` with `Metadata-protocol: none`. Run locally on the round's commit: - `check-adr-0087-registration --base origin/main`: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+clause-②-narrowing] not-required (runtime-interface-only) -- verified: packages/core/src/plugin-loader.ts#PluginLoader (class)"; - `check-changeset-no-major --base origin/main`: no `major`; its level axis reads the PR body, so it was run again with this body as the event payload (Verification). - **The narrowing's other carriers.** The `Skills-docs:` line above is new. `content/docs/plugins/index.mdx` was the one page teaching `signature: 'ed25519:…'` on a plugin object, which `kernel.use` now refuses; it now says a `signature` field is not a verification by itself and shows `recordPluginArtifactVerdict` before `kernel.use`. The `signature` field's own TSDoc on `PluginMetadata` says the same. No skill under `skills/**` teaches it (searched). The full dogfood battery the arm owes ran once on this head (Verification). - **Should-fix: the field TSDoc's premise.** It now reads "An artifact `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." The spec changeset's sentence follows it. The pin is unchanged and green. - **The `test-budget:` header** is added from the seat's CI reading of `af0b3888` (Test Core 5/8, job 123319866423: `serve-installed-plugins.integration.test.ts (7 tests) 27083ms`): `// test-budget: 40 — spawns the real producer once per single-cause fixture: …`, 27.08 s × 1.25 rounded up. This round adds no case to that file. - **③.16 (#27114, `json-stdout-purity`)** is the devx holder's; nothing here changes it further. ## Review round 1: what changed Head `a6a7e490` → `af0b3888`, one push. Each seat-review item (r1, CHANGES), each contract-review item (PASS `6487771335`), and the two CI reds on `a6a7e490`. **Seat review, must-fix** 1. **The stale-lock steal race** (store). A writer that judged the lock stale used to unlink it by path, holding nothing, so two writers seeing the same stale lock could interleave: B unlinked the fresh lock A had just taken, and both ran the read-modify-write. - Now a stale lock is cleared only while holding `<lock>.steal` (created `wx`), and only if the lock file is still the one judged stale: same inode and same bytes. A steal lock left by a writer that died mid-clear is cleared the same way (by its age in round 1; by its pid since round 2, nit 5). - Every lock carries `{ pid, thread, token, at }` with a fresh `randomUUID` token, so two locks never share bytes. Release removes the lock only while it is still the holder's own file. - Test: `installationsLockTestHooks.afterStaleJudged` (a module seam, not exported from the package index) runs in the window between "judged stale" and "cleared". In that window, writer A's fresh lock replaces the stale one. Writer B then throws `locked by process <A>`, does not run its function, and leaves A's lock byte for byte, with no `.steal` file left behind. **Anti-vacuity:** the same test against the old unlink-by-path clear fails. Two more cases: a stale `.steal` is cleared, and a holder's release does not remove a lock that replaced its own. 2. **Per-record throws abort the boot.** Each one is now a named refusal of that one plugin, and the next plugin still loads: - a throw while reading the install directory, i.e. EACCES on the walk or a file vanishing between the walk and its read → `install_unreadable`; - a failed uninstall (`rmSync`, or the installations lock held past its wait) → `uninstall_failed`, with the record still marked so the next boot retries it; - a loader that throws outright, at the serve wiring → one `loader_failed` line, and the boot goes on without installed plugins; - a plugin whose `name` cannot be read → `kernel_refused`; - an installations-file entry that is not a record → `records_unreadable`. **The corrupt disable-state file was worse than a throw.** The package disable-state store reads a corrupt or unreadable file as `{}` (deliberately, for `AppPlugin`), so the loader took it for "nothing disabled" and **loaded plugins that had been disabled**. Now the installed-plugin path reads that file strictly (`loadDisabledPackageIdsStrict`, new in `package-state-store.ts`): - the boot refuses each plugin `disabled_state_unreadable`; - `os plugin disable | enable` refuse `disable_state_failed` and write nothing. Before, a disable rewrote the file holding one id, dropping every other disable. `AppPlugin`'s lenient read is unchanged. Tests: the fault cases run against the real loader with `node:fs` faults injected per call (the slice-1 `plugin-install.test.ts` precedent). They cover a listing that fails, a file that vanishes, an `rmSync` that fails (the next boot then finishes), and a live lock held past a 200 ms test wait (`installationsLockTestHooks.waitMs`). The corrupt disable-state file is a real file, both at the boot and through both commands. **Seat review, should-fix** 3. **Uninstall acted on an unlocked snapshot.** Now it runs under the installations lock and re-reads the record first. It removes and drops only while the record is still `uninstall-pending` with the same `dir` and artifact sha. Otherwise the result is `record_changed`, and nothing is removed or loaded. Test: an id installed again inside the window between the records read and the uninstall keeps its fresh directory and record, and the next boot loads it. 4. **What the import resolves.** Confirmed: a plugin built by `os plugin build` failed. The new end-to-end case builds `com.acme.built` with `os plugin build` from a TypeScript source that imports `@objectstack/core`, signs it, installs it and boots. **Without the fix** it was refused: `import_failed: … Cannot find package '@objectstack/core' imported from …/plugins/env_local/com.acme.built/1.0.0/dist/index.mjs` (run with the one wiring line removed, then restored). The fix: a resolve hook registered with `module.register` (a `data:` module, no file on disk). It resolves a bare `@objectstack/*` specifier imported from inside a verified install directory as if the host file passed in (`resolveHostPackagesFrom: import.meta.url` in `installed-plugins-boot.ts`) had imported it. With it, the plugin loads, and its `init` reads, through its own import of `@objectstack/core`, the verdict the host recorded. That proves it got the host's module instance, not a copy. The resolution boundary and the lazy-import window are under Known limitations. 5. **The core narrowing is now stated.** The changeset carries a **Migration** paragraph: - FROM: setting `plugin.signature` and calling `kernel.use`; - TO: `recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 })` after your own artifact verification, or leaving `signature` unset. The Clause-② weighing is under Acceptance notes. 6. **"Nothing of it runs" was false after the import.** It is reworded in `installed-plugins-boot.ts`, the loader header and the changeset. A plugin refused before its import had nothing 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. 7. **Off-surface files** are listed under Acceptance notes, with why each was needed. **Seat review, nits.** All taken. 8. The overstated binding is corrected everywhere: the signature covers the bytes, and what ties them to the record's id and version is the manifest comparison (`manifest_mismatch`). 9. The own-pid stale clause now compares the thread id too. A lock naming another `worker_thread` of the same process is a live holder (test added). 10. The verdict module and the loader message no longer say a plugin object "cannot vouch for itself". They say the check stops a `signature` field from reading as verified, and that it is no boundary against in-process code, which can call the public `recordPluginArtifactVerdict`. 11. **The one-way name guard is kept and documented.** A later host plugin of the same name supersedes the installed one with the kernel's warning, so the host wins in both directions. Moving the registration was not worth its blast radius (see Known limitations). 12. **`readTar`, and the reads before verification:** - `readTar` honours the typeflag: regular files only, directories skipped, and a link, device, pax or GNU extension header refused by name. - It joins a POSIX `prefix` onto the name, refuses a cut-short entry and an unreadable size, and returns views instead of copies. The inflate's input is no longer copied either. - The compressed artifact is size-checked before it is read, at install (`artifact_unreadable`) and at boot (`artifact_changed`). - Tests cover the prefix, the directory skip, the views, GNU-format prefix bytes, the four refused typeflags, truncation, and a bad size. 13. `os plugin uninstall` no longer reports "Marked" from a stale record. A record gone under the lock is `plugin_not_installed` ("removed while this command ran"). The lock error is matched by type (`PluginInstallationsLockedError`, exported), and the commands report it as `installations_locked`. It carries no `code` field: a `code` stamped in `@objectstack/runtime`'s `dist` must be an ADR-0112 ledger code (`check:dispatcher-error-vocabulary`, which flagged the first cut of this round), and this is a CLI refusal, not a wire code. Any other failure is `install_failed` / `uninstall_failed`, not mislabelled as a busy lock. **Contract review** 1. Both `runtime` describes now say "at boot for a plugin `os plugin install` installed". The pin probe `SAYS_BOOT_LOADS_ONLY_NODE` moved with them, and the other probes still match (the pin is green). The five reference pages were regenerated. 2. The field TSDoc now bridges the two sentences: an unset tier declares a pure-metadata package, and on an artifact `os plugin install` installs (which carries code) it is an omission, refused `runtime_undeclared`. 3. `packages/spec/liveness/README.md` L79, L302 and L1010 are taken in this PR. The rows stay as the historical worked example, each says the row flipped to `live` on #27123, and each says no row holds `live-elsewhere` today. 4. **The `test-budget:` header is not added yet.** Its precondition, a CI reading of the file, is not readable from this seat. The merged timing table in the `Test Core` log lists only the slowest 20 files, cut at 57.88 s on `a6a7e490`, and this file was not among them. The per-shard capture artifacts download from a blob host this container's proxy refuses. The file also changed in this push (a build and a sixth install), so it needs a fresh reading anyway. Round 2 adds it from the seat's reading. **The two CI reds on `a6a7e490`** (Test Core 2/8 and 6/8). Both came from this PR's liveness flip. The `runtime` row was the only `live-elsewhere` row, and two suites asserted on it in the shipped ledger: - **2/8, `@objectstack/spec` `scripts/liveness/check-liveness.test.ts`:** - Problem: the `live-elsewhere` criteria block mutates `manifest/runtime` and expected it to carry the verdict. - Fix: each case now plants the row as it was before #27123, in its copy of the ledgers. The planted row is attested today, so it stays inside the 180 d window, and the copy's `manifest` count shard is moved by that one row. - The shipped-ledger control stays: green, with the population line printed. A new planted control asserts the row is counted and satisfied. - **6/8, `@objectstack/lint` `src/lint-liveness-properties.test.ts`.** Four real-ledger assertions needed a shipped `live-elsewhere` row: - The shipped half now pins the flip: the row is `live` and not warned, with a guard that the manifest ledger was read. - "The fuse, lit" is shown on a planted `live-elsewhere` row with no opt-in, through the walk itself. - COVERAGE now runs over the union of the shipped statuses and the graded verdicts, so a status no row carries today is still answered. - The grading pin uses a row of the old shape. - The tracker-id pin no longer requires a `live-elsewhere` row to exist. My earlier local run covered only spec's `src/kernel`; this round ran the full spec `test` project and the lint suite. ## What changes **The boot-time loader** (`packages/runtime/src/installed-plugin-loader.ts`, new; `loadInstalledPlugins`, `verifyInstalledPlugins`, `dispatchPluginRuntime`). It reads this project's installation records (`loadPluginInstallations(env)`). For each record, in this order, the first failing check refuses that plugin by name, before anything of it is imported: 0. `install_dir_unexpected`: the record's `dir` must be the one `pluginInstallDir(env, id, version)` derives. This runs before the uninstall and disable branches, so nothing is read from, or removed under, any other path. 1. **Disabled** (`os plugin disable`): skipped before anything is read. This is not a refusal. A disable-state file that is there and cannot be read refuses each plugin instead (`disabled_state_unreadable`). 2. **The kept artifact**: `install_dir_missing`, and `artifact_changed` when `sha256(artifact.osplugin)` is not the recorded sha, or the file is larger than any artifact may unpack to (it is then not read). This closes slice 1's shared-ownership limitation: a tree another project replaced is refused here. 3. **The signature, over the raw kept bytes, before anything is inflated**: - `signature_missing`, `signature_malformed`; - `signature_key_mismatch` when the `.sig` names a key id other than the record's; - the key resolved in `sys_trusted_key`: `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`, and `trusted_key_changed` when the fingerprint is not the one the install verified; - then `verifyPluginArtifact` with `requirePlatform: false`: `signature_invalid`. The signature covers the bytes only. `package_id` and `version` feed only the platform counter-signature, which is not required here; what ties the bytes to the record's id and version is check 4's manifest comparison. A key revoked since install therefore refuses the plugin at its next boot. A key read that throws refuses every signed record `trusted_keys_unreadable`; unsigned records are still checked. A `trustUnsigned` record skips this step and loads with a warning on every boot; an unsigned record without that flag is refused as `unsigned_without_override`. 4. **The tree is the artifact**: the bounded `readTarGz` (`artifact_unreadable`). Every file in the directory must be byte-equal to its entry, with no extra file and no symbolic link beside the two kept files (`tree_changed`). The manifest's id and version must be the record's (`manifest_mismatch`). 5. **The tier dispatch point** (`dispatchPluginRuntime`, ADR-0025 §3.6): - `node` imports `<dir>/<manifest.main>` (default `dist/index.mjs`, which must be a file of the artifact, else `entry_missing`) in process through `materializeStackPlugin`, the rule a stack's `plugins` entries take; - `sandbox` and `worker` are refused as `runtime_not_dispatched` until M3, with no in-process fallback; - a manifest with no tier is refused as `runtime_undeclared` (default-deny). A check that throws on one record (a file it cannot read, one that vanished mid-walk) refuses that record `install_unreadable`; one installation never fails the boot. **After the import.** `import_failed`, `not_a_plugin` (a plain bundle is wrapped in `AppPlugin`; reading the export throwing is `not_a_plugin` too) and `signature_conflict` come after the entry module's top-level code ran: the plugin is not registered, but its module was evaluated. `signature_conflict` covers three cases: an unsigned install whose module carries a `signature`, a module that carries a signature other than the verified one, or a frozen plugin object the verified signature cannot be pinned to. **What the import resolves.** `os plugin build` leaves `@objectstack/*` external, and an install directory under `OS_HOME` has no `node_modules` holding them. So when the host passes `resolveHostPackagesFrom` (a file URL of its own), the loader registers a resolve hook once per (host, install directory) with `module.register`. A bare `@objectstack/*` specifier imported from inside a verified install directory then resolves as if the host file had imported it, which gives the same module instances the host's kernel runs on. An install directory is matched by its real path too (Node names an importing module by it), so an `OS_HOME` behind a symbolic link resolves. Only a specifier that names a package under `@objectstack/` is redirected: one with an empty, `.` or `..` segment, a `%` or a backslash is not. Every other specifier resolves as Node resolves it. `loadInstalledPlugins` also finishes uninstalls. For a record marked `uninstall-pending`, it works under the installations lock and re-reads the record. Only while the record is still the marked one, with the same directory and sha, does it remove the directory (and the empty parent) and drop the record and its disable flag. The directory is kept if another project's installations file for the same home and environment still names it. Otherwise it answers `record_changed`, and a removal that throws is `uninstall_failed`. **Serve wiring** (`packages/cli/src/commands/serve.ts` + `packages/cli/src/utils/installed-plugins-boot.ts`, new). `registerInstalledPlugins` is called after the config `plugins` loop and before bootstrap, because `ObjectKernel.use` throws after bootstrap. When there is no installations file it returns `null` and reads nothing, so the boot is what it was. Otherwise it does the following: - It reads the trusted keys once, through `readTrustedKeys` (below), against `OS_DATABASE_URL` or the project database, and only when a signed record needs them. - It passes its own `import.meta.url` as `resolveHostPackagesFrom`. - It `kernel.use`s each plugin that passed, so dependency order and health checks stay the kernel's. - Host-side codes: - `plugin_name_taken`, when `kernel.hasPlugin(name)`: the kernel's same-name rule would let an installed plugin replace one the host already registered; - `kernel_refused`, when `kernel.use` throws or the plugin's name cannot be read; - `loader_failed`, one line, when the loader itself throws; the boot goes on without installed plugins. - It prints one red `✗ Refused installed plugin id@version (code): message` line per refusal, plus dim lines for the disabled and uninstalled ones, and the banner gains `⚠ N installed plugins were refused and not loaded` (`ServerReadyOptions.installedPluginsRefused`). A refused plugin does not stop the boot, following the config `plugins` loop's precedent. `os dev` and `os start` reach this through serve. **The kernel's signature check is real** (`packages/core/src/plugin-loader.ts` `verifyPluginSignature` + `packages/core/src/security/plugin-artifact-verdict.ts`, new). - Before: any well-formed `signature` on a plugin object passed. - Now: the host that verified the artifact records a frozen verdict `{ signature, keyId, fingerprint, artifactSha256 }` against the plugin object (`recordPluginArtifactVerdict`, a `WeakMap`). The loader refuses a plugin whose `signature` has no verdict or a different one: "…carries a signature (keyId=…) that no host verified against its artifact … a `signature` field alone does not make a plugin signed." - What this stops is a `signature` field reading as verified. It is not a boundary against code already running in the process: `recordPluginArtifactVerdict` is public, and in-process code (a `node`-tier plugin's) can call it. - A malformed signature is still refused first. A plugin without a signature is unaffected. No plugin in the repo sets `signature` (measured). - The narrowing is declared: `Clause-②: no (narrowing)`, the BREAKING banner, an ADR-0087 `runtime-interface-only` disposition and the Migration paragraph. `PluginMetadata.signature`'s TSDoc and `content/docs/plugins/index.mdx` no longer teach setting the field by hand. **The archive reader is in core** (`packages/core/src/security/plugin-artifact-archive.ts`, new). `readTar`, `readTarGz` and `sha256Hex` moved from `packages/cli/src/utils/osplugin.ts`, which re-exports them, so CLI callers do not change. - The inflate is bounded by `gunzipSync`'s `maxOutputLength` (`MAX_UNPACKED_ARTIFACT_BYTES`, 256 MiB). An artifact past the cap is refused as "the artifact inflates past N bytes, the most an .osplugin may unpack to" once the output reaches the cap. This closes slice 1's "no size cap" limitation. - `readTar` takes regular files (typeflag `0` / NUL) and skips directories (`5`). Any other entry is refused, naming it: a link, a device, or a pax or GNU extension header. - It joins a POSIX `prefix` (magic `ustar\0` only) onto the name, and refuses a cut-short entry and an unreadable size. - Neither the input nor the files are copied: a reader holds the compressed bytes plus the one inflated tar. **`os plugin install`** (`packages/cli/src/commands/plugin/install.ts`) now runs in this order: 1. read the bytes, refusing an artifact file larger than `MAX_UNPACKED_ARTIFACT_BYTES` before reading it (`artifact_unreadable`), and the `.sig`; 2. `signature_missing` unless `--trust-unsigned`, then `signature_malformed`; 3. resolve the key through `readTrustedKeys`; 4. the key verdicts; 5. `verifyPublisherSignature` over the raw bytes; 6. only then `readTarGz` (bounded) and the slice-1 entry, manifest and integrity checks; 7. records, `install_dir_taken`, materialize and record, all inside `withPluginInstallationsLock`. A busy lock is refused as `installations_locked` (matched by type, `PluginInstallationsLockedError`); any other failure of the lock itself is `install_failed`. All slice-1 refusal codes are unchanged; only their order moved, so a bad signature is now named before a bad archive. An artifact whose tier the boot will refuse installs with a warning: "X is installed, and the next boot will refuse to load it (runtime_not_dispatched): …". The `--json` output gains `boot: { loads, error }`. The "`os serve` does not load installed plugins yet" line is gone. **`readTrustedKeys`** (`packages/cli/src/utils/plugin-trust-read.ts`, new) is the one read of the trusted keys, shared by install and the boot loader. It is a read-only one-shot `bootSchemaStack` (`deferSchemaDdl` + `readOnlyProbe`) for all key ids at once. A table the boot measured absent answers every id `unknown`. It replaces install's private resolver. **New commands** (`packages/cli/src/commands/plugin/{disable,enable,uninstall}.ts`), each with `--json`: - `os plugin disable ID` / `os plugin enable ID` use the package disable-state store under the plugin environment id (`env_local` unless `OS_ENVIRONMENT_ID`; the store's own default is `default`, so the commands pass it explicitly). They write nothing when the state is already the one asked for (`changed: false`). On a disable-state file they cannot read they refuse `disable_state_failed` and write nothing. - `os plugin uninstall ID` marks the record `uninstall-pending` under the lock; the next boot does the removal. A record removed while the command ran is `plugin_not_installed`. Each refuses by name an id this project has not installed (`plugin_not_installed`, naming what is installed, or "nothing is installed") and an unreadable records file (`record_unreadable`). `disable` / `enable` refuse an id marked for uninstall (`plugin_uninstall_pending`); a second `uninstall` changes nothing; for `uninstall`, a busy lock is `installations_locked` and any other failure `uninstall_failed`. Installing the plugin again before the next boot cancels the uninstall. **The installations-file lock** (`packages/runtime/src/plugin-installation-store.ts`). I chose a lock over append-only, so slice 1's file format stays and nothing migrates. - `withPluginInstallationsLock(env, fn)` creates `<installations file>.lock` with `open(…, 'wx')`, holding `{ pid, thread, token, at }`. - It is re-entrant within one thread (`recordPluginInstallation` takes it inside install's own hold), and released in `finally`, only while the lock file is still the holder's. - Every read-modify-write takes it: install, uninstall mark, uninstall finish, record drop. **How a stale lock from a crashed process is cleared:** the next writer clears it, with no manual step, when any of these holds: - the pid it names is not a live process on this host (`process.kill(pid, 0)`, where `EPERM` counts as alive); - it is older than 5 minutes (`INSTALLATIONS_LOCK_STALE_MS`), which covers a reused pid; - it names the writer's own pid AND thread while the writer holds nothing (another `worker_thread` of the same process is a live holder); - it cannot be parsed and is older than one second, so a lock caught mid-write by its creator is not taken for stale. The clear holds `<lock>.steal` and removes the lock only if it is still the file judged stale (same inode, same bytes), so two writers judging the same lock stale cannot both take it. A steal lock is judged as the lock is (its pid, its age): a dead writer's is cleared, and a live writer's is waited for, with a sleep between tries. A lock path that is there and cannot be read (a directory, an unreadable file) is refused at once, naming it. Otherwise the writer polls for up to 10 s (`INSTALLATIONS_LOCK_WAIT_MS`), then throws `PluginInstallationsLockedError`, which the commands report as `installations_locked`: "The plugin installations file X is locked by process N (since T), still running after 10s. Let that command finish; if no `os plugin` command is running, remove X.lock." **Self-descriptions** (`packages/spec`): - `packages/spec/src/kernel/manifest.zod.ts`: the `.describe()` and TSDoc of `PluginRuntimeSchema` and the manifest's `runtime` field now say the tier is enforced at the publish gate **and at boot for a plugin `os plugin install` installed**. Only `node` is loaded, in process and not isolated; `sandbox` and `worker` are refused until M3. The field TSDoc adds that an unset tier on an artifact `os plugin build` produces is an omission, and that the loader refuses an installed plugin with no tier either way. The five generated reference pages that carry those strings were regenerated (`check:generated --fix`): `content/docs/references/kernel/{manifest,package-registry}.mdx` and `content/docs/references/api/{package-api,package-api-assembled,protocol}.mdx`. - `packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts` pins the new text and pins the retracted "load-side enforcement is NOT implemented" absent. - `packages/spec/liveness/manifest.json`: the `runtime` row moves `live-elsewhere` → `live` (`verifiedAt` 2026-10-29, `in-repo`, evidence `installed-plugin-loader.ts#dispatchPluginRuntime` + `install.ts#PluginInstall`). The old note had named exactly this flip as the open half. The cloud half is kept as attributed history and was not re-read. `liveness/state-counts/manifest.md` was regenerated. - `packages/spec/liveness/README.md` L79 / L302 / L1010: the row stays as the worked example of `live-elsewhere`, says it flipped to `live` on #27123, and says no row holds that status today. - `packages/spec/scripts/liveness/check-liveness.test.ts`: the `live-elsewhere` criteria cases plant the row (CI red 2/8, above). - `packages/cli/README.md` gains the `disable` / `enable` / `uninstall` rows, and its paragraph describes boot re-verification and the tier rule. `content/docs/protocol/kernel/lifecycle.mdx` updates the top callout, the *Plugin Installation* callout, *What an author does today*, and the Summary line. `packages/cli/src/commands/plugin/trust/revoke.ts` now says the next boot refuses plugins signed by the revoked key. **Changesets:** - `.changeset/27123-boot-time-plugin-loader.md` (`@objectstack/cli`, `@objectstack/runtime`, `@objectstack/core`, patch), with the **Migration** paragraph for `@objectstack/core`; - `.changeset/27123-runtime-tier-enforced-at-boot.md` (`@objectstack/spec`, patch, prose only). Both carry `Metadata-protocol: none`. The first carries `Clause-②: no (narrowing)`, the `**BREAKING**` banner and `adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader)`; the second carries `Clause-②: no`. ## Tests - `packages/runtime/src/installed-plugin-loader.test.ts` (55) runs on a real tmp `OS_HOME`, with artifacts built and signed in the test (its own tar.gz writer, a real Ed25519 key): - loading paths: a signed `node` plugin loads and its `init` runs; an unsigned `--trust-unsigned` install loads; a plain bundle is wrapped; with no installations file, none is created; - every refusal code in order, each asserting that the plugin's module was never imported (`imported == []`); - `records_unreadable`; one refused record does not stop the next from loading; - the import outcomes (`import_failed`, `not_a_plugin` incl. an export whose read throws, three `signature_conflict` shapes); - disable; uninstall removing the directory; the shared-directory guard keeping it for another project; a foreign `dir` never removed; and `verifyInstalledPlugins` importing nothing; - **round 1:** the per-record throws (`install_unreadable` on a failed listing and on a vanished file, `disabled_state_unreadable` on a corrupt file and the write refused over it, `uninstall_failed` on a failed removal then finished by the next boot, `uninstall_failed` on a lock held past the wait) and `record_changed`; - **round 2:** a top-level `throw undefined` and a getter throwing `null`, each refused by name with the next plugin loading; an unreadable lock at boot (`uninstall_failed`, at once); the boot's artifact size cap (`artifact_changed`, never read); and, in a real Node child process, the symlinked `OS_HOME` and the climbing specifier. - `packages/runtime/src/plugin-installation-store.lock.test.ts` (19): - a live holder (the test's parent pid) makes a writer wait, then time out naming the holder and the lock path, as a `PluginInstallationsLockedError`; - the stale shapes (dead pid, aged, own pid and thread, aged unparseable) are cleared; a fresh unparseable lock and one naming another thread of this process are not; - **round 1:** the steal race (above, with its anti-vacuity control), a stale `.steal` cleared, a release that leaves a replaced lock alone; - **round 2:** a dead writer's steal lock cleared by its pid, a live writer's kept and waited for without spinning, and a directory or an unreadable lock refused at once by name; - re-entrancy, and release on throw; - two child processes, started on a shared barrier, each record 40 installations under the lock, and all 80 land. **Anti-vacuity control:** the same test against the store with the lock removed lost records (54/80 and 49/80 on two runs, measured on `a6a7e490`), so the test fails without the lock; - mark / drop / disable, and `otherProjectsInstallationDirs`. - `packages/core/src/security/plugin-artifact-verdict.test.ts` (17): - the verdict hook through a real `PluginLoader` and `ObjectKernel.use`: a recorded verdict passes; no verdict, a different signature, and a malformed signature are refused; an unsigned plugin is unaffected; - the inflate cap: 4 MiB of zeros under a 1 MiB cap is refused by name, with the control that the default cap reads it; non-gzip input throws the inflate's own error; - **round 1:** `readTar` reads regular files only (above). - `packages/cli/src/commands/plugin/install.integration.test.ts` (22): - verify-before-inflate: a non-gzip artifact with an untrusted signature is refused `signature_key_untrusted` (it never reaches gunzip), and the same bytes with a trusted key and a valid signature are refused `artifact_unreadable`; - the `sandbox` tier installs with the boot warning and a `--json` `boot` payload; - the lifecycle commands: `plugin_not_installed`; disable / enable toggling, with `changed: false` on a repeat; uninstall marking `uninstall-pending` and removing nothing yet, a repeat changing nothing, `disable` then refused as `plugin_uninstall_pending`, and a re-install before the boot restoring `installed`; - **round 2:** an artifact file one byte over the unpack cap, refused `artifact_unreadable` unread (a sparse file). - `packages/cli/src/utils/installed-plugins-boot.test.ts` (6, new, round 2) runs the serve wiring against a stand-in loader and kernel: a loader throwing an `Error`, `undefined` or `null` is one `loader_failed` line; a name getter throwing `undefined` and a `kernel.use` throwing `null` are `kernel_refused` by name, and the next plugin is registered; a taken name is `plugin_name_taken`; no installations file reads nothing. - `packages/cli/src/commands/plugin/uninstall.test.ts` (3, new, round 2): the record-gone race is `plugin_not_installed`, a busy lock `installations_locked`, any other failure `uninstall_failed`. - `packages/cli/test/serve-installed-plugins.integration.test.ts` (7, new) runs a real `os serve objectstack.config.ts --no-server` child process (`OS_MIGRATE_AND_EXIT=1`). Six plugins are installed in process: a signed `node` plugin, a `sandbox` one, a tampered tree, a disabled one, one marked for uninstall, and **`com.acme.built`, built by `os plugin build` from a TypeScript source importing `@objectstack/core`**. Marker files written by each plugin's `init` show whose code ran: - only the `node` plugins ran; - the built plugin's marker holds the artifact sha256 of the verdict it read through its own `@objectstack/core` import, equal to its record's; - `runtime_not_dispatched` and `tree_changed` are printed as `✗` lines by name, and neither plugin ran; - the disabled plugin is reported as disabled and did not run; - the uninstalled directory is gone and its record dropped. Both boots exit 0 through `Migration complete` (the control). A second boot, after `enable` on the disabled plugin and `uninstall` on the loaded one, runs only the re-enabled plugin among the five, and removes the other, leaving exactly four records. Last, `disable` and `enable` refuse a corrupt disable-state file `disable_state_failed` and leave it byte for byte. The file carries a measured `test-budget: 40` header (round 2). - `packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts`: the `bootSchemaStack` caller is now `utils/plugin-trust-read.ts`, which replaces `commands/plugin/install.ts`, run through `os plugin install` with the same no-write mode. The `[#27123]` anti-vacuity case uses the new key. - `packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts` (4): the new probes, and the retracted sentence absent. - `packages/spec/scripts/liveness/check-liveness.test.ts` and `packages/lint/src/lint-liveness-properties.test.ts`: the CI-red fixes above. ## Acceptance notes - **Size: Tier H.** 4711 changed lines (44 files, +4319 / −392, generated included), against the 3000 human-merge threshold. Most of the growth in rounds 1 and 2 is tests. - **Clause-② on the core narrowing: `no (narrowing)`, per the contract review.** Round 1 weighed it as `no`, reading Clause ② as the authorable accept set. The contract review (`6490914228` ②) read it as the published-surface question it is (`check-changeset-no-major` L914–L922: "does this diff widen or narrow a published accept set or public surface"), and `kernel.use`'s accept set for a plugin carrying `signature` is narrowed for every out-of-repo host. So the line carries the arm, the changeset carries the BREAKING banner and the `runtime-interface-only` disposition, and the level stays `patch` under `Metadata-protocol: none` (the same docblock: a non-protocol narrowing "ships `patch` and keeps its BREAKING banner and its ADR-0087 disposition unchanged"). In substance the narrowing makes ADR-0025 §3.7's load-side check real; no plugin in this repo sets the field. - **Off-surface files (deviations from the Claim's surface), each named with why:** - `packages/cli/src/utils/installed-plugins-boot.ts` (new): serve's wiring, kept out of the 4700-line `serve.ts` so the call site is twelve lines; it holds the key read, the host codes and the printing. - `packages/cli/src/utils/format.ts`: `ServerReadyOptions.installedPluginsRefused` and its banner line. - `packages/cli/src/utils/plugin-install.ts`: `pluginEnvironmentId()` and `findPluginInstallation()`, shared by the three new commands. - `packages/core/src/security/plugin-artifact-verdict.ts` (new) and `packages/core/src/security/index.ts`: the verdict the kernel's loader reads, and the exports of it and of the archive reader. - `packages/runtime/src/index.ts`: the loader, the store's new functions, `PluginInstallationsLockedError` and the lock constants. - **Round 1 adds** `packages/runtime/src/package-state-store.ts` (`loadDisabledPackageIdsStrict`, for the fail-open disable read), and **the CI-red fixes** in `packages/spec/scripts/liveness/check-liveness.test.ts` and `packages/lint/src/lint-liveness-properties.test.ts`. The fixes come under the seat's authorization to take derivative work that blocks this card (04:21Z); they change test fixtures only, and no gate's behaviour. Also in round 1: `packages/spec/liveness/README.md`, taken in at the contract review's request. - **Round 2 adds** `content/docs/plugins/index.mdx` (the one page that taught the narrowed field, for the `Skills-docs:` line) and two unit-test files for the seat's nit 7, `packages/cli/src/utils/installed-plugins-boot.test.ts` and `packages/cli/src/commands/plugin/uninstall.test.ts`. - **Deviations from the posted plan**, each inside the Claim's surface: - The trust read became a shared util (`plugin-trust-read.ts`) rather than a second private copy in serve. The one-shot family pin's `CALLERS` key moved there from `commands/plugin/install.ts`, still run through `os plugin install`, so no new command is needed for the pin. - The host-side codes (`plugin_name_taken`, `kernel_refused`, `loader_failed`), `signature_conflict`, and round 1's `install_unreadable`, `disabled_state_unreadable`, `uninstall_failed`, `record_changed` and `disable_state_failed` are new, not in the plan. - The banner counts only refusals; loaded plugins appear in its plugin list like any other. - The stale-lock rule has the four clauses above and the steal lock, not only "pid dead". - Install verifies with `verifyPublisherSignature` over the raw bytes, because the manifest's id and version are not read yet at that point. The boot loader uses `verifyPluginArtifact` over the kept bytes, and binds them to the record's id and version through the manifest comparison. - The host-package resolve hook (round 1, should-fix 4). - **Not in this slice**, per the Go: the i18n-extract config for the deployment-level objects; card item 4 (`manifest-deps` retirement, native-addon refusal) and item 6 (the cloud `connector-stripe` e2e) go to slice 3. No `os plugin build` edit, no `docs/adr/**`, nothing read or edited in objectstack-ai/cloud. - **`packages/spec` reach:** the describe text and TSDoc of the `runtime` tier, its pin, the liveness grade (`manifest.json` + regenerated `state-counts/manifest.md`), the liveness README's three passages about that row, and the liveness gate's self-test fixtures. Nothing else under `packages/spec` changed. - **Metadata-protocol: none.** No key, accepted value, metadata type or export of `@objectstack/spec` changes: `node`, `sandbox` and `worker` still parse, and an unset tier is still legal at parse time. What changes is what the local boot does with them. ## Known limitations - **The resolution boundary.** Only a bare `@objectstack/*` specifier that names a package (no empty, `.` or `..` segment, no `%` or backslash), imported from inside a verified install directory (matched by its path and its real path), is redirected to the host's packages. Every other bare specifier resolves as Node resolves it from that directory: under `OS_HOME` that normally finds nothing, so a plugin that leaves any other dependency external fails `import_failed` by name (`os plugin build`'s `bundled` packaging bundles them). The hook is a `module.register` loader hook; on a runtime without it, registration is skipped and such an import fails the same way. - **The verify-to-import window.** The tree is verified, then imported. A file the plugin imports lazily, after boot, is read from disk when it is imported, after this check. Anyone who can write the install directory between the check and that read can change what runs; the directory is under the user's own `OS_HOME`, which this design trusts as much as the code it runs. - **The name guard is one-way.** An installed plugin never supersedes a plugin the boot registered before it (`plugin_name_taken`). A host plugin registered after it, under the same name, supersedes the installed one with the kernel's warning, so the host wins either way. Making that a refusal too means moving the installed-plugin registration after every host registration in `serve.ts`, which is not worth its blast radius in this slice. - **`node` is not isolation.** An installed `node` plugin is author code in the host process with a full `PluginContext`. The signature proves who built it, not what it does, and in-process code can call `recordPluginArtifactVerdict` itself. `sandbox` and `worker` are refused rather than run, until M3. - **The lock is per host.** Liveness is `process.kill(pid, 0)` on this machine. An `OS_HOME` shared across machines over a network filesystem could see a remote live holder's pid as dead here and clear its lock. The documented layout is one machine. - **The lock's wait blocks the thread** (`Atomics.wait`, up to 10 s, per uninstall finished at boot too). The callers are one-shot CLI commands and the boot before the kernel starts. - **Memory while reading an artifact** is the compressed bytes plus one inflated tar, at most 256 MiB + the cap on the compressed file. - **The verdict lives in one module instance of `@objectstack/core`.** The resolve hook gives an installed plugin the host's instance, and the serve test proves it for the built plugin. A host that loaded two copies of core itself would have its own loader refuse what the other verified, which fails closed, as `kernel_refused` by name. - **Uninstall removes the directory at the next boot**, not at the command. A project that never boots again keeps the bytes; its record says `uninstall-pending`. ## Verification At head `766b96ff` (base `origin/main` `10f27b65`; `origin/main` is now `7614f8f0`, 41 commits on, and merges without conflict. None of those commits adds a `live-elsewhere` row to any ledger). **Round 2, under `scripts/pm/os-verify-lock.sh`** (slot `issue-27123`, `NODE_OPTIONS=--max-old-space-size=4096`, `--maxWorkers=2`), run one after another on the round's commit. Every VERDICT was `command-exit 0`: - `@objectstack/core` build and `@objectstack/runtime` build; - `@objectstack/runtime` test: Test Files 473/473, Tests 7739 passed, 19 skipped (round 1: 7729; the 10 are the loader's 7 and the lock's 3); - `@objectstack/core` test: Test Files 125/125, Tests 2825/2825; - `@objectstack/spec` `plugin-runtime-tier-truthful-text`: Tests 4/4; - cli `--project unit`: Test Files 348/348, Tests 5047/5047 (round 1: 346 and 5038; the difference is the two new files and their 9 tests, and the tiers partition pin is green with them); - cli `--project integration` on `install.integration`, `serve-installed-plugins.integration` and `schema-migrate.one-shot-family.integration`: Test Files 3/3, Tests 153/153; - `typecheck` of runtime, cli and core (each with `check:test-typecheck`): clean, each VERDICT `command-exit 0` and `check:test-typecheck` OK against its ledger. Round 1's full runs at `af0b3888` (lint Tests 7570/7570; spec Tests 22358 passed, 1 todo; cli `--project integration` Tests 1547 passed, 9 skipped) are not repeated: round 2 changes no spec key or schema, no lint source, and no cli integration file but the two run above. **The dogfood battery the narrowing arm owes**, once on this head, with no `OS_TEST_SHARD` and no path: `pnpm --filter '@objectstack/dogfood^...' build` (VERDICT `command-exit 0`), then under the lock `pnpm --filter @objectstack/dogfood test`: VERDICT `command-exit 0` (held the lock 25m20s), Test Files 348 passed, 3 skipped (351), Tests 2806 passed, 20 skipped (2826), no failure. **Anti-vacuity, each run once and restored** (round 2's; round 1's stand): - The old lock reader (any error read as "no lock"): the directory-lock test hangs until killed (`timeout 90` → exit 143). - The hook without the real path: the symlinked-`OS_HOME` test fails. - The hook with the old `startsWith('@objectstack/')` match: the climbing-specifier plugin loads. - The loader with the old `(error as Error).message` read: the two `throw undefined` / getter-`null` tests fail. **Other checks:** - `check-adr-0087-registration --base origin/main` on the round's commit: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", the one verified `packages/core/src/plugin-loader.ts#PluginLoader (class)`. - `check-changeset-no-major --base origin/main`, run with this body as the `pull_request` event payload so its level axis reads the PR's `Clause-②` and `Metadata-protocol` lines: no `major`, and LEVEL AXIS ✓ ("this PR declares clause-② `no (narrowing)` and metadata-protocol `none`, and it introduces no changeset entry graded `minor` or above and moves no metadata-protocol baseline — a non-protocol change at `patch`"), exit 0. - `pnpm --filter @objectstack/spec check:generated`: green (after `--fix` rebuilt the spec dist the TSDoc edit had made stale; no generated file changed). - eslint (`--no-inline-config`) over the round's touched TypeScript files: exit 0. - Raw control-byte scan over the round's touched files: no hit. **Gates:** `node scripts/pm/dispatch-gates.mjs --ran …` on the round's commit: 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN, every command exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1d8bf6 commit d4ff1a0

44 files changed

Lines changed: 4389 additions & 392 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/core': patch
5+
---
6+
7+
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)
8+
9+
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
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader) The one narrowing is a runtime check of a published runtime class: `PluginLoader` refuses a plugin object's `signature` that no host recorded a verdict for. No Zod schema, no spec declaration and no stored representation carries that field or the verdict, so there is nothing for the metadata migration to rewrite and no tombstone; what an out-of-repo host changes is its own code, as the Migration paragraph says. -->
13+
14+
**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.
15+
16+
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.
17+
18+
**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.
19+
20+
- **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:
21+
- 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;
22+
- 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;
23+
- 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;
24+
- 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;
25+
- 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`);
26+
- **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);
27+
- the entry module is a file of the artifact (`entry_missing`).
28+
29+
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`.
30+
31+
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.
32+
33+
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.
34+
- **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.
35+
- **`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 }`.
36+
- **`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.
37+
- **The installations file takes a lock** for every read-modify-write: `<file>.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 `<file>.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.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no — no key or accepted value changes; `node`, `sandbox` and `worker` still parse, and an unset tier is still legal
8+
Metadata-protocol: none
9+
10+
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.

‎content/docs/plugins/index.mdx‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -312,25 +312,29 @@ const dbPlugin: PluginMetadata = {
312312

313313
ObjectStack supports plugin security features:
314314

315-
- **Signature Verification**: Cryptographic signatures for plugin integrity
315+
- **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.
316316
- **Permission Enforcement**: Fine-grained access control for plugin operations
317317

318+
A `signature` field on a plugin object is not a verification by itself. The
319+
kernel's loader refuses a plugin that carries one unless the host that verified
320+
its artifact recorded that verdict against the object, so leave `signature`
321+
unset on a plugin you register yourself. A host that verifies artifacts of its
322+
own records the verdict before `kernel.use`:
323+
324+
```typescript
325+
import { recordPluginArtifactVerdict } from '@objectstack/core';
326+
327+
// After this host verified the artifact's bytes against a trusted key:
328+
recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 });
329+
await kernel.use(plugin); // refused without the verdict
330+
```
331+
318332
Plugin configuration is the plugin's own concern: take it as a constructor or
319333
factory argument and parse it at your own seam (a Zod `schema.parse` in the
320334
factory, constructor or `init`). The kernel-side `configSchema` metadata field
321335
was retired under ADR-0049 — the kernel never received a plugin's config, so
322336
the field could not validate anything.
323337

324-
```typescript
325-
const securePlugin: PluginMetadata = {
326-
name: 'com.example.secure',
327-
version: '1.0.0',
328-
signature: 'ed25519:key-1:<base64url-signature>',
329-
330-
async init(ctx) { /* ... */ },
331-
};
332-
```
333-
334338
### Startup Timeout
335339

336340
Plugins can configure a custom startup timeout (default: 30 seconds):

‎content/docs/protocol/kernel/lifecycle.mdx‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ command, so no `objectstack upgrade …` line on this page is runnable.
1818

1919
**Code-plugin installation is local and partial.** `os plugin install
2020
./x.osplugin` verifies a local artifact's signature against the environment's
21-
trusted keys and its per-file integrity, then unpacks it; nothing loads an
22-
installed plugin at boot yet, and there is no `os plugin enable` or `test`.
21+
trusted keys and its per-file integrity, then unpacks it, and the next
22+
`os serve` re-verifies it and loads it — `runtime: 'node'` only, in process;
23+
`sandbox` and `worker` are refused at boot until
2324
[ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md)
24-
§3.5 M1 records the loader as the next step; see
25-
[Plugin Installation](#plugin-installation) for what an author does today.
25+
M3. `os plugin disable`, `enable` and `uninstall` act at the next boot; there
26+
is no `os plugin test`. See [Plugin Installation](#plugin-installation) for
27+
what an author does today.
2628
</Callout>
2729

2830
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 {
201203
records and the platform ships only in part: `os plugin install ./x.osplugin`
202204
verifies a local artifact (its signature against the environment's trusted
203205
keys, its files against the manifest's `integrity` map), unpacks it and
204-
records the installation, writing nothing until every check passes. Download
205-
from the cloud, loading an installed plugin at boot, and the install hooks and
206+
records the installation, writing nothing until every check passes. At the
207+
next boot, `os serve` re-verifies each installation (the kept artifact's
208+
sha256, its signature against the trusted keys as they are now, the unpacked
209+
tree) and loads it by its declared tier: `node` in process, `sandbox` and
210+
`worker` refused by name. Download from the cloud and the install hooks and
206211
registry below have no command and no runtime path. The sections below up to
207212
*Upgrades* describe that design, not a command you can run. See
208213
[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
375380
### What an author does today
376381

377382
The one install command for a code plugin is `os plugin install`, for a
378-
signed artifact on local disk, and nothing loads what it installs yet. What
379-
exists, per the
383+
signed artifact on local disk; the next `os serve` loads what it installed.
384+
What exists, per the
380385
[CLI reference](https://github.com/objectstack-ai/objectstack/blob/main/packages/cli/README.md):
381386

382387
- **Runtime plugins** declared in `objectstack.config.ts` `plugins` are loaded
@@ -390,8 +395,12 @@ exists, per the
390395
manifest) are packaged and shipped with `os plugin build`, `os plugin sign`
391396
and `os plugin publish`. A signed `.osplugin` on local disk is installed with
392397
`os plugin install` once its publisher key is trusted (`os plugin keygen`,
393-
`os plugin trust add`); `os serve` does not load installed plugins yet, and a
394-
published `.osplugin` has no `os` command that fetches it from the cloud.
398+
`os plugin trust add`). `os serve` re-verifies and loads it at boot when it
399+
declares `runtime: 'node'` (author code in the host process, not isolated),
400+
refuses `sandbox` and `worker` until they are routed, and prints each refusal
401+
by name; `os plugin disable | enable | uninstall ID` take effect at the next
402+
boot. A published `.osplugin` has no `os` command that fetches it from the
403+
cloud.
395404

396405
See [Plugin System](/docs/plugins) for how kernel plugins are created and
397406
registered.
@@ -920,7 +929,7 @@ Plugin authors must document breaking changes in CHANGELOG.md.
920929

921930
ObjectStack lifecycle management provides:
922931
- **Deterministic Boot:** 7-phase boot sequence with clear error handling
923-
- **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
932+
- **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
924933
- **Zero-Downtime Upgrades:** Blue-green and rolling strategies for production
925934
- **Automatic Rollback:** Failed upgrades auto-revert to previous version
926935
- **Health Monitoring:** Built-in health checks validate system state

0 commit comments

Comments
 (0)