Repository navigation
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
Conversation
…1 — trusted-key store, keygen, trust, install Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
…re tests Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
…ts, one-shot family entries Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
…verified at unpack, os plugin install exists) Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
…istration pin; field copy names no ADR Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 5 package(s): 77 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 17 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 184 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b20695e193c1eed3a01b6ca5f1dcb8ec4a7acb7e && git checkout b20695e193c1eed3a01b6ca5f1dcb8ec4a7acb7e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6abcb72b0f20257b075149a1fabcaba884648b8b 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f && git checkout -B drift-repro 6abcb72b0f20257b075149a1fabcaba884648b8b && git merge --no-ff 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f
node scripts/docs-audit/affected-docs.mjs --json 6abcb72b0f20257b075149a1fabcaba884648b8b
|
…ore any write, swaps safely, and lists sys_trusted_key in the registry (#27123) The CI red: `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lacked the `sys_trusted_key` row its maintenance contract requires. Review round 1: - the installations file is read before anything is written, and one it cannot read is refused (`record_unreadable`) instead of being found only after the tree was materialized; - the install-directory swap restores the earlier install and removes the new tree when either rename fails, and clears a leftover from an earlier failed swap first; - an install directory holding other bytes that no record of this project owns is refused (`install_dir_taken`): two projects on one OS_HOME and environment no longer overwrite each other's install; - `sys_trusted_key.fingerprint` is unique `'global'`; - archive entries differing only in letter case, a file that is also another entry's directory, and the reserved names in any case are refused by name; - a `.sig` (or artifact) path that is a directory or cannot be read is refused by name (`signature_unreadable`, `artifact_unreadable`); - lifecycle.mdx's three remaining passages and the liveness `integrity` row (now `live`, counts regenerated) say what the install does; - the integration cases each build their own home, project and database, and cover --trust-unsigned with untrusted, malformed, other-bytes and revoked signatures, signature_malformed and trusted_key_invalid. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
With sys_trusted_key now in PLATFORM_OBJECTS_BY_PACKAGE, the inventory's enumeration pin named it as a platform object without a fate. It is deployment-level (systemFields.tenant: false, ADR-0131 D7), so its row is the column-drop fate its siblings sys_secret and sys_flow_credential carry. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK 席位核对 — ACCEPT ·
|
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Triage seat, taking Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: The lane seat takes the Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Inputs: card #27123 (body + all 10 comments, ruling ① Derived judgments
② Semver level
③ Boundary flagsDev flags (PR body "Known limitations") and report fields (
④ Design judgment
Verdict on ④: no must-fix on either Implemented-by: VERDICT: PASS Generated by Claude Code |
Contract reviewServed-tier: Inputs read: card #27123 (body and all 10 comments, ruling ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS Generated by Claude Code |
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK
Requested by Bill · project thread
Part of #27123
Ruling-ref: 6467932167
Clause-②: no — no authorable key or accepted value changes;
packages/specgains thesys_trusted_keyrow ofPLATFORM_OBJECTS_BY_PACKAGE['platform-objects'](the registry's maintenance contract, a platform-object name list) and its livenessintegrityrow moves tolive; the rest is TSDoc proseMetadata-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 + 指纹)。.osplugin没有任何命令能安装它。os serve还不加载已安装的插件(那是第 2 片),所以运行时行为不变。和 feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization #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 不符、越界路径、安装记录文件坏、另一个项目占着同一目录,都按名字拒绝;每个用例自建目录和库,顺序无关(打乱顺序跑、单独跑都绿)。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(localnow;platform/cloudreserved 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', APIget/listonly, so no API caller can add or revive a key. Platform root keys are not rows.PlatformObjectsPlugin's first manifest, besidesys_secret, on the project database. Its readers are the install loop itself,os plugin installnow 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 composesPlatformObjectsPlugin. So it is core infrastructure under D2, registered where its siblings are; no capability plugin'sownlist 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—readPublisherPublicKeyrefuses a PEM holding a private key (beforecreatePublicKey, which would quietly derive the public half), an unreadable one, and a key that is not Ed25519, naming its type.addTrustedKeywrites 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.revokeTrustedKeykeeps the row (status: 'revoked').resolveTrustedKeyanswersactive(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]— writesPATH.key.pem(PKCS#8, mode0600) andPATH.pub.pem(SPKI), prints the key id (default: the first 16 hex digits of the fingerprint), the fingerprint, and thesign/trust addlines. 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 withPlatformObjectsPlugin+AuditPlugin, so each write lands insys_audit_logby the audit mirror;listtakes 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: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;verifyIntegrity):integrity_malformed,integrity_mismatch(one line per violation); an absent map is the manifest's permissive pass;signature_not_found(an explicit--sigthat is not there),signature_unreadable(a.sigthat is a directory or cannot be read, never taken for an absent one, so--trust-unsignedcannot 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; thenverifyPluginArtifactwithrequirePlatform: false:signature_invalid/signature_verification_failed.record_unreadable) rather than clobbered by the write; an install directory that already holds other bytes (itsartifact.ospluginsha256 differs, or it has none) that no record of this project owns is refused (install_dir_taken): two projects sharing oneOS_HOMEand 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.artifact.ospluginandartifact.osplugin.sigbeside the files; then the record.--trust-unsignedapplies only to an artifact with NO signature: it installs with a warning on stderr and recordstrustUnsigned: 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 isOS_ENVIRONMENT_ID, elseenv_local. The human face ends by sayingos servedoes not load installed plugins yet.Self-descriptions slice 1 makes false, corrected (prose only):
packages/spec/src/kernel/manifest.zod.ts—PluginIntegritySchemaTSDoc ("NOT RE-VERIFIED AT UNPACK … there is noos plugin install") and theintegrityfield's TSDoc now sayos plugin installre-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 noinstall") andcontent/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 noos plugin installor that code-plugin installation is not implemented) — the three commands listed, andos servenamed as not loading installed plugins yet.packages/spec/liveness/manifest.json— theintegrityrow movesdead→livewith in-repo evidenceinstall.ts#PluginInstallandpublish.ts#PluginPublish; its note had assigned that flip to whatever built re-verification at unpack.liveness/state-counts/manifest.mdregenerated (gen:liveness-counts), andliveness/README.md's manifest row no longer countsintegrityamong the dead.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 realObjectQL+SqlDriverover a SQLite file holding the realsys_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 bothkey-id-revokedandkey-revokedafterwards;resolveTrustedKeyhands back a key that verifies what its private half signed, and answersinvalidfor 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 topackageStateFileName, 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.sigfor 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 half0600, 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 ownOS_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 addwrites 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.sigdirectory, with and without--trust-unsigned);integrity_mismatch;artifact_entries_refused;signature_missingthen--trust-unsignedinstalling with the stderr warning;--trust-unsignedstill refusing an untrusted, a malformed, an other-bytes and a revoked signature;signature_key_revokedaftertrust revokewith the earlier install left in place;record_unreadableleaving the corrupt file byte-identical; and two projects on oneOS_HOME: the second is refusedinstall_dir_takenwith 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 newbootSchemaStackcallers declared inCALLERS(the first case reads the family off the source, so a caller left out of the table is named there):plugin installandplugin trust listas no-write modes (database byte-identical, no SQLite file brought into existence, one JSON document),plugin trust addandplugin trust revokeas write modes (no seed write), all four leaving the key home empty. A@FIXTURE@placeholder carries a signed artifact and public key written inbeforeAll, 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.packages/platform-objects/src/plugin.test.ts(registration roster gainssys_trusted_key) anddeployment-level-no-tenant-column.test.ts(the deployment-level population gains it).Acceptance notes
sys_trusted_keythrough the CLI's one-shotbootSchemaStack(read-only forinstall/list, plain foradd/revoke), the family theos migrate/os secretcommands use, against--database-url/OS_DATABASE_URLor the project's default database. No HTTP route was added.loadPluginInstallations), re-verifyartifact.ospluginagainstartifact.osplugin.sigwithresolveTrustedKey, and refuse a key revoked since install. Nothing here loads or dispatches onruntime.installresolves the signature's key id, thenresolveTrustedKeyre-derives the fingerprint from the stored PEM and refuses a mismatch; the record keeps both.sys_exchange_rate): both PRs append toPlatformObjectsPlugin's first manifest and to the same roster assertion inplugin.test.ts. Both also movescripts/platform-object-tenancy-census.json. Whichever lands second takes the union in the two files (five objects, "The five above") and regenerates the census withnode scripts/platform-object-tenancy-census.mjs --write.os plugin install". It is not edited here (nodocs/adr/**edit); it rides the card's Tier H docs follow-up.packages/cli/src/utils/organization-ownership-inventory.ts(the ADR-0131 D10 inventory row forsys_trusted_key: censusplatform-census-main, fatecolumn-drop, assys_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.tsand theirindex.tsexports;packages/cli/src/utils/plugin-trust-store.ts,plugin-install.ts; the one-shot family pin; the self-description comment edits inpackages/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) andpackages/spec/liveness/(manifest.json,state-counts/manifest.md,README.md).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.sys_trusted_keyis an engine-owned system object with a read-only API; no metadata type, authorable key or accepted value is added (check:authorable-surfaceandcheck:api-surfacegreen, the spec's 14 generated artifacts current with nothing to regenerate).origin/main9ff8154c:verifyPluginArtifact(had one hit, its definition (plugin-artifact-signature.ts:264); on this branch the second isinstall.ts.sign.tsreads--key/--key-idand writes only the signature.allowedSignersoutsidepackages/spec: 0 hits.Known limitations
os plugin installruns 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.readTarGzinflates the whole artifact before its signature is verified, so a hostile.osplugincan 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.install_dir_takenrefuses 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']lackedsys_trusted_key, reproduced locally and fixed), each insidescripts/pm/os-verify-lock.sh, every VERDICTcommand-exit 0: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.plugin-install+plugin-keygen+ exit-signal pin — Test Files 3/3, Tests 154/154.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.tsc --noEmitclean; eslint--no-inline-configover the touched files exit 0;check:livenessexit 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/mainebfadb02merged in), each insidescripts/pm/os-verify-lock.sh, every VERDICTcommand-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.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).--no-inline-config) overpackages/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 on70cdda4e, together with the eight the merged tree adds:pnpm check:platform-object-tenancy-census— exit 1 on the first run (sys_trusted_keynew 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 fullturbo run build;check-plugin-teardown-shape --self-test— exit 3 (a shallow clone without its pinned fixture commit), then 0 after fetching it.OS_HOME,--database-url file:…): keygen → install refused untrusted (noapp.dbcreated) → 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_logcarried thecreateandupdaterows for the key.Generated by Claude Code