Skip to content

fix(cli): one-shot secret commands seal with the crypto provider os serve uses - #31228

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-3ca9rv
Nov 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-3ca9rv

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #31007

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:一次性 CLI 密钥命令(os secret rewrap、os secret orphans、带设置服务的数据迁移)不再自建本地密钥提供方,而是用 os serve 同一个选择函数 createHostCryptoProvider:宿主配置里有插件声明 crypto-provider 就启动它并用它加解密;两个声明拒绝并点名两者;没有声明照旧用已存在的本地密钥。
  • 为什么改:feat(cli): os serve seals secrets with the crypto provider a plugin registers #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 feat(cli): os serve seals secrets with the crypto provider a plugin registers #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.
Ablation output
ablation-replace: anchor   "  if (declared) {"  x1 (before)
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 17673a5a70cd -> 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 (17673a5a70cd) and `git diff HEAD` is empty

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…erve uses

The one-shot composition (os secret rewrap, os secret orphans, the data
migrations' settings arm) built its own LocalCryptoProvider. It now chooses
through serve's createHostCryptoProvider over the plugins the host config
declares, boots a declared provider, and refuses naming it when it cannot
be booted, registers none, or the config cannot be read.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5kDtnUcfoZfMJqLNWd
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 7, 2026
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 16 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os secret orphans (command, read off packages/cli/src/commands/secret/orphans.ts), os secret rewrap (command, read off packages/cli/src/commands/secret/rewrap.ts))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via os secret orphans (command, read off packages/cli/src/commands/secret/orphans.ts), os secret rewrap (command, read off packages/cli/src/commands/secret/rewrap.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 47 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fe97d85b060b8314880e8ee5f6f897ffbb300bcd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4d47679c2433f7af12b32dd2cb12422a7b961071 — the merge of head e82bb064300493750b2a3fdce690542839101a7f into base fe97d85b060b8314880e8ee5f6f897ffbb300bcd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4d47679c2433f7af12b32dd2cb12422a7b961071 && git checkout 4d47679c2433f7af12b32dd2cb12422a7b961071
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fe97d85b060b8314880e8ee5f6f897ffbb300bcd e82bb064300493750b2a3fdce690542839101a7f && git checkout -B drift-repro fe97d85b060b8314880e8ee5f6f897ffbb300bcd && git merge --no-ff e82bb064300493750b2a3fdce690542839101a7f

node scripts/docs-audit/affected-docs.mjs --json fe97d85b060b8314880e8ee5f6f897ffbb300bcd

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fe97d85b060b8314880e8ee5f6f897ffbb300bcd → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

维护者速读

  • 改了什么:一次性 CLI 密钥命令(os secret rewrap、os secret orphans、带设置服务的数据迁移)不再自建本地密钥提供方,而是用 os serve 同一个选择函数 createHostCryptoProvider:宿主配置里有插件声明 crypto-provider 就启动它并用它加解密;两个声明拒绝并点名两者;没有声明照旧用已存在的本地密钥。
  • 为什么改:feat(cli): os serve seals secrets with the crypto provider a plugin registers #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)。
  • 需要你做的:无,席位落地。

席位意见:接受。三种情形与安全复核 6690506028 的「失败即关闭、不降级」一致;代价是配置读不了的项目上这几个命令会拒绝而不是照跑,这是有意的。开发者另报的 os serve 插件加载失败后继续启动的问题,席位已核对代码,另行保密处理,不在本 PR。

席位核对 — ACCEPT · domain:server#5 · Head-sha e82bb064300493750b2a3fdce690542839101a7f · 2026-11-07T19:02Z

  • Shape: 11 files, +800/−76, read from the diff (non-test: one-shot-settings.ts +183/−12, rewrap.ts +80/−24, serve.ts +25/−11, data-migration-plugins.ts, orphans.ts). serve.ts only extracts cryptoProviderPlugins from refuseTwoCryptoProviderPlugins (same reading, now shared). oneShotCrypto reads the host config's plugins (never devPlugins) by serve's own rules, refuses on an unreadable config or entry, refuses two declarers, and composes the declarer + createHostCryptoProvider + settings service; the declared path's local fallback is a refusal, and the declarer's init() failure is re-thrown naming it. data-migration-plugins.ts: the storage arm's optional settings no longer swallows a HostCryptoProviderRefusal.
  • Premise measured: on main one-shot-settings.ts built LocalCryptoProvider for every one-shot run; Blocked-by: #30948 closed.
  • Pins: one-shot-crypto-provider.test.ts (one declarer boots and seals/opens; two refused naming both; boot failure / registers none / unreadable config or entry refused naming it), rewrap driver contract (refusal writes nothing, rows read back equal; ablated red under env:OS_SECRET_KEY and restored). Report 6711449188.
  • Gates: head check-runs 35 success, 13 skipped, none red or pending. check-expected-skips: OK, all 13 in main's roster (exit 0). check-governed-merges: no governed path, 876 changed lines ≤ 3000. The dev's dispatch-gates --commands: 63 derived, 63 run, all exit 0. Mergeable state clean.
  • Accepted deviations: thread-default effort (claim records it); lint run per touched file; refusal case ordered last in the driver contract because a failed --json boot keeps stdout reserved. New --json error string crypto_provider_refused on os secret rewrap is CLI output, not a ledger code or spec accept set: Clause-②: no holds.
  • Out of scope: (1) serve's stack-plugin loop continues past a plugin that fails to load: verified by the seat at serve.ts (the ✗ Failed to load plugin catch), routed confidentially, ⛔ not as a public issue. (2) An undeclared provider is not booted by a one-shot run; it cannot downgrade (the local key opens nothing it sealed; rewrap counts those refused_unreadable and writes nothing). (3) Installed plugins outside the declaration check stay on the card's last line, routed by triage. Metadata-protocol: none, Skills-docs: n/a. Changeset @objectstack/cli patch.
  • Landing: ungoverned. Fixes #31007. Ready + auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 7, 2026 19:04
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 7, 2026 19:04
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit d3f3686 Nov 7, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-3ca9rv branch November 7, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant