Repository navigation
fix(cli): one-shot secret commands seal with the crypto provider os serve uses - #31228
Conversation
…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
…e top Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5kDtnUcfoZfMJqLNWd
📓 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:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 35 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 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
|
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ 维护者速读
席位意见:接受。三种情形与安全复核 6690506028 的「失败即关闭、不降级」一致;代价是配置读不了的项目上这几个命令会拒绝而不是照跑,这是有意的。开发者另报的 席位核对 — ACCEPT ·
|
Requested by Leehom · project thread
Fixes #31007
Clause-②: no
Metadata-protocol: none
维护者速读
os secret rewrap、os secret orphans、带设置服务的数据迁移)不再自建本地密钥提供方,而是用os serve同一个选择函数createHostCryptoProvider:宿主配置里有插件声明crypto-provider就启动它并用它加解密;两个声明拒绝并点名两者;没有声明照旧用已存在的本地密钥。os serveseals secrets with the crypto provider a plugin registers #30993 之后serve用插件注册的 KMS/Vault 提供方封存密钥,这些一次性命令仍用本地密钥,打不开 serve 封存的值;rewrap甚至可能把行改封到本地密钥下(降级)。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: newoneShotCrypto({ cwd, env })composes a one-shot boot's crypto throughos serve's own function,createHostCryptoProvider. It reads the host config'splugins(stackBootPlugins(config, false)), materializes each entry the wayservedoes (materializeStackPlugin,Serve.importConfigPluginanchored at the config's directory), and keeps the plugins that declareprovidesServices: ['crypto-provider'](cryptoProviderPlugins).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 atkernel:ready. The local key is never resolved.refuseTwoCryptoProviderPlugins(serve's) refuses before either initializes, both named.plugins[i]. Whether a provider is declared cannot be read, and "none" would be the fallback the security read forbids.oneShotSettingsPlugin()becomesoneShotSettingsPlugins(): 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 fromrefuseTwoCryptoProviderPlugins, so serve's two-provider refusal and the one-shot reader share one reading of "who declares the provider". No behaviour change inserve.packages/cli/src/commands/secret/rewrap.ts: composesoneShotCrypto()before the boot (refusals there answercrypto_provider_refused), spreads its plugins into the boot, and after the boot confirms the chosen provider withserve'sconfirm(). 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'stry/catcharound the settings service used to swallow every failure as "optional". It now rethrows aHostCryptoProviderRefusal, so a declared provider the run cannot read stops the run..changeset/31007-one-shot-crypto-provider.md:@objectstack/clipatch, both declaration lines.Tests
packages/cli/src/utils/one-shot-crypto-provider.test.ts(8 cases). They mirrorserve-host-crypto-provider.test.ts: each writes a host config into a fresh directory, composesoneShotCrypto, and boots the composition in a realObjectKernel.sourceisregistered,confirm()returns the plugin's provider, the seal carries itskmsKeyIdand does not open under the local key, the settings service holds the host provider, andkeyisnull(local never resolved).sourceislocal. What it seals opens under aLocalCryptoProviderbuilt the original way, and the other way round. With no key in production, every call refuses with the reason.HostCryptoProviderRefusalnaming'com.example.kms-a' and 'com.example.kms-b', and neitherinit()ran.init()throws (refusal names the plugin and its cause); boots but registers none (refused atkernel:ready, naming it; later calls refuse too); config throws at load (refusal names the file); apluginsentry that does not resolve (refusal namesplugins[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):OS_SECRET_KEYremoved: the dry run re-wraps 2 rows with 0 unreadable, and the report's key source iscrypto provider 'com.example.kms'.init()throws, with the deployment's old local key still inOS_SECRET_KEYand a held version-1 row still to re-wrap.--apply --yesanswersboot_failednaming'com.example.kms-offline', exit 1, andsys_secretreads back equal to before.scripts/ablation-replace.mjs: anchorif (declared) {hit once, blob17673a5a→f0975a50, i.e. the declared provider ignored and the local key used): the case reds withexpected undefined to be 'boot_failed', and the run's report readskeySource: 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 HEADempty. The test imports the module as relative source, so no build sits on the path.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'sinit()andkernel:readyas a kernel would), the two driver-contract suites (plural composition).Acceptance notes
start()runs too), asservecomposes it. The alternative,composeProviderForDeclarations(start suppressed), would leave a provider that connects instart()unusable in every one-shot. A plugin whose dependencies the one-shot boot lacks fails the boot, and the refusal names it.plugins, notdevPlugins. That is the listos serveboots without--dev, andos start. A provider declared only indevPluginsis not read here.crypto-providerwithout 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 itrefused_unreadableand writes nothing.serveitself could not load now refuses these commands instead of being ignored.os plugin install) sit outside feat(cli):os serveseals 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 leavesserveon the local provider. Not touched here (serve path, not this card); noted for the seat.Verification
All readings at head
e82bb064(after mergingorigin/mainat4c318214).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 63 families, every one run with its exit code captured before any pipe.--ranreconciliation:✓ 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-configover every touched cli file → exit 0.unittier,vitest run --project unit→Test Files 399 passed (399),Tests 5630 passed (5630).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).bootSchemaStackcaller, soschema-migrate.one-shot-family.integration.test.tswas run locally:Tests 164 passed (164). The rest of theintegrationtier is declared to CI.Ablation output
Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (not touched)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code