Repository navigation
Commit 293a85b
feat(cli):
<!-- 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>os serve seals secrets with the crypto provider a plugin registers (#30993)1 parent bcf8a37 commit 293a85b
5 files changed
Lines changed: 946 additions & 60 deletions
File tree
- .changeset
- packages
- cli/src/commands
- types/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
0 commit comments