You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d3f3686
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cli): one-shot secret commands seal with the crypto provider os serve uses (#31228)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQSKftazBL1M6YfL5N8dHSC)_Fixes#31007
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:一次性 CLI 密钥命令(`os secret rewrap`、`os secret
orphans`、带设置服务的数据迁移)不再自建本地密钥提供方,而是用 `os serve` 同一个选择函数
`createHostCryptoProvider`:宿主配置里有插件声明 `crypto-provider`
就启动它并用它加解密;两个声明拒绝并点名两者;没有声明照旧用已存在的本地密钥。
- 为什么改:#30993 之后 `serve` 用插件注册的 KMS/Vault 提供方封存密钥,这些一次性命令仍用本地密钥,打不开
serve 封存的值;`rewrap` 甚至可能把行改封到本地密钥下(降级)。
- 风险与回滚:失败即关闭:声明的提供方启动失败、启动后没注册、或宿主配置/插件条目读不了,命令都拒绝、点名、不写任何行,⛔
不回退本地密钥。新增一个读配置的步骤,配置坏了的项目上这些命令会从「照跑」变成「拒绝」。回滚即 revert。
- 先看:`packages/cli/src/utils/one-shot-settings.ts`(`oneShotCrypto` /
`readDeclaredCryptoProvider`,拒绝分支);`packages/cli/src/commands/secret/rewrap.ts`(启动失败与
`confirm()` 的拒绝点);`packages/cli/src/commands/serve.ts` 抽出的
`cryptoProviderPlugins`。
- 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run
src/utils/one-shot-crypto-provider.test.ts
src/commands/secret/rewrap.driver-contract.test.ts`,预期 `Tests 15 passed
(15)`。
- 需要你做的:无,席位落地。
## What changes
- **`packages/cli/src/utils/one-shot-settings.ts`**: new
`oneShotCrypto({ cwd, env })` composes a one-shot boot's crypto through
`os serve`'s own function, `createHostCryptoProvider`. It reads the host
config's `plugins` (`stackBootPlugins(config, false)`), materializes
each entry the way `serve` does (`materializeStackPlugin`,
`Serve.importConfigPlugin` anchored at the config's directory), and
keeps the plugins that declare `providesServices: ['crypto-provider']`
(`cryptoProviderPlugins`).
- **One declaring plugin**: it is composed into the boot and the run
seals and opens with the provider it registers. Its `init()` is wrapped
so a throw refuses naming it. The host seam's local fallback for that
deployment is a refusal naming it, so a plugin that boots and registers
nothing stops the boot at `kernel:ready`. The local key is never
resolved.
- **Two**: `refuseTwoCryptoProviderPlugins` (serve's) refuses before
either initializes, both named.
- **None, or no host config**: as before — the local provider over the
key that already exists, strict posture, never minting. With no key, a
provider that refuses every call.
- **Config that does not load, or an entry that does not materialize**:
refused, naming the file or `plugins[i]`. Whether a provider is declared
cannot be read, and "none" would be the fallback the security read
forbids.
- `oneShotSettingsPlugin()` becomes `oneShotSettingsPlugins()`: it
returns the composition's plugin list (declared provider plugin, host
crypto seam, settings service over the seam's provider), spread into the
boot.
- **`packages/cli/src/commands/serve.ts`**:
`cryptoProviderPlugins(composed)` is extracted from
`refuseTwoCryptoProviderPlugins`, so serve's two-provider refusal and
the one-shot reader share one reading of "who declares the provider". No
behaviour change in `serve`.
- **`packages/cli/src/commands/secret/rewrap.ts`**: composes
`oneShotCrypto()` before the boot (refusals there answer
`crypto_provider_refused`), spreads its plugins into the boot, and after
the boot confirms the chosen provider with `serve`'s `confirm()`. A boot
failure with a declared provider names it. A row to open needs either a
confirmed provider or, on the local path, an existing key
(`crypto_key_unavailable`, unchanged). The report's key source names the
provider: `crypto provider 'PLUGIN'`.
- **`packages/cli/src/utils/data-migration-plugins.ts`**,
**`packages/cli/src/commands/secret/orphans.ts`**: spread the plural
composition. The storage arm's `try/catch` around the settings service
used to swallow every failure as "optional". It now rethrows a
`HostCryptoProviderRefusal`, so a declared provider the run cannot read
stops the run.
- **`.changeset/31007-one-shot-crypto-provider.md`**: `@objectstack/cli`
patch, both declaration lines.
## Tests
- New **`packages/cli/src/utils/one-shot-crypto-provider.test.ts`** (8
cases). They mirror `serve-host-crypto-provider.test.ts`: each writes a
host config into a fresh directory, composes `oneShotCrypto`, and boots
the composition in a **real `ObjectKernel`**.
- One declared and registered provider: `source` is `registered`,
`confirm()` returns the plugin's provider, the seal carries its
`kmsKeyId` and does not open under the local key, the settings service
holds the host provider, and `key` is `null` (local never resolved).
- None declared (no config, and a config with an unrelated plugin):
`source` is `local`. What it seals opens under a `LocalCryptoProvider`
built the original way, and the other way round. With no key in
production, every call refuses with the reason.
- Two declared: `HostCryptoProviderRefusal` naming `'com.example.kms-a'
and 'com.example.kms-b'`, and neither `init()` ran.
- Fail closed, each with a usable local key present: `init()` throws
(refusal names the plugin and its cause); boots but registers none
(refused at `kernel:ready`, naming it; later calls refuse too); config
throws at load (refusal names the file); a `plugins` entry that does not
resolve (refusal names `plugins[0] ('…')`). Every refusal is checked for
key bytes.
- **`packages/cli/src/commands/secret/rewrap.driver-contract.test.ts`**
(+2, real SQLite boot through the command):
- A declared provider that registers a provider over the sealing key,
with `OS_SECRET_KEY` removed: the dry run re-wraps 2 rows with 0
unreadable, and the report's key source is `crypto provider
'com.example.kms'`.
- **The security read's refusal case**: a declared provider whose
`init()` throws, with the deployment's old local key still in
`OS_SECRET_KEY` and a held version-1 row still to re-wrap. `--apply
--yes` answers `boot_failed` naming `'com.example.kms-offline'`, exit 1,
and `sys_secret` reads back equal to before.
- **Ablation** of that refusal (`scripts/ablation-replace.mjs`: anchor `
if (declared) {` hit once, blob `17673a5a` → `f0975a50`, i.e. the
declared provider ignored and the local key used): the case reds with
`expected undefined to be 'boot_failed'`, and the run's report reads
`keySource: env:OS_SECRET_KEY`, `rewrap: 3`. The rows were re-sealed
under the local key, the downgrade the security read forbids. Restore:
blob equals HEAD (`17673a5a`), `git diff HEAD` empty. The test imports
the module as relative source, so no build sits on the path.
- Updated: `one-shot-settings.pin.test.ts` (the helper's no-key and
key-file cases read the provider the host seam chooses),
`rewrap.guards.test.ts` (the mocked boot drives the host seam's `init()`
and `kernel:ready` as a kernel would), the two driver-contract suites
(plural composition).
## Acceptance notes
- **Measured arm of the card: "use it" when it boots, "refuse naming it"
when it cannot.** The one-shot boots compose no host plugin today. The
declaring plugin is composed in full (its `start()` runs too), as
`serve` composes it. The alternative, `composeProviderForDeclarations`
(start suppressed), would leave a provider that connects in `start()`
unusable in every one-shot. A plugin whose dependencies the one-shot
boot lacks fails the boot, and the refusal names it.
- **Only `plugins`, not `devPlugins`.** That is the list `os serve`
boots without `--dev`, and `os start`. A provider declared only in
`devPlugins` is not read here.
- **Undeclared registrations.** A plugin that registers
`crypto-provider` without declaring it is not booted by a one-shot run,
so the run takes the local path. Reading the provider off stored rows
would close that, but nothing asks for it. It cannot downgrade a row
such a provider sealed: the local key does not open it, so rewrap counts
it `refused_unreadable` and writes nothing.
- **New refusal surface.** Every one-shot command now loads the host
config when one is present. A config `serve` itself could not load now
refuses these commands instead of being ignored.
- Out of scope per the card: installed plugins (`os plugin install`) sit
outside #30993's declaration check.
- `serve`'s own loop logs and continues past a stack plugin that fails
to load (`✗ Failed to load plugin`), so a provider plugin that fails to
import there leaves `serve` on the local provider. Not touched here
(serve path, not this card); noted for the seat.
## Verification
All readings at head `e82bb064` (after merging `origin/main` at
`4c318214`).
- Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` → 63 families, every one run with its exit
code captured before any pipe. `--ran` reconciliation: `✓ dispatch-gates
--ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED`.
All 63 exit 0.
- `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit && pnpm
check:test-typecheck`) → exit 0; `check:test-typecheck: OK`.
- `eslint --no-inline-config` over every touched cli file → exit 0.
- cli `unit` tier, `vitest run --project unit` → `Test Files 399 passed
(399)`, `Tests 5630 passed (5630)`.
- Touched suites (unit + integration): `one-shot-crypto-provider`,
`one-shot-settings.pin`, `rewrap.guards`, `rewrap.driver-contract`,
`orphans.guards`, `orphans.driver-contract`,
`serve-host-crypto-provider` → `Test Files 7 passed (7)`, `Tests 79
passed (79)`.
- The composition reaches every `bootSchemaStack` caller, so
`schema-migrate.one-shot-family.integration.test.ts` was run locally:
`Tests 164 passed (164)`. The rest of the `integration` tier is declared
to CI.
<details><summary>Ablation output</summary>
```text
ablation-replace: anchor " if (declared) {" x1 (before)
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 17673a5 -> f0975a50d53f
× [#31007] a declared provider that cannot boot: --apply refuses naming it, and every stored value is byte-equal
AssertionError: {"mode":"apply","report":{"mode":"apply","keySource":"env:OS_SECRET_KEY", … "counts":{"total":5,"rewrap":3, … : expected undefined to be 'boot_failed'
ablation-replace: ok restored: blob == HEAD (17673a5) and `git diff HEAD` is empty
```
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (not touched)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5kDtnUcfoZfMJqLNWd)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cli): one-shot secret commands seal and open with the crypto provider `os serve` uses
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
`os secret rewrap`, `os secret orphans` and the data migrations that compose the settings service (`os migrate files-to-references`, `os storage orphans`, `os migrate recompute-home-amounts`) used to build their own `LocalCryptoProvider`. On a deployment whose plugin registers a KMS- or Vault-backed provider as the `crypto-provider` service, they could open nothing `os serve` sealed, and a re-wrap could have re-sealed under the local key.
11
+
12
+
They now choose the provider with `os serve`'s own function. They read the host config's `plugins`, as `os serve` does without `--dev`:
13
+
14
+
- One plugin declares `providesServices: ['crypto-provider']`: the run boots it and seals and opens with the provider it registers.
15
+
- Two plugins declare it: the run refuses before either initializes, and names both.
16
+
- No plugin declares it: nothing changes. The run uses the local provider over the key that already exists, and never mints one.
17
+
18
+
The run fails closed and never falls back to the local key for a deployment that declares a provider. It refuses, names the provider, and writes nothing when:
19
+
20
+
- the declared plugin cannot boot in the run;
21
+
- the declared plugin boots but registers no provider;
22
+
- the host config, or one of its `plugins` entries, cannot be loaded.
23
+
24
+
`os secret rewrap` names the provider as the report's key source.
0 commit comments