Skip to content

Commit 10f27b6

Browse files
feat(cli, runtime, platform-objects): os plugin install verifies a signed .osplugin against the environment's trusted keys and installs it locally (#27123, slice 1 of 3) (#27335)
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 — no authorable key or accepted value changes; `packages/spec` gains the `sys_trusted_key` row of `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (the registry's maintenance contract, a platform-object name list) and its liveness `integrity` row moves to `live`; the rest is TSDoc prose Metadata-protocol: none ## 维护者速读 - **改了什么:** 新增 `os plugin keygen`、`os plugin trust add|list|revoke`、`os plugin install ./x.osplugin`,以及部署级系统对象 `sys_trusted_key`(只存公钥)。install 先按清单 `integrity` 逐文件复核,再用库里该 key id 的公钥验 `.sig`(`verifyPluginArtifact` 的第一个生产调用方),全部通过才解包到 `<OS_HOME>/plugins/<env>/<id>/<version>/`,并写安装记录(key id + 指纹)。 - **为什么改:** #27123 裁决 B′ 的第 1 片(ADR-0025 §3.5 M1 本地 T0 安装闭环)。此前签好名的 `.osplugin` 没有任何命令能安装它。 - **风险与回滚:** 只新增命令、一张部署级表和两个 runtime 存储模块;`os serve` 还不加载已安装的插件(那是第 2 片),所以运行时行为不变。和 #27246 在 `platform-objects` 注册清单及其测试上会有文本冲突,后合的一方取两边并集即可。回滚 = revert 本 PR。 - **先看:** `packages/cli/src/commands/plugin/install.ts`(拒绝的顺序和名字);`packages/runtime/src/plugin-trusted-keys.ts`(一个 key id 只对应一把钥匙、吊销后不能复活);`packages/platform-objects/src/system/sys-trusted-key.object.ts`。 - **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/plugin/install.integration.test.ts`,预期 16 个全绿:信任后安装成功;未知键、已吊销键、键行不可用、签名对不上字节、没有签名、签名格式坏、签名读不了、制品路径读不了、integrity 不符、越界路径、安装记录文件坏、另一个项目占着同一目录,都按名字拒绝;每个用例自建目录和库,顺序无关(打乱顺序跑、单独跑都绿)。 - **需要你做的:** 需要你批准合并:本 PR 改动 3096 行(+3016 / -80,含生成文件),超过 3000 行的人工合并线,按 Tier H 落地,席位在 CI 绿、契约审查通过后来请你批。ADR-0025 §3.5 v2 里 "Steps 4–7 do not exist" 那句随卡上的 Tier H 文档跟进去改,本 PR 不碰 `docs/adr/**`。 ## What changes **`sys_trusted_key`** (`packages/platform-objects/src/system/sys-trusted-key.object.ts`) — the environment's trusted plugin-publisher keys, as ruled: `key_id` (unique `'global'`), `public_key` (SPKI PEM), `fingerprint` (`sha256:` + hex of the SPKI DER bytes, unique `'global'`), `source` (`local` now; `platform` / `cloud` reserved and written by nothing), `status` (`active` | `revoked`), `added_by`, `added_at`. Deployment-level by ADR-0131 D7: `systemFields: { tenant: false }`, `requiredPermissions: ['manage_platform_settings']`, `managedBy: 'engine-owned'`, API `get` / `list` only, so no API caller can add or revive a key. Platform root keys are not rows. - **Registration home (ADR-0029 D1 / D2).** `PlatformObjectsPlugin`'s first manifest, beside `sys_secret`, on the project database. Its readers are the install loop itself, `os plugin install` now and the boot-time loader of slice 2, and no capability plugin owns that loop: the loader runs at boot, before any optional plugin would register the table, and every one-shot boot the commands take composes `PlatformObjectsPlugin`. So it is core infrastructure under D2, registered where its siblings are; no capability plugin's `own` list changes. **Runtime stores** (`@objectstack/runtime`, exported because the CLI writes them and the slice-2 loader is to read the same ones): - `plugin-trusted-keys.ts` — `readPublisherPublicKey` refuses a PEM holding a private key (before `createPublicKey`, which would quietly derive the public half), an unreadable one, and a key that is not Ed25519, naming its type. `addTrustedKey` writes in system context and keeps one key id ↔ one key: a different key under a taken id, the same key under a second id, a revoked id, or a revoked key under a new id are each refused by name; the same key under the same id again writes nothing. `revokeTrustedKey` keeps the row (`status: 'revoked'`). `resolveTrustedKey` answers `active` (with the key) / `revoked` / `unknown` / `invalid`, the last when the stored fingerprint is not the stored PEM's. - `plugin-installation-store.ts` — `pluginInstallDir(env, id, version)` = `<OS_HOME>/plugins/<env>/<id>/<version>/`, refusing an id or version that is not one safe path segment. The record (`id`, `version`, `runtime`, `status`, `grantedPermissions`, `dir`, artifact sha256, `signature: { keyId, fingerprint } | null`, `trustUnsigned`, `installedAt`) is kept in `<OS_HOME>/plugin-installations/<packageStateFileName(env, cwd)>`, the package disable-state store's own file-name rule (ADR-0016 §9.7), written by temp file + rename. An unreadable record file is refused, never rewritten from empty. **Commands** (`packages/cli/src/commands/plugin/`): - `os plugin keygen --out PATH [--key-id ID] [--force]` — writes `PATH.key.pem` (PKCS#8, mode `0600`) and `PATH.pub.pem` (SPKI), prints the key id (default: the first 16 hex digits of the fingerprint), the fingerprint, and the `sign` / `trust add` lines. Never overwrites either file without `--force`. The private key never leaves the developer's disk. - `os plugin trust add PUB.pem --key-id ID`, `trust list [--json]`, `trust revoke ID` — admin writes through a plain one-shot boot with `PlatformObjectsPlugin` + `AuditPlugin`, so each write lands in `sys_audit_log` by the audit mirror; `list` takes the read-only boot (`deferSchemaDdl` + `readOnlyProbe`, absent table read as empty). - `os plugin install ARTIFACT [--sig PATH] [--trust-unsigned] [--json]` — in order, every refusal named and exit 1, nothing written before the last check passes: 1. the archive (`readTarGz`): `artifact_not_found`, `artifact_unreadable` (also an artifact path that is a directory or cannot be read), `artifact_entries_refused` (absolute, `..`, empty-segment, backslash / NUL, non-normal, duplicate or case-folded duplicate, file-and-directory, or reserved entry paths, the reserved names in any letter case), `manifest_unreadable`, `manifest_incomplete`, `manifest_unsafe_identity`; 2. integrity at unpack (`verifyIntegrity`): `integrity_malformed`, `integrity_mismatch` (one line per violation); an absent map is the manifest's permissive pass; 3. the signature: `signature_not_found` (an explicit `--sig` that is not there), `signature_unreadable` (a `.sig` that is a directory or cannot be read, never taken for an absent one, so `--trust-unsigned` cannot install around it), `signature_missing`, `signature_malformed`; the key id resolved in the store through the read-only boot: `signature_key_untrusted`, `signature_key_revoked`, `trusted_key_invalid`; then `verifyPluginArtifact` with `requirePlatform: false`: `signature_invalid` / `signature_verification_failed`. 4. before anything is written: this project's installation records are read, and a file it cannot read is refused (`record_unreadable`) rather than clobbered by the write; an install directory that already holds other bytes (its `artifact.osplugin` sha256 differs, or it has none) that no record of this project owns is refused (`install_dir_taken`): two projects sharing one `OS_HOME` and environment id share `<OS_HOME>/plugins/<env>/<id>/<version>/` under the ruled layout, and one must not overwrite the other's install. Installing the very bytes the directory holds, or replacing a directory this project's record owns, goes ahead. 5. materialize (staging dir + rename, so a failed write leaves no half install, and a failed swap puts the earlier install back and removes the new tree), keeping `artifact.osplugin` and `artifact.osplugin.sig` beside the files; then the record. `--trust-unsigned` applies only to an artifact with NO signature: it installs with a warning on stderr and records `trustUnsigned: true`. A present signature is always verified; the flag never makes a bad one pass (pinned). Per triage 6463804227 there is no "unsigned with warning" arm without the flag. The environment id is `OS_ENVIRONMENT_ID`, else `env_local`. The human face ends by saying `os serve` does not load installed plugins yet. **Self-descriptions slice 1 makes false**, corrected (prose only): - `packages/spec/src/kernel/manifest.zod.ts` — `PluginIntegritySchema` TSDoc ("NOT RE-VERIFIED AT UNPACK … there is no `os plugin install`") and the `integrity` field's TSDoc now say `os plugin install` re-verifies at unpack. No `.describe()` text changed. - `packages/core/src/security/index.ts`, `plugin-artifact-integrity.ts`; `packages/cli/src/utils/osplugin.ts`, `commands/plugin/publish.ts`, `commands/plugin/sign.ts` — comments naming the "future runtime loader" as the only re-verifier. - `packages/cli/README.md` ("The group has no `install`") and `content/docs/protocol/kernel/lifecycle.mdx` (the top callout, the *Plugin Installation* callout, *What an author does today*, and the Summary's *Atomic Installations* line, each of which said there is no `os plugin install` or that code-plugin installation is not implemented) — the three commands listed, and `os serve` named as not loading installed plugins yet. - `packages/spec/liveness/manifest.json` — the `integrity` row moves `dead` → `live` with in-repo evidence `install.ts#PluginInstall` and `publish.ts#PluginPublish`; its note had assigned that flip to whatever built re-verification at unpack. `liveness/state-counts/manifest.md` regenerated (`gen:liveness-counts`), and `liveness/README.md`'s manifest row no longer counts `integrity` among the dead. - Left as they are, true until slice 2: `PluginRuntimeSchema` "nothing dispatches on the tier", `plugin-loader.ts`'s format-only signature check, `plugin-artifact-signature.ts`. **Changesets:** `.changeset/27123-local-plugin-install.md` (`@objectstack/cli`, `@objectstack/runtime`, `@objectstack/platform-objects`, patch) and `.changeset/27123-integrity-reverified-at-unpack.md` (`@objectstack/spec`, `@objectstack/core`, patch, prose only); both carry the two declaration lines. ## Tests - `packages/runtime/src/plugin-trusted-keys.test.ts` (14) — against a real `ObjectQL` + `SqlDriver` over a SQLite file holding the real `sys_trusted_key`, never a double: the PEM refusals (a private key, with the control that node itself would derive a public key from it; RSA, naming `'rsa'`; non-PEM); add / idempotent re-add / `key-id-invalid` / `key-id-taken` / `key-trusted-under-another-id`; revoke keeps the row and refuses both `key-id-revoked` and `key-revoked` afterwards; `resolveTrustedKey` hands back a key that verifies what its private half signed, and answers `invalid` for a row whose fingerprint is another key's (with the anti-vacuity control that the PEM itself is a good key); and the table itself refuses a second row of the same key, and a second key under the same id, written around the store (two adds racing), with the control that a second key under a second id is a row. - `packages/runtime/src/plugin-installation-store.test.ts` (10) — the install path, six unsafe id / version segments refused, round trip and replace-by-id, the project-keyed file name equal to `packageStateFileName`, and a corrupt record file refused and left byte-identical. - `packages/cli/src/utils/plugin-install.test.ts` (28) — every unsafe entry path named once; materialize keeps the original bytes and signature, writes no `.sig` for an unsigned install, and a re-install replaces the old tree whole with no staging dir left; two paths differing only in letter case; a file that is also another entry's directory (three shapes, one case-folded), with a control; the reserved names in other letter case; `installedArtifactSha`'s three answers; a swap that fails at either rename leaves the earlier install whole and no staging dir, and a leftover from an earlier failed swap does not block the next (each of the three fails against the pre-fix code). - `packages/cli/test/plugin-keygen.test.ts` (4) — the pair is one Ed25519 key, private half `0600`, the printed fingerprint is the public half's, the private key's text never reaches the terminal, the default key id, `--force`, and an unusable key id writing nothing. - `packages/cli/src/commands/plugin/install.integration.test.ts` (16, integration tier) — in process through oclif, real SQLite, every case on its own `OS_HOME`, project and database, so the cases hold in any order (green shuffled, `--sequence.shuffle`, and one at a time): an untrusted key refused and no database file created; `trust add` writes the row and an audit row, re-add writes nothing, a private key is refused; a signed install materializes and records the key; `signature_invalid`; `trusted_key_invalid` (the row's PEM swapped under its fingerprint); `artifact_unreadable` (an artifact path that is a directory); `signature_malformed`; `signature_unreadable` (a `.sig` directory, with and without `--trust-unsigned`); `integrity_mismatch`; `artifact_entries_refused`; `signature_missing` then `--trust-unsigned` installing with the stderr warning; `--trust-unsigned` still refusing an untrusted, a malformed, an other-bytes and a revoked signature; `signature_key_revoked` after `trust revoke` with the earlier install left in place; `record_unreadable` leaving the corrupt file byte-identical; and two projects on one `OS_HOME`: the second is refused `install_dir_taken` with the first's tree untouched, the owner may replace, and installing the bytes already there goes ahead. - `packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts` — the four new `bootSchemaStack` callers declared in `CALLERS` (the first case reads the family off the source, so a caller left out of the table is named there): `plugin install` and `plugin trust list` as no-write modes (database byte-identical, no SQLite file brought into existence, one JSON document), `plugin trust add` and `plugin trust revoke` as write modes (no seed write), all four leaving the key home empty. A `@FIXTURE@` placeholder carries a signed artifact and public key written in `beforeAll`, and one added case pins that the install fixture reaches the store read (`signature_key_untrusted`) rather than refusing earlier, so its no-write cases cannot hold vacuously. - Updated: `packages/platform-objects/src/plugin.test.ts` (registration roster gains `sys_trusted_key`) and `deployment-level-no-tenant-column.test.ts` (the deployment-level population gains it). ## Acceptance notes - **Store transport.** The commands reach `sys_trusted_key` through the CLI's one-shot `bootSchemaStack` (read-only for `install` / `list`, plain for `add` / `revoke`), the family the `os migrate` / `os secret` commands use, against `--database-url` / `OS_DATABASE_URL` or the project's default database. No HTTP route was added. - **Slice 2 reads what this writes.** The boot-time loader is to read the installation record (`loadPluginInstallations`), re-verify `artifact.osplugin` against `artifact.osplugin.sig` with `resolveTrustedKey`, and refuse a key revoked since install. Nothing here loads or dispatches on `runtime`. - **Key id, not fingerprint, is the lookup.** `install` resolves the signature's key id, then `resolveTrustedKey` re-derives the fingerprint from the stored PEM and refuses a mismatch; the record keeps both. - **Merge collision with #27246** (draft, `sys_exchange_rate`): both PRs append to `PlatformObjectsPlugin`'s first manifest and to the same roster assertion in `plugin.test.ts`. Both also move `scripts/platform-object-tenancy-census.json`. Whichever lands second takes the union in the two files (five objects, "The five above") and regenerates the census with `node scripts/platform-object-tenancy-census.mjs --write`. - **ADR text.** ADR-0025 v2 §3.5 still says "Steps 4–7 do not exist … there is no `os plugin install`". It is not edited here (no `docs/adr/**` edit); it rides the card's Tier H docs follow-up. - **File surface beyond the commands and the object** (all inside the slice): `packages/cli/src/utils/organization-ownership-inventory.ts` (the ADR-0131 D10 inventory row for `sys_trusted_key`: census `platform-census-main`, fate `column-drop`, as `sys_secret`); `scripts/platform-object-tenancy-census.json` (derived, regenerated with `--write`: the new object joins the population, 87 → 88 registered, out of reach as deployment-level); `packages/runtime/src/plugin-trusted-keys.ts`, `plugin-installation-store.ts` and their `index.ts` exports; `packages/cli/src/utils/plugin-trust-store.ts`, `plugin-install.ts`; the one-shot family pin; the self-description comment edits in `packages/core/src/security/*`, `packages/cli/src/utils/osplugin.ts`, `publish.ts`, `sign.ts`, `packages/cli/README.md`, `content/docs/protocol/kernel/lifecycle.mdx`; `packages/spec/src/system/constants/platform-object-names.ts` (the registry row) and `packages/spec/liveness/` (`manifest.json`, `state-counts/manifest.md`, `README.md`). - **Size: Tier H.** 3096 changed lines (+3016 / -80, generated included) against the 3000 human-merge threshold, measured by dispatch-gates on `6b3a8286`; review round 1 added about 400, mostly tests (integration 10 → 16, unit 17 → 28). It lands on an authorized APPROVED review or a human merge. - **Metadata-protocol: none, measured.** `sys_trusted_key` is an engine-owned system object with a read-only API; no metadata type, authorable key or accepted value is added (`check:authorable-surface` and `check:api-surface` green, the spec's 14 generated artifacts current with nothing to regenerate). - **Premises 1–3 of 6464984121, re-run on `origin/main` `9ff8154c`:** `verifyPluginArtifact(` had one hit, its definition (`plugin-artifact-signature.ts:264`); on this branch the second is `install.ts`. `sign.ts` reads `--key` / `--key-id` and writes only the signature. `allowedSigners` outside `packages/spec`: 0 hits. ## Known limitations - **The installations file's read-modify-write takes no lock.** Two `os plugin install` runs for one project and environment at the same moment can each read the file, and the second rename wins, dropping the first's record (its tree stays on disk). The write itself is atomic (temp file + rename), so no reader sees half a file. A lock is out of this slice. - **gunzip has no size cap before the signature check.** `readTarGz` inflates the whole artifact before its signature is verified, so a hostile `.osplugin` can make the command spend memory before it is refused. The signature covers the compressed bytes, so a cap belongs on the inflate, not after it. - **Two projects that installed identical bytes share ownership.** `install_dir_taken` refuses bytes no record of this project owns; when both projects' records name the same sha, either may later replace the tree, and the other's record then names a sha the directory no longer holds. The slice-2 loader is to check the kept artifact's sha256 against the record before it trusts the tree, which refuses that case at boot. ## Verification Review round 1, at head `ae1b0536` (the CI red: `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lacked `sys_trusted_key`, reproduced locally and fixed), each inside `scripts/pm/os-verify-lock.sh`, every VERDICT `command-exit 0`: - runtime `plugin-trusted-keys` + `plugin-installation-store` — Test Files 2/2, Tests 24/24. - `pnpm --filter @objectstack/platform-objects exec vitest run` — Test Files 97/97, Tests 1369/1369. - cli unit `plugin-install` + `plugin-keygen` + exit-signal pin — Test Files 3/3, Tests 154/154. - cli integration `install.integration` + one-shot family pin — Test Files 2/2, Tests 140/140. - `pnpm --filter @objectstack/spec run test:repo` — Test Files 84/84, Tests 1396/1396. - cli `tsc --noEmit` clean; eslint `--no-inline-config` over the touched files exit 0; `check:liveness` exit 0. - `node scripts/pm/dispatch-gates.mjs --ran …` — 119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN; every one exited 0. First round, at head `70cdda4e`: All at head `70cdda4e` (origin/main `ebfadb02` merged in), each inside `scripts/pm/os-verify-lock.sh`, every VERDICT `command-exit 0`: - `pnpm --filter @objectstack/runtime exec vitest run src/plugin-trusted-keys.test.ts src/plugin-installation-store.test.ts` — Test Files 2/2, Tests 23/23. - `pnpm --filter @objectstack/platform-objects exec vitest run` — Test Files 97/97, Tests 1369/1369. - `pnpm --filter @objectstack/cli exec tsc --noEmit -p tsconfig.json` — no errors. - `pnpm --filter @objectstack/cli exec vitest run src/utils/plugin-install.test.ts test/plugin-keygen.test.ts test/exit-signal.pin.test.ts` — Test Files 3/3, Tests 143/143 (the exit-signal pin discovers the four new commands on its own). - `pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/plugin/install.integration.test.ts src/utils/schema-migrate.one-shot-family.integration.test.ts` — Test Files 2/2, Tests 134/134. - Before the merge, at `a0c22f13`: `pnpm --filter @objectstack/runtime exec vitest run` — Test Files 473/473, Tests 8598 passed, 19 skipped. - `pnpm --filter @objectstack/spec check:generated --fix` — "All 14 generated artifacts are up to date", nothing regenerated (the spec edit is TSDoc only). - eslint (`--no-inline-config`) over `packages/cli packages/runtime packages/platform-objects packages/core packages/spec/src/kernel` — exit 0. - `node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack` — "117 derived famil(ies) accounted for — 117 run, 0 NOT-MEASURED". Every one of the 117 exited 0. 104 of them ran on the pre-merge tree; the five that first exited non-zero were re-run on `70cdda4e`, together with the eight the merged tree adds: - `pnpm check:platform-object-tenancy-census` — exit 1 on the first run (`sys_trusted_key` new to the population), then 0 after `--write`; - `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage` — exit 3 (no build output) on the first run, then 0 after a full `turbo run build`; - `check-plugin-teardown-shape --self-test` — exit 3 (a shallow clone without its pinned fixture commit), then 0 after fetching it. - Manual run of the loop by hand (isolated `OS_HOME`, `--database-url file:…`): keygen → install refused untrusted (no `app.db` created) → trust add (private key refused; idempotent) → install → tamper / unsigned / malformed / escape refusals → `--trust-unsigned` → revoke → install refused revoked → trust add of the revoked key refused. `sys_audit_log` carried the `create` and `update` rows for the key. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8f4acc commit 10f27b6

37 files changed

Lines changed: 3016 additions & 80 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
docs(spec, core): the plugin manifest's `integrity` map is described as re-verified at unpack by `os plugin install`, and the platform-object registry lists `sys_trusted_key` (#27123)
7+
8+
Clause-②: no — no authorable key or accepted value changes. `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` gains the `sys_trusted_key` row its maintenance contract requires for an object `@objectstack/platform-objects` now registers (a list of platform-object names, not an authorable surface); the rest is prose: the TSDoc of `PluginIntegritySchema` and the manifest's `integrity` field, and comments in `@objectstack/core`'s security module, with no `.describe()` text changed
9+
Metadata-protocol: none
10+
11+
The TSDoc said nothing in the repo re-verified the map after publish and that there was no `os plugin install`. `os plugin install` now re-hashes the unpacked files against the map before it installs anything, and refuses the install on a mismatch, so the text says that. An absent map is still a permissive pass. The liveness ledger's `integrity` row moves from `dead` to `live` on that reader.
12+
13+
`sys_trusted_key` joins the `platform-objects` group of `PLATFORM_OBJECTS_BY_PACKAGE`, so `PLATFORM_PROVIDED_OBJECT_NAMES` counts it as platform-provided like its siblings.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
feat(cli, runtime, platform-objects): `os plugin install ./x.osplugin` installs a signed plugin artifact locally, after verifying its signature against the environment's trusted publisher keys and its files against the manifest's `integrity` map (#27123)
8+
9+
Clause-②: no — no key, accepted value or export of `@objectstack/spec` changes; the new object and commands are platform code, not authorable metadata
10+
Metadata-protocol: none
11+
12+
The first slice of ADR-0025's local install loop (§3.5 M1). Before this, a built and signed `.osplugin` had no command that installed it.
13+
14+
- **`os plugin keygen --out PATH`** writes a publisher Ed25519 key pair: `PATH.key.pem` (private, mode `0600`, stays with the publisher) and `PATH.pub.pem`, and prints the key id and the public key's fingerprint (`sha256:` + hex of the SPKI DER bytes). It never overwrites a key file without `--force`.
15+
- **`os plugin trust add PUB.pem --key-id ID`**, **`trust list`**, **`trust revoke ID`** manage the environment's trusted keys in the new deployment-level object `sys_trusted_key` (registered by `PlatformObjectsPlugin`, beside `sys_secret`; no tenant column; `manage_platform_settings`; the API offers reads only). The writes run in system context and are recorded by the audit mirror. Only a public key is accepted: a PEM holding a private key, or a key that is not Ed25519, is refused. One key id names one key and one key carries one key id. A revoked row stays, and neither its key id nor its key is trusted again.
16+
- **`os plugin install ARTIFACT`** reads the archive and refuses, by name and with exit status 1, before anything is written:
17+
- an entry that could land outside the install directory, two entries that differ only in letter case, or a file that is also the directory of another entry (`artifact_entries_refused`);
18+
- a file whose digest is not the one the manifest's `integrity` map declares, a declared file that is missing, or a packaged file the map does not name (`integrity_mismatch`);
19+
- a missing signature (`signature_missing`), one that is there but cannot be read (`signature_unreadable`), a malformed one (`signature_malformed`), a key id the environment does not trust (`signature_key_untrusted`) or has revoked (`signature_key_revoked`), a trusted-key row whose fingerprint is not its key's (`trusted_key_invalid`), and a signature that does not verify over the artifact's bytes (`signature_invalid`);
20+
- an installations file it cannot read (`record_unreadable`), which a write would clobber, and an install directory that already holds other bytes no installation of this project put there (`install_dir_taken`): two projects sharing one `OS_HOME` and environment share the directory, and one does not overwrite the other's install.
21+
22+
It then unpacks the artifact to `<OS_HOME>/plugins/<env>/<id>/<version>/`, keeping the original `artifact.osplugin` and `artifact.osplugin.sig` beside the files, and writes an installation record (the version, the key id and fingerprint it verified against) to `<OS_HOME>/plugin-installations/`, keyed by environment and project like the package disable-state store. A swap that fails puts the earlier install of the same version back. `--trust-unsigned` installs an artifact that has no signature, with a warning on stderr; it never makes a present signature that fails verification pass. `--json` prints one document.
23+
- **Not yet.** `os serve` does not load installed plugins: the boot-time loader is the next slice of the same loop.
24+
25+
`@objectstack/runtime` exports the two stores both sides read: `addTrustedKey`, `listTrustedKeys`, `revokeTrustedKey`, `resolveTrustedKey`, `readPublisherPublicKey`, `publicKeyFingerprint`, and `pluginInstallDir`, `loadPluginInstallations`, `recordPluginInstallation`.

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ partially implemented in the control plane and exposed only on hosted plans.
1616
Treat those command snippets as design intent: the `os` CLI has no `upgrade`
1717
command, so no `objectstack upgrade …` line on this page is runnable.
1818

19-
**Code-plugin installation is not implemented.** The `os plugin` group is
20-
`build`, `sign` and `publish` only; there is no `os plugin install`, `enable`
21-
or `test`. [ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md)
22-
records the install half (download, verify, materialize, load) as not yet
23-
implemented; see [Plugin Installation](#plugin-installation) for what an author
24-
does today.
19+
**Code-plugin installation is local and partial.** `os plugin install
20+
./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`.
23+
[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.
2526
</Callout>
2627

2728
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).
@@ -195,11 +196,15 @@ class AppPlugin implements Plugin {
195196
## Plugin Installation
196197

197198
<Callout type="warn">
198-
**Not implemented.** Installing a code plugin is a design that
199+
**Partly implemented.** Installing a code plugin is a design that
199200
[ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md)
200-
records but the platform does not yet ship: the install half (download,
201-
verify, materialize, load) has no command and no runtime path. The sections
202-
below up to *Upgrades* describe that design, not a command you can run. See
201+
records and the platform ships only in part: `os plugin install ./x.osplugin`
202+
verifies a local artifact (its signature against the environment's trusted
203+
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+
registry below have no command and no runtime path. The sections below up to
207+
*Upgrades* describe that design, not a command you can run. See
203208
[What an author does today](#what-an-author-does-today).
204209
</Callout>
205210

@@ -369,7 +374,9 @@ transitions (this page, above), not code hooks; boot-time code goes in
369374

370375
### What an author does today
371376

372-
There is no install command for a code plugin. What exists, per the
377+
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
373380
[CLI reference](https://github.com/objectstack-ai/objectstack/blob/main/packages/cli/README.md):
374381

375382
- **Runtime plugins** declared in `objectstack.config.ts` `plugins` are loaded
@@ -381,9 +388,10 @@ There is no install command for a code plugin. What exists, per the
381388
publishing with `os package publish … --env <env-id> --install`.
382389
- **Code-bearing plugins** (a directory carrying an `objectstack.plugin.json`
383390
manifest) are packaged and shipped with `os plugin build`, `os plugin sign`
384-
and `os plugin publish`. The group stops there: ADR-0025 records the install
385-
half as not yet implemented, so a published `.osplugin` has no `os` command
386-
that installs it.
391+
and `os plugin publish`. A signed `.osplugin` on local disk is installed with
392+
`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.
387395

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

913921
ObjectStack lifecycle management provides:
914922
- **Deterministic Boot:** 7-phase boot sequence with clear error handling
915-
- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; code-plugin installation is not implemented yet
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
916924
- **Zero-Downtime Upgrades:** Blue-green and rolling strategies for production
917925
- **Automatic Rollback:** Failed upgrades auto-revert to previous version
918926
- **Health Monitoring:** Built-in health checks validate system state

‎packages/cli/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,18 @@ in there with `--email` / `--password` (env `OS_RUNTIME_EMAIL` /
142142

143143
Runtime plugins (declared in `objectstack.config.ts` `plugins`) are loaded automatically by `os serve` / `os dev`. Runtime plugins are bundled into the build artifact. To distribute a build, publish it as a package with `os package publish` (see [Cloud — publish & install](#cloud--publish--install)); the `os environments bind <id> --artifact dist/objectstack.json` path still binds an artifact directly into an environment without going through the package registry.
144144

145-
A code-bearing plugin — a directory carrying an `objectstack.plugin.json` manifest — is packaged and shipped through the `os plugin` command group (ADR-0025 §3.4, build → sign → publish):
145+
A code-bearing plugin — a directory carrying an `objectstack.plugin.json` manifest — is packaged and shipped through the `os plugin` command group (ADR-0025 §3.4, build → sign → publish), and installed locally from a signed artifact (ADR-0025 §3.5 M1, keygen → trust → install):
146146

147147
| Command | Description |
148148
|---------|-------------|
149149
| `os plugin build [dir]` | Compile a plugin into a signed-ready `.osplugin` artifact (`--entry`, `--out`, `--minify`) |
150150
| `os plugin sign <artifact> --key <pem>` | Sign a built `.osplugin` with a publisher Ed25519 key, writing a detached `<artifact>.sig` |
151151
| `os plugin publish [artifact]` | Publish a signed `.osplugin` to ObjectOS Cloud |
152+
| `os plugin keygen --out <path>` | Generate a publisher Ed25519 key pair: `<path>.key.pem` (private, stays with you) and `<path>.pub.pem` |
153+
| `os plugin trust add <pub.pem> --key-id <id>` | Trust a publisher public key in this environment's database (`sys_trusted_key`); `trust list` shows the keys, `trust revoke <id>` revokes one |
154+
| `os plugin install <artifact>` | Verify a local `.osplugin`'s detached signature against the trusted keys and its per-file `integrity`, then unpack it to `<OS_HOME>/plugins/<env>/<id>/<version>/`; `--trust-unsigned` is the explicit dev override for an unsigned artifact |
152155

153-
The group has no `install`: ADR-0025 records the code-plugin install half (download, verify, materialize, load) as not yet implemented. `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).
156+
`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).
154157

155158
### Quality
156159

0 commit comments

Comments
 (0)