Skip to content

Commit 293a85b

Browse files
feat(cli): os serve seals secrets with the crypto provider a plugin registers (#30993)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DC29cD9SC4ABxGHX1Wr2zGZ)_ Fixes #30948 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:`os serve` 现在用插件在初始化时注册的密钥加密提供方(服务名 `crypto-provider`)封存全部机密,即对象的机密字段、数据源凭据和加密设置;没有插件注册时仍用原来的本地提供方。 - 为什么改:云端客户自管密钥(cloud#3573)要让 KMS 接管密钥,而此前 `os serve` 启动收尾时总会建本地提供方并覆盖插件设好的那个,KMS 接不进来。 - 风险与回滚:风险在密钥持有边界,选错提供方会让机密打不开,所以两个插件同时声明、初始化之后才注册、注册后被替换、注册的不是提供方这四种情况都在启动时拒绝,拒绝文字不带任何密钥值;无注册时行为不变,唯一差别是默认设置服务改为与数据引擎共用同一个本地提供方实例(密钥来源相同,互相能解);回滚即 revert 本 PR,不涉及数据迁移。 - 先看:`packages/cli/src/commands/serve.ts` 的 `createHostCryptoProvider`(选哪个提供方、何时拒绝),同文件启动后接线处的 `hostCrypto.provider.confirm()`,再看测试 `packages/cli/src/commands/serve-host-crypto-provider.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/commands/serve-host-crypto-provider.test.ts`,预期 16 条全部通过。 - 需要你做的:无,席位落地。 ## What changes A plugin supplies the host's secret crypto provider by registering an `ICryptoProvider` as the kernel service `crypto-provider` with `ctx.registerService` in its `init()`, and declaring it in `providesServices`. The name is exported as `CRYPTO_PROVIDER_SERVICE` from `@objectstack/types`, beside its precedent `DATASOURCE_SECRET_RESOLVER_SERVICE`, so objectstack-ai/cloud imports it instead of a literal. No spec entry: `CoreServiceName` is the dispatcher's slot vocabulary, and the registered value's type is the existing `ICryptoProvider` contract, unchanged. `os serve` (`packages/cli/src/commands/serve.ts`): - **One host provider.** `createHostCryptoProvider` builds one `ICryptoProvider` that delegates to the provider the boot chooses, plus a small host plugin (`com.objectstack.cli.host-crypto-provider`) that keeps the plugin context. The choice is made at `kernel:ready`, after every `init()`: the registered provider when there is one, otherwise the deferred `LocalCryptoProvider` serve already built. That one instance is handed to the data engine (`setCryptoProvider`, secret fields), the datasource secret binder (datasource credentials) and the default `SettingsServicePlugin` (encrypted settings, through its existing `cryptoProvider` option). - **No registration.** The fallback is the same deferred `LocalCryptoProvider`, keyed off `OS_SECRET_KEY` or the persisted key file as before; its production refusal for an unstable key still fires at `kernel:ready`, unchanged. `LocalCryptoProvider` itself is not touched. The one difference on this path: the default settings service now shares that one local instance instead of constructing its own `new LocalCryptoProvider()` from the same key source (the seat accepted this; a test pins that a value either one sealed opens under the other). - **Two registrations refuse at boot.** Before `runtime.start()`, `refuseTwoCryptoProviderPlugins` refuses a composition where more than one plugin (config-mounted or resolver-mounted, deduplicated by name) declares `providesServices: ['crypto-provider']`, naming both. An undeclared second `registerService('crypto-provider')` is refused by the kernel itself (it names the service, not the plugins; recording the registrant in `kernel.ts` is out of scope per the claim). - **The deferred path.** The binder is built before plugins initialize, so it holds the host provider, which chooses nothing until every `init()` has run. A secret sealed or opened while plugins are still initializing is refused (`HostCryptoProviderRefusal`) rather than sealed by a provider the boot has not chosen. A credential written before the post-start wiring is sealed by the registered provider. - **After start.** `hostCrypto.provider.confirm()` refuses a provider registered after init (the boot had already chosen the local one) and a registered provider replaced after the choice. A value registered under the name that lacks the provider's methods refuses, naming the missing methods. Every refusal names the service and plugins, never a registered value. A `HostCryptoProviderRefusal` or the production key refusal stops the boot; any other wiring failure keeps the old warn-and-continue. ## Tests `packages/cli/src/commands/serve-host-crypto-provider.test.ts` (new, 16 tests, `integration` tier by `vitest-tiers.ts`), over a real `ObjectKernel`, the real host plugin, a real `ObjectQL` with a `secret` field, the real `SettingsService`, and a test KMS provider that wraps `LocalCryptoProvider` with its own key and records each scope it sealed: - a provider registered during init seals secret-field writes, datasource credentials and encrypted settings (scopes `object_secret_field`, `datasource_credential`, `settings`), and the local fallback is never built; - no registration: the local provider is chosen at `kernel:ready` from the same key source as `new LocalCryptoProvider()`; an encrypted setting opens under a `LocalCryptoProvider` built the original way and the other way round; a secret field sealed by it opens under the original provider; - two declaring plugins are refused before either initializes, both named; one plugin, or one plugin composed twice under its name, is not; an undeclared second registration is refused by the kernel; - the deferred path: a credential bound at `kernel:ready` is sealed by the registered provider; a seal during init is refused in both plugin orders and records nothing; late registration and replacement refuse; a non-provider value refuses naming what it lacks; - no refusal message carries the test keys (asserted on every refusal); - source pins on `run()`: one host provider over the deferred local one, its plugin mounted, the same provider handed to the binder, the default settings service and the engine, no `new LocalCryptoProvider(` left, and the declaration check before `runtime.start()`. ## Acceptance notes - **Out of this card's surface, noted for cloud#3573:** the one-shot CLI commands that open secrets outside `os serve` (`one-shot-settings.ts`, `os secret rewrap`, `os secret orphans`) build their own `LocalCryptoProvider` and do not consult a registered provider. A KMS-keyed deployment needs them adopted separately. - Plugins mounted by `registerInstalledPlugins` (installed packages) are not in the declaration check; the kernel's own refusal of a second registration still applies to them. - A `SettingsServicePlugin` the app composes itself (not the default capability) is not handed the host provider; it builds its own as before. - A plugin that declares the service but never registers it falls back to the local provider; it is not refused. - Owed before landing: the `CONTRACT_REVIEW_TIER` review named in the claim (the key-holder boundary). ## Verification <details><summary>Premise on main 256a4b7, and the same boot on this branch (real os dev --fresh boot)</summary> A probe app (outside the repo) mounts a plugin that registers a test provider as `crypto-provider` in `init()` and also calls `setCryptoProvider` with it in `start()`, then inserts a row into an object with a `secret` field and prints which scopes its provider sealed. On `main` 256a4b7, the plugin's provider is overwritten: ``` ↪ secret fields: LocalCryptoProvider wired (dev) — set OS_SECRET_KEY and swap for KMS/Vault in production PROBE engine holds the plugin provider itself: false | holder type: Object | keys: keySource,resolve,encrypt,decrypt,rotateKey,digest,keyedDigest PROBE secret-field insert ok PROBE scopes sealed by the plugin provider: [] ``` On this branch, the same probe: ``` ↪ secret fields: the crypto provider a plugin registered as 'crypto-provider' wired PROBE engine holds the plugin provider itself: false | holder type: Object | keys: encrypt,decrypt,rotateKey,digest,keyedDigest,resolve,confirm,source PROBE secret-field insert ok PROBE scopes sealed by the plugin provider: ["object_secret_field"] ``` (The engine holds the host provider, which delegates to the registered one: the plugin's provider sealed the write.) The same probe with the registration switched off keeps the original line `↪ secret fields: LocalCryptoProvider wired (dev)` and its provider seals nothing. </details> <details><summary>Tests</summary> ``` # head 2ff85da pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/serve-host-crypto-provider.test.ts src/commands/serve-audit-registration.contract.test.ts test/vitest-tiers-partition.test.ts Test Files 3 passed (3) Tests 42 passed (42) # the cli unit tier, head b308ea8 (one commit earlier) pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 Test Files 1 failed | 392 passed (393) Tests 1 failed | 5527 passed (5528) # the one red: serve-audit-registration.contract.test.ts, whose #9863 scan anchors on the text # "for (const plugin of plugins)" -- the new declaration check used the same text. # 2ff85da renames that loop; the file passes above. The commit changes nothing else a unit test reads. pnpm --filter @objectstack/types typecheck exit 0 NODE_OPTIONS=--max-old-space-size=6144 pnpm --filter @objectstack/cli typecheck exit 0 check:test-typecheck: OK -- 3 file(s) / 28 error(s) / 6 pinned signature(s) held (unchanged ledger) (at the default 2 GB heap the test-layer tsc ran out of memory; CI's lane uses 6144) ``` </details> <details><summary>Ablation: removing the registry read turns the first test red</summary> Via `scripts/ablation-replace.mjs` on `packages/cli/src/commands/serve.ts` (the test imports `serve.ts` from source): ``` anchor "read: () => ctx.getServices().get(CRYPTO_PROVIDER_SERVICE)," x1 -> x0 replace "read: () => undefined," x0 -> x1 blob 4e50bae -> fb89a43cd489 FAIL |integration| src/commands/serve-host-crypto-provider.test.ts > a provider a plugin registers during init seals every secret > seals secret-field writes, datasource credentials and encrypted settings, and never builds the local one Error: the local fallback was built although a provider is registered Tests 1 failed | 15 skipped (16) restored: blob == HEAD (4e50bae) and `git diff HEAD` is empty ``` </details> <details><summary>Gates</summary> ``` # head 2ff85da -- every family dispatch-gates derives (62), plus pnpm lint node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates.txt Run reconciliation -- 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. 62 x exit 0 pnpm lint exit 0 # repaired on the way (both in 2ff85da): # check:where-matcher -- the new test's sys_secret double read a $-key as a field name; it now refuses it # (unit tier) -- the #9863 anchor above # exit 3 before a whole-tree build (no dist), green after it: # check:i18n-coverage, check:dual-build-cjs-loads, check:dts-closure ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czs7cUFAphQEUFSrcz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcf8a37 commit 293a85b

5 files changed

Lines changed: 946 additions & 60 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/types': patch
4+
---
5+
6+
feat(cli): `os serve` seals secrets with the crypto provider a plugin registers, so a KMS- or Vault-backed provider can hold the keys
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A plugin supplies the host's secret crypto provider by registering an `ICryptoProvider` as the kernel service `crypto-provider` — `CRYPTO_PROVIDER_SERVICE`, new in `@objectstack/types` — with `ctx.registerService` in its `init()`, and declaring it in `providesServices`. `os serve` then hands that one provider to everything that seals a `sys_secret` row: the data engine (secret fields), the datasource secret binder (datasource credentials) and the default settings service (encrypted settings). Before, `os serve` built `LocalCryptoProvider` itself after every plugin hook and overwrote whatever a plugin had set.
12+
13+
Without a registration nothing changes: the host keeps `LocalCryptoProvider`, keyed off `OS_SECRET_KEY` or the persisted key file exactly as before, and a production boot without a stable key still refuses at `kernel:ready` with the same message. The one difference is that the default settings service now shares that local provider instead of building a second one from the same key source; what either sealed, the other opens.
14+
15+
The provider holds every secret's key, so the boot refuses rather than guess:
16+
17+
- two plugins that both declare `providesServices: ['crypto-provider']` refuse before either initializes, both named (an undeclared second `registerService` is refused by the kernel, naming the service);
18+
- a provider registered after plugin init, or the registered one replaced, refuses once the boot is up;
19+
- a value registered under the name that is not an `ICryptoProvider` refuses, naming the missing methods;
20+
- a secret sealed or opened while plugins are still initializing is refused, never sealed by a provider the boot has not chosen yet.
21+
22+
No refusal prints a registered value.

0 commit comments

Comments
 (0)