Repository navigation
fix(cli): a one-shot command never mints a data key in the key home (#21471) - #21497
Conversation
…the fix Adds the one-shot settings composition (utils/one-shot-settings.ts) and the two pins that hold it: the enumeration of every CLI module that names the settings plugin or the local crypto provider, and, across every bootSchemaStack caller and mode, an empty key home in a development posture staying empty, with the default composition minting there as the control. The commands still compose the default here, so the pins are red on this commit by design; the next commit moves the commands onto the helper. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
`os secret orphans` and the storage arm of the data-migration plugins (`os storage orphans`, `os migrate files-to-references`) composed the settings service with no crypto provider, so it built its default one, which in a development posture with no key writes a key file into the key home. Every one of them now composes the service through utils/one-shot-settings.ts: the provider over a key that already exists, in the strict posture with the auto-key opt-in withheld, or one that refuses every call. `os secret rewrap` moves onto the same helper, so there is one spelling. The two driver-contract tests boot the commands' own composition again. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 27 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 bb30c025cd2dcd8706dad18f98c382c876137609 && git checkout bb30c025cd2dcd8706dad18f98c382c876137609
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ff74285f14b7b60699546211e53e9ccc13c0d61 bda27b507349625ebc60538ceae017d8a1f7064e && git checkout -B drift-repro 9ff74285f14b7b60699546211e53e9ccc13c0d61 && git merge --no-ff bda27b507349625ebc60538ceae017d8a1f7064e
node scripts/docs-audit/affected-docs.mjs --json 9ff74285f14b7b60699546211e53e9ccc13c0d61
|
|
Landing note: one commit status is pending, and it is not a check run · On head
This diff touches no deployed app, so the seat readies and arms this PR and does not wait on the stuck vendor status. The merge queue runs its own Generated by Claude Code |
…e harness seals under an in-process data key (objectstack-ai#21507) Fixes objectstack-ai#21499 Clause-②: no ## What changed `bootStack` (`packages/verify/src/harness.ts`) composed the settings service with no crypto provider and bound the engine to a bare `LocalCryptoProvider`. `bootStack` forces a development posture, where both providers resolve a data key the way a server does: an env key, then the key file in the key home, and with neither they mint the key file. So `os verify`, a one-shot command over an in-memory database, left key material in the key home. On a host that already had a key, the harness sealed its throwaway fixtures under that real key. The harness now holds ONE `LocalCryptoProvider` over an explicit random key (`harnessCryptoProvider`, module-private): - no env read, no key-file read, no write anywhere: the key never leaves the process's memory; - the settings service (`new SettingsServicePlugin({ cryptoProvider })`) and the engine (`setCryptoProvider`) get the same instance, so `secret` fields and encrypted settings still seal AND open on a keyless host. That is why the CLI's one-shot shape from PR objectstack-ai#21497 ("read an existing key, or refuse every call") does not fit here; - one key per PROCESS, not per boot: two boots over one `databaseFile` (the harness's restart) open each other's secrets, as a real host's stable key would let them. ## Shape choices, measured (dispatch zone 2) - **A1, confirmed at base `6f17d1d364`.** `harness.ts:498` was `new SettingsServicePlugin()` with no provider, and `:694` was `engine.setCryptoProvider(new LocalCryptoProvider())`. `packages/cli/src/commands/verify.ts:195` and `:219` call `bootStack(config, { multiTenant })` and `bootStack(config, { multiTenant, security })`. `BootOptions` has no crypto option. - **A2: no public option is needed.** No caller of `bootStack` in this repository passes or needs a key: every boot is an in-memory database or a caller-owned temp file. So the harness owns its provider internally. `BootOptions` and every export are unchanged, `Clause-②: no` holds, and `packages/cli/src/commands/verify.ts` is untouched. - **Per process, not per boot.** Measured by ablation (below): a per-boot key breaks the restart case with an AES-GCM authentication failure. ## Evidence **Public door.** Built CLI, `examples/app-todo`, `os verify --json`, development posture, no env key, a fresh empty key home (`OS_HOME`): | `@objectstack/verify` built from | exit | stdout | key home after | stderr line announcing a minted key | |---|---|---|---|---| | the base harness (`6f17d1d364`, rebuilt; dist preflight: marker absent) | 1 | 764 B | `dev-crypto-key` | 1 | | this PR (rebuilt; dist preflight: marker present) | 1 | 764 B, byte-identical | empty | 0 | Both runs exit 1 on the same pre-existing fidelity gap on `todo_task.tags` (see Acceptance notes). It is unrelated to this change. **Pin** `packages/verify/src/harness.key-custody.test.ts`. Every boot runs in a hook, and the cases only assert. - Committed red first (`465c22036a`), against the unfixed harness: 4 failed, 3 passed. - The empty key home gained `dev-crypto-key`. - Both sealed rows opened under a pre-existing key file's key: `[true, true]`. - Both sealed rows opened under `OS_SECRET_KEY`: `[true, true]`. - The restart case's key home gained `dev-crypto-key`. - The control stayed green: the default provider reports `generated-file` in this posture and home. - With the change (`7c3a1843ce`): 7 passed. - Ablation, a per-boot key in place of the per-process one, through `scripts/ablation-replace.mjs`: - the anchor went 1 to 0 and the blob changed; - after the restore, the blob equals HEAD and `git diff HEAD` is empty; - the restart describe's hook fails with `Unsupported state or unable to authenticate data` (6 passed, 1 skipped, file red). - The pin imports `./harness.js` from source, so no `dist/` leg applies to it. **Local runs at HEAD `e8a091457c`:** - `pnpm --filter @objectstack/verify exec vitest run`: 17 files, 127 tests passed. - `pnpm --filter @objectstack/verify typecheck`: exit 0, tsc plus the test layer. `--listFiles` on the test config holds the new test (17 of 17 test files). - `node scripts/pm/dispatch-gates.mjs --commands`: 62 families derived and all 62 run. The `--ran` reconciliation reads 0 NOT-MEASURED and 0 UNRUN. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 9 packages had no `dist/`). After a cache-replay build of those 9 it exited 0. - `pnpm lint` over the whole repository, unnarrowed: exit 0. Not run locally: CI's full suites, and the CLI integration tier (no `packages/cli` file is touched). ## Serial note PR objectstack-ai#21497 adds an enumeration pin over `packages/cli/src`. It had not landed when this PR was opened, and this branch sits on `6f17d1d364`. This diff touches no `packages/cli` file, so that pin's population is unchanged. The seat merges `main` and re-runs that pin at landing. ## Acceptance notes - `os verify --json` on `examples/app-todo` exits 1 on `main` with one fidelity gap. The derived write puts the scalar `important` into `todo_task.tags`, a `select` with `multiple: true`, and reads back the array `["important"]`. It is reported to the seat as a finding and is not touched here. - Every `bootStack` under vitest also stops minting into the runner's key home. The harness forces a development posture, so the old default minted there too. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21471
Clause-②: no
What changed
os secret orphansis a report that promises to write nothing. It composed the settings service with no crypto provider, so the service built its default one. In a development posture with no env key and no key file, that default creates a key file in the key home. The database stayed untouched, but the key home did not, and the next development-posture process on that host adopted the minted key. The storage arm of the data-migration plugins (os storage orphans, also report-only, andos migrate files-to-references) composed the service the same way.packages/cli/src/utils/one-shot-settings.tsholds the idiomos secret rewrapalready used, moved rather than copied:resolveExistingDataKey()builds the provider over a key that already exists, in the strict posture with the auto-key opt-in withheld, so it never mints;refusingCryptoProvider()refuses every call and names why;oneShotSettingsPlugin()hands the settings service one of those two, never the default.secret/orphans.tsandutils/data-migration-plugins.tscompose through it.secret/rewrap.tsmoves onto it, so there is one spelling, not two.rewrapsince its provider change.@objectstack/clipatch (.changeset/21471-one-shot-never-mints-key.md).Census: every CLI composition of the settings service, by any spelling
How the census was built:
packages/cli/srcwhose comment-masked code namesSettingsServicePlugin,LocalCryptoProvideror its deprecated alias (the enumeration pin's own detector; atda6acc015dit named exactly the four source composers below plus the new helper);os secret orphans(report and--delete)commands/secret/orphans.tsda6acc015d, green atbda27b5073os storage orphansutils/data-migration-plugins.tsda6acc015d, green atbda27b5073os migrate files-to-references(dry run and--apply)da6acc015d, green atbda27b5073. It needs the real key when one exists, because the storage plugin reads its stored credentials through the settings service. That is why the composition reads an existing key rather than always refusingos secret rewrapcommands/secret/rewrap.tsos serve, andos dev/os start, which spawn itsettingsrow; two default providers for secret fieldsos migrate plan/os migrate apply(composeHostStack)start()suppressed. The settings plugin builds its default from a hook registered instart(). Both are green in the family pinbootSchemaStackcallers (meta resync,migrateaccount-issuer/audit-metadata-bodies/duplicates/meta --stored/multi-value-columns/recorded-by/resume/summary-nulls/value-shapes)os verify@objectstack/verify's boot harness, in another packagePins
src/utils/one-shot-settings.pin.test.ts(unit tier)commands/serve.ts, failing by file name. A self-check confirms the detector ignores prose and sees a renamed destructure, the capability-table string and the alias.src/utils/schema-migrate.one-shot-family.integration.test.ts(integration tier, by its existingbootSchemaStackimport). It gains a third promise across the source-derived family: in a development posture with an empty key home, every mode of everybootSchemaStackcaller leaves the home empty.new SettingsServicePlugin({ registerRoutes: false }), the composition the report used to pass, leaves exactly one key file there.Verification (head
bda27b5073; red leg atda6acc015d)da6acc015d) and run against the unfixed commands:commands/secret/orphans.ts,commands/secret/rewrap.tsandutils/data-migration-plugins.ts(7 of 8 tests passed, the control included);migrate files-to-references,secret orphans,storage orphans,migrate files-to-references --applyandsecret orphans --delete, each as "key material was created in the key home", with the key file present.bda27b5073.vitest run src/utils/one-shot-settings.pin.test.ts: 8 / 8 passed.vitest run --project integrationover the family file and both driver-contract files: 3 files, 85 / 85 passed.orphans.guards,rewrap.guards,summary-nulls,sys-secret-rewrap), 4 files, 37 passed.pnpm --filter @objectstack/cli typecheck(tsc pluscheck:test-typecheck): exit 0, with no new test-typecheck debt.os secret orphans --json, in a development posture with an empty key home, left the home empty.pnpm lint(eslint . --no-inline-config, the whole repo): exit 0 atbda27b5073, not narrowed.dispatch-gates.mjs --ranwith no paths: 64 derived, 64 run, every one exit 0, and 0 NOT MEASURED (a derived zero, from recorded exit codes). Four gates first refused on a missing build (exit 3, nothing measured). After the prerequisite builds they were re-run to exit 0:check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parity.origin/main. It is three commits ahead, and none of them touches these files. The derivation's stale-tree note namesscripts/engine-double-contract.pinned.json, which this diff does not touch.Acceptance notes
os secret rewrapalready did. stdout and--jsonare unchanged.nullwith a warning. A freshly minted key produced the same, because it can open nothing stored.packages/cli/src. A composition inside another package that a command calls into names nothing there; the pin header says so, and the census lists the one that exists.Out of scope (reported to the seat, not filed here)
os verifyreaches@objectstack/verify's boot harness. The harness composes the settings plugin with no provider and also sets a default local provider on the engine for secret fields.examples/app-todo, in a development posture with an empty key home: after the run, the key home held a key file.packages/cli. And it needs a different provider shape: the harness seals and opens secret fields against an in-memory database, so a read-or-refuse provider would breakos verifyon a keyless host where an ephemeral in-process key would not.Generated by Claude Code