Skip to content

Commit f6f663a

Browse files
fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production (#30087)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB6KCC4KfBqy33ZRD1NPFK95)_ Fixes #30042 ## 维护者速读 - 改了什么:两个数据密钥文件(共享的 `$HOME/.objectstack/dev-crypto-key` 与项目里旧 `os start` 留下的那份)不一致时,CLI 不再替子进程挑;`serve` 子进程在数据库打开后,用每把钥匙试开最新一条 `sys_secret`,用能打开的那把并说明选了哪个文件;两把都打不开时,生产启动拒绝启动并点名两个文件,开发启动打印启动错误。一次性命令(`os migrate`、`os secret rewrap/orphans`)走同一套解析和试开;`OS_HOME` 里开头的 `~` 现在按 `os start` 的方式展开。 - 为什么改:#30040 之后,只跑过 `os start` 的项目若机器上另有别的项目 `os dev` 生成的家目录密钥,会用错钥匙启动,所有已存密钥读出来都是"未设置",只留一行日志。这是下个版本会带出去的回归(p1)。 - 风险与回滚:风险在生产启动多了一种拒启(两把钥匙都打不开已存密钥时),这正是 `LocalCryptoProvider` 的 fail-loud 约定;serve 的共享 crypto provider 改为首次使用时才构造。回滚:revert 本 PR 即回到 #30040 的行为,不改任何密文、不重新封装。 - 先看:`packages/services/service-settings/src/local-crypto-provider.ts`(`chooseKeyFileByTrialOpen` / `settleKeyFileChannel` / `keyFilePath`)、`packages/services/service-settings/src/settings-service-plugin.ts`(`start()` 里的选钥)、`packages/cli/src/commands/serve.ts`(共享 provider 延迟构造)。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run --project integration test/dev-start-shared-data-key.integration.test.ts`,预期 6/6 通过:两把钥匙时 `os start` 读回密钥,两把都打不开时生产启动以 "Refusing to start in production" 退出并点名两个文件。 - 需要你做的:无,席位落地。 Clause-②: yes (narrowing) Metadata-protocol: none The `yes` is the new `@objectstack/service-settings` exports; the `(narrowing)` arm is the boot refusal: a production `os start` / `os serve` whose two candidate key files both fail to open the newest stored secret now refuses to start, where it used to start and serve every stored secret as unset. No key of any schema, export or stored row changes shape, so `Metadata-protocol: none` and `patch`; the changeset carries the BREAKING banner, the remedy and `not-required (no-migration-prescription)`. ## What changes - **The choice moves to where the database is open.** `applyCryptoKeyFileChannel` (CLI parent) still resolves `resolvePersistedKeyFile`. On `both-differ` it no longer picks: it names the canonical file on `OS_INTERNAL_CRYPTO_KEY_FILE` and the legacy file on one new parent-owned channel, `OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE` (deleted on every other answer, so an inherited copy never speaks). - **`SettingsServicePlugin.start()` settles it** (`settleKeyFileChannel`), in Phase 2: objectql has started (optional dependency), so the table is readable, and every consumer that decrypts does so at `kernel:ready` or later. `storedSecretReaderOverEngine` reads the newest `sys_secret` rows as the system and takes the first whose marker the provider knows; `chooseKeyFileByTrialOpen` opens it under each file's key (canonical first), once per member of `CRYPTO_CONTEXT_SCOPES` because `sys_secret` records no producer, and discards the plaintext. The winner is written back to the channel, the candidate is deleted, and the boot prints which file and why. - **Postures, measured against the provider's fail-loud contract.** Neither key opens it: production throws `DataKeyOpensNothingError` ("Refusing to start in production…", both paths, the remedy), which fails the plugin's `start()` and so the boot (`rollbackOnFailure`); development and test keep the canonical file and print a `console.error` naming both. No stored row: the canonical file, as today. A read that fails other than a missing table: the canonical file with a boot error naming the read (no verdict is invented from a failed read). - **No provider is built before the choice.** `serve.ts` builds the shared provider (datasource binder + secret fields) with `deferredLocalCryptoProvider()`, constructed at first use; the post-start secret-field wiring calls `resolve()` so the constructor's production refusal still fires at boot. A provider built while a candidate is still pending prints a warning naming both files (safety net, never silent). - **One-shot commands.** `resolveExistingDataKey` resolves the same file with the same legacy path (`resolveStartHome` + `dev-crypto-key`), sets the channels on its own env (it is its own parent), reads the project file when it alone holds a key, and on `both-differ` returns a deferred provider that the boot's settings service settles. - **`~` in `OS_HOME` / `OBJECTSTACK_HOME`** is expanded in `keyFilePath` exactly as `resolveStartHome` does. - The ciphertext format is unchanged and nothing is rewrapped. `protocol.ts` and `engine.ts` are untouched. ## Tests - `service-settings` `local-crypto-provider.test.ts` (+21): the case-1 shape picks the legacy key; dev-then-start keeps canonical; every scope's row is judged; production refusal names both paths; development/test error notice; no row → canonical; failed read → not judged; one-file answers pass through untried; settle mutates the channel once for concurrent callers; refusal leaves both channels; provider-before-settle warning; deferred provider reads options at first use; engine reader (newest, unknown marker skipped, missing table → none, other failure rejects); `~` expansion. - `service-settings` `settings-engine-bind-window.test.ts` (+2, real `LiteKernel`): every `kernel:ready` reader sees the legacy file chosen; production with a key that opens nothing fails `bootstrap()` naming both files. - `cli` `internal-crypto-key-file-channel.test.ts`: both-differ hands the child both channels; case-1 and dev-then-start shapes read back after the child's settle; inherited candidate removed; `~` parity with `resolveStartHome`. - `cli` `one-shot-settings.pin.test.ts` (+3): legacy-only read by a one-shot; both-differ provider follows the trial; nothing minted. - `cli` `test/dev-start-shared-data-key.integration.test.ts` (+3, public door, real `os start`): the card's case 1 reads the secret back and names the chosen file; a production boot whose keys open nothing refuses naming both files. Budget re-measured (24.3 s for four boots → 40). ## Acceptance notes - `sys_secret` row chosen for the trial: the newest by `created_at` with a known marker. A database mixing rows from both keys is judged by its newest row, the key the deployment sealed with last. - The datasource-admin block in `serve.ts` used to catch a provider-construction error and leave `secrets` undefined; with the deferred provider that error surfaces at first use instead, and production still refuses at the secret-field wiring as before. ## Verification All readings on head `ec16e14d` (the branch merged with `main` at that point). - Build: the touched closure, 63/63 tasks green. Typecheck: `@objectstack/service-settings` 0, `@objectstack/cli` 0. - Tests: `service-settings` 1151/1151 (67 files); `cli` unit 5365/5365 (382 files); `cli` integration `dev-start-shared-data-key.integration.test.ts` 6/6 (24.3 s of tests). - `pnpm lint`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 66 derived gates, each run and its exit code recorded; `--ran`: 66 run, 0 NOT-MEASURED, 0 UNRUN. Three exited 3 on the first pass for environment prerequisites (the teardown self-test's pinned commit absent from the shallow clone; `check:dual-build-cjs-loads` and `check:i18n-coverage` before every dist existed). After fetching that commit and with the dists built they exited 0, and those are the codes recorded. - Ablations, each through `scripts/ablation-replace.mjs` with the dist rebuilt and preflighted, each restored to `HEAD` (blob equal, diff empty): - A, the trial forced to the canonical file: `service-settings` 12 red, `cli` 2 red. - B, the production refusal disabled: 3 red. - C, `~` expansion disabled in `keyFilePath`: `service-settings` 2 red, `cli` 2 red. - Full dogfood (narrowing arm), on this head: closure build `@objectstack/dogfood^...` exit 0, then `pnpm --filter @objectstack/dogfood test` exit 0: 417 files passed, 3 skipped (420); 3462 tests passed, 28 skipped (3490); 1695 s. <!-- signed: domain:server#6 · os-support-ai · session_01WGufzhkoyLG9aL3MMfCgCW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a27208 commit f6f663a

12 files changed

Lines changed: 1110 additions & 39 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-settings': patch
4+
---
5+
6+
fix(cli): when two data-key files hold different keys, `os start` and `os dev` read with the one that opens the project's stored secrets, or a production boot refuses
7+
8+
Clause-②: yes (narrowing) — a production `os start` / `os serve` boot whose two candidate key files (the shared `$HOME/.objectstack/dev-crypto-key` or `$OS_HOME`'s, and the project's `.objectstack/dev-crypto-key`) both fail to open the database's newest stored secret now refuses to start, naming both files; it used to start and serve every stored secret as unset. The widening half: `@objectstack/service-settings` gains new exports (`chooseKeyFileByTrialOpen`, `settleKeyFileChannel`, `storedSecretReaderOverEngine`, `deferredLocalCryptoProvider`, `DataKeyOpensNothingError`, `KEY_FILE_CANDIDATE_CHANNEL_ENV` and their types).
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, export or stored row changes shape: the refusal is a boot posture over which key file opens existing ciphertext. No ciphertext is rewritten, so there is nothing at rest for a migration to move. -->
12+
13+
**BREAKING** boot narrowing (`@objectstack/cli`, `@objectstack/service-settings`): a production boot whose candidate data keys open none of the stored secrets refuses to start.
14+
15+
- **`@objectstack/cli`.** When the shared key file and the project's own key file (the one an earlier `os start` minted) hold different keys, the CLI no longer picks one. It hands the `serve` child both, and the child decides once the database is open: it opens the newest stored `sys_secret` value under each key and reads with the key that opens it, and the boot says which file it took. A project that only ever ran `os start`, on a machine whose home holds a key from `os dev` in another project, reads its secrets back again. With no stored secret yet, the shared file is used, as before. When neither key opens the stored secrets, a production boot (`os start`, `os serve`) refuses to start and names both files; a development boot (`os dev`) starts and prints a boot error naming both files. The one-shot commands (`os migrate`, `os secret rewrap`, `os secret orphans`, `os storage orphans`) now read the same file by the same rules: the project's own file when it alone holds a key, and the key that opens a stored secret when both do.
16+
- **`@objectstack/service-settings`.** The settings service makes that choice in its `start()`, before any reader decrypts, when the CLI names a second candidate on the internal `OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE` channel. A leading `~` in `OS_HOME` (or `OBJECTSTACK_HOME`) is now the home directory when the provider finds its key file, as `os start` already read it; it used to name a directory called `~` under the working directory.
17+
18+
**The remedy.** If a production boot refuses with "neither data key opens this database's stored secrets", find the key those secrets were sealed under (a backup of either file, or the `OS_SECRET_KEY` the deployment once ran with), keep a copy of both files, and put that key at the shared file the message names, or set `OS_SECRET_KEY` to it.
19+
20+
Supersedes, in the same release: the note for #29934 says that when the shared file and the project's file hold different keys, the shared one is used and a warning names both paths. After this change the boot uses whichever of the two opens the stored secrets, and refuses in production when neither does.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5325,13 +5325,17 @@ export default class Serve extends Command {
53255325
// cleartext (by design).
53265326
let secrets: any = undefined;
53275327
try {
5328-
const { LocalCryptoProvider } = await import(
5328+
const { deferredLocalCryptoProvider } = await import(
53295329
/* webpackIgnore: true */ '@objectstack/service-settings'
53305330
);
53315331
// First block to touch `sharedCryptoProvider` (still undefined
5332-
// here), so create it directly; the secret-field wiring below
5333-
// reuses this instance so every sys_secret shares one key.
5334-
sharedCryptoProvider = new LocalCryptoProvider();
5332+
// here); the secret-field wiring below reuses this instance so
5333+
// every sys_secret shares one key. Built at its FIRST USE, not
5334+
// here: the database is not open yet, and when two key files hold
5335+
// different keys the boot chooses between them only once it is
5336+
// (the settings service's `start()`). The binder first decrypts at
5337+
// `kernel:ready`, after that choice.
5338+
sharedCryptoProvider = deferredLocalCryptoProvider();
53355339
secrets = createDatasourceSecretBinder({
53365340
engine: lazySecretEngine,
53375341
cryptoProvider: sharedCryptoProvider,
@@ -5631,6 +5635,11 @@ export default class Serve extends Command {
56315635
// every sys_secret value after a restart. Let that error be loud:
56325636
// secret writes must not proceed under an unstable key.
56335637
sharedCryptoProvider = new LocalCryptoProvider();
5638+
} else if (typeof sharedCryptoProvider.resolve === 'function') {
5639+
// The deferred instance the datasource binder shares: build it now
5640+
// (the key file was chosen during `runtime.start()`), so the same
5641+
// production refusal stays at boot rather than at the first write.
5642+
sharedCryptoProvider.resolve();
56345643
}
56355644
dataEngine.setCryptoProvider(sharedCryptoProvider);
56365645
if (isDev) {

‎packages/cli/src/utils/internal-crypto-key-file-channel.test.ts‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
* boot, one settings secret) is `test/dev-start-shared-data-key.integration.test.ts`.
1212
*/
1313

14-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1515
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
1616
import { tmpdir } from 'node:os';
1717
import { join } from 'node:path';
1818
import { randomBytes } from 'node:crypto';
19-
import { LocalCryptoProvider } from '@objectstack/service-settings';
19+
import { LocalCryptoProvider, resolvePersistedKeyFile, settleKeyFileChannel } from '@objectstack/service-settings';
2020
import { applyCryptoKeyFileChannel } from './internal-crypto-key-file-channel.js';
21+
import { resolveStartHome } from './start-home.js';
2122

2223
const CHANNEL = 'OS_INTERNAL_CRYPTO_KEY_FILE';
24+
const CANDIDATE = 'OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE';
2325
const ctx = { scope: 'settings', namespace: 'ai', key: 'openai_api_key' } as const;
2426

2527
let home: string;
@@ -102,16 +104,42 @@ describe('#29934 — the key file an earlier `os start` kept in the project', ()
102104
expect(existsSync(canonicalFile())).toBe(false);
103105
});
104106

105-
it('both files hold different keys: the canonical one is used, and the warning names both', async () => {
107+
it('both files hold different keys: the child is handed both, and the notice names both', async () => {
106108
writeKey(legacyFile());
107-
const canonical = writeKey(canonicalFile());
108-
const sealed = await new LocalCryptoProvider({ key: canonical }).encrypt('sk-canonical', ctx);
109-
109+
writeKey(canonicalFile());
110110
for (const { child, notice } of [await startChild(), await devChild()]) {
111111
expect(child[CHANNEL]).toBe(canonicalFile());
112-
expect(notice).toContain(`${canonicalFile()} (used)`);
113-
expect(notice).toContain(`${legacyFile()} (NOT used)`);
114-
expect(await new LocalCryptoProvider({ env: child }).decrypt(sealed, ctx)).toBe('sk-canonical');
112+
// [#30042] Only the child opens the database, so the parent rules
113+
// nothing: the second file travels on the candidate channel.
114+
expect(child[CANDIDATE]).toBe(legacyFile());
115+
expect(notice).toContain(canonicalFile());
116+
expect(notice).toContain(legacyFile());
117+
}
118+
});
119+
120+
it.each([
121+
['the case-1 shape (a start-sealed project, a foreign home key)', 'legacy'],
122+
['the dev-then-start shape', 'canonical'],
123+
] as const)('%s: the child reads its secret back with the %s key', async (_shape, sealer) => {
124+
const keys = { legacy: writeKey(legacyFile()), canonical: writeKey(canonicalFile()) };
125+
const handle = await new LocalCryptoProvider({ key: keys[sealer] }).encrypt('sk-30042', ctx);
126+
const stored = async () => ({ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext });
127+
for (const { child } of [await startChild(), await devChild()]) {
128+
const quiet = vi.spyOn(console, 'warn').mockImplementation(() => {});
129+
// What the child's settings service does at start(), the database open.
130+
const choice = await settleKeyFileChannel({ env: child, readStoredSecret: stored });
131+
quiet.mockRestore();
132+
expect(choice?.path).toBe(sealer === 'legacy' ? legacyFile() : canonicalFile());
133+
expect(child[CANDIDATE]).toBeUndefined();
134+
expect(await new LocalCryptoProvider({ env: child }).decrypt(handle, ctx)).toBe('sk-30042');
135+
}
136+
});
137+
138+
it('an inherited candidate never survives a one-file answer', async () => {
139+
writeKey(canonicalFile());
140+
const env = { ...operatorEnv, [CANDIDATE]: '/inherited/elsewhere' };
141+
for (const { child } of [await startChild(env), await devChild(env)]) {
142+
expect(child[CANDIDATE]).toBeUndefined();
115143
}
116144
});
117145

@@ -131,10 +159,19 @@ describe('#29934 — an explicit env key keeps winning, unchanged', () => {
131159
['OS_DEV_CRYPTO_KEY', randomBytes(32).toString('base64')],
132160
])('%s set: no file is named and an inherited channel is removed', async (name, value) => {
133161
writeKey(legacyFile());
134-
const env = { ...operatorEnv, [name]: value, [CHANNEL]: '/inherited/elsewhere' };
162+
const env = { ...operatorEnv, [name]: value, [CHANNEL]: '/inherited/elsewhere', [CANDIDATE]: '/inherited/too' };
135163
for (const { child, notice } of [await startChild(env), await devChild(env)]) {
136164
expect(child[CHANNEL]).toBeUndefined();
165+
expect(child[CANDIDATE]).toBeUndefined();
137166
expect(notice).toBeUndefined();
138167
}
139168
});
140169
});
170+
171+
describe('#30042 — a leading `~` in OS_HOME names one directory for the key file and for `os start`', () => {
172+
it.each(['~/pinned-home', '~'])('OS_HOME=%s', (value) => {
173+
const env = { ...operatorEnv, OS_HOME: value };
174+
const startHome = resolveStartHome(undefined, { hasProjectConfig: true, cwd: project, env });
175+
expect(resolvePersistedKeyFile({ env })?.canonical).toBe(join(startHome, 'dev-crypto-key'));
176+
});
177+
});

‎packages/cli/src/utils/internal-crypto-key-file-channel.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ import { resolveStartHome } from './start-home.js';
2222
* `resolvePersistedKeyFile` over the env the child sees, with `OS_HOME` as dev
2323
* reads it (`--home` counts as `OS_HOME`, being start's spelling of it). The
2424
* file `os start` used before — `<start home>/dev-crypto-key` — is the legacy
25-
* file: read when it alone holds a key, named in a warning when it and the
26-
* canonical file both hold different keys, never chosen silently.
25+
* file: read when it alone holds a key, and never chosen silently. When it and
26+
* the canonical file hold different keys, the parent cannot rule between them:
27+
* it names the canonical file on the channel and the legacy file on the
28+
* second channel `OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE`, and the child, which
29+
* opens the database, takes the file whose key opens a stored secret (or
30+
* refuses in production when neither does; `settleKeyFileChannel`).
2731
*
2832
* An env key (`OS_SECRET_KEY`, a usable `OS_DEV_CRYPTO_KEY`) still answers
2933
* first, unchanged: no file is named and the channel is removed. The parent
@@ -46,7 +50,9 @@ export async function applyCryptoKeyFileChannel(
4650
homeFlag?: string;
4751
},
4852
): Promise<string | undefined> {
49-
const { resolvePersistedKeyFile, KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings');
53+
const { resolvePersistedKeyFile, KEY_FILE_CHANNEL_ENV, KEY_FILE_CANDIDATE_CHANNEL_ENV } = await import(
54+
'@objectstack/service-settings'
55+
);
5056
const startHome = resolveStartHome(opts.homeFlag, {
5157
hasProjectConfig: opts.hasProjectConfig,
5258
cwd: opts.cwd,
@@ -57,6 +63,9 @@ export async function applyCryptoKeyFileChannel(
5763
env: keyEnv,
5864
legacyKeyFile: path.join(startHome, 'dev-crypto-key'),
5965
});
66+
// Both channels are the parent's: an inherited copy never speaks for a file
67+
// this run did not choose.
68+
delete childEnv[KEY_FILE_CANDIDATE_CHANNEL_ENV];
6069
if (!answer) {
6170
delete childEnv[KEY_FILE_CHANNEL_ENV];
6271
return undefined;
@@ -68,11 +77,15 @@ export async function applyCryptoKeyFileChannel(
6877
`\`os dev\` and \`os start\` now share ${answer.canonical}; move the file there to converge.`
6978
);
7079
}
71-
if (answer.kind === 'both-differ') {
80+
if (answer.kind === 'both-differ' && answer.legacy) {
81+
// Only the child opens the database, so only the child can tell which key
82+
// opens its stored secrets: it rules between the two files at boot
83+
// (`settleKeyFileChannel` in the settings service), names the one it
84+
// took, and refuses in production when neither opens them.
85+
childEnv[KEY_FILE_CANDIDATE_CHANNEL_ENV] = answer.legacy;
7286
return (
73-
`Data key: two different key files exist, ${answer.canonical} (used) and ${answer.legacy} (NOT used). ` +
74-
`Secrets saved under the unused key will not decrypt. If this project's secrets were saved under ` +
75-
`\`os start\`, keep a copy of both files and move ${answer.legacy} over ${answer.canonical}.`
87+
`Data key: two different key files exist, ${answer.canonical} and ${answer.legacy}. ` +
88+
`The server uses the one that opens this project's stored secrets, and says which.`
7689
);
7790
}
7891
return undefined;

‎packages/cli/src/utils/one-shot-settings.pin.test.ts‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@
3434
* `schema-migrate.one-shot-family.integration.test.ts`.
3535
*/
3636

37-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
38-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
37+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
38+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
39+
import { randomBytes } from 'node:crypto';
3940
import { tmpdir } from 'node:os';
4041
import { join, relative, resolve } from 'node:path';
4142
import { fileURLToPath } from 'node:url';
42-
import { LocalCryptoProvider } from '@objectstack/service-settings';
43+
import { LocalCryptoProvider, settleKeyFileChannel } from '@objectstack/service-settings';
4344
// The one code/prose separator, typed by the hand-written `.d.mts` beside it.
4445
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
4546
import { oneShotSettingsPlugin, refusingCryptoProvider, resolveExistingDataKey } from './one-shot-settings.js';
@@ -104,6 +105,7 @@ describe('[#21471] the settings service is composed in one place in this package
104105
const KEY_ENV = [
105106
'NODE_ENV', 'OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY',
106107
'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY',
108+
'OS_INTERNAL_CRYPTO_KEY_FILE', 'OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE',
107109
] as const;
108110
const KEY_HEX = '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f';
109111
const savedEnv: Record<string, string | undefined> = {};
@@ -202,3 +204,70 @@ describe('[#21471] the one-shot helper never mints a data key', () => {
202204
await expect(provider.keyedDigest('x')).rejects.toThrow(/the reason/);
203205
});
204206
});
207+
208+
// ── [#30042] The file a one-shot reads is the one `os dev` / `os start` read ──
209+
210+
describe('[#30042] one-shot commands take the same key file, by the same resolution and trial', () => {
211+
let project: string;
212+
let userHome: string;
213+
beforeEach(() => {
214+
project = mkdtempSync(join(tmpdir(), 'os-30042-project-'));
215+
userHome = mkdtempSync(join(tmpdir(), 'os-30042-user-'));
216+
writeFileSync(join(project, 'objectstack.config.ts'), 'export default {};\n');
217+
});
218+
afterEach(() => {
219+
rmSync(project, { recursive: true, force: true });
220+
rmSync(userHome, { recursive: true, force: true });
221+
vi.restoreAllMocks();
222+
});
223+
224+
const canonicalFile = () => join(userHome, '.objectstack', 'dev-crypto-key');
225+
const legacyFile = () => join(project, '.objectstack', 'dev-crypto-key');
226+
const writeKey = (file: string): Buffer => {
227+
const key = randomBytes(32);
228+
mkdirSync(join(file, '..'), { recursive: true });
229+
writeFileSync(file, key.toString('base64'), { mode: 0o600 });
230+
return key;
231+
};
232+
const ctx = { scope: 'settings', namespace: 'ai', key: 'openai_api_key' } as const;
233+
/** A one-shot's own environment: no key and no home pin, `HOME` is this test's. */
234+
const oneShotEnv = (): NodeJS.ProcessEnv => ({ HOME: userHome, NODE_ENV: 'production' });
235+
236+
it('only the project file an earlier `os start` kept holds a key: the one-shot reads it', async () => {
237+
const legacy = writeKey(legacyFile());
238+
const handle = await new LocalCryptoProvider({ key: legacy }).encrypt('sk-legacy', ctx);
239+
vi.spyOn(console, 'warn').mockImplementation(() => {});
240+
const env = oneShotEnv();
241+
const key = await resolveExistingDataKey({ cwd: project, env });
242+
expect(await key.provider!.decrypt(handle, ctx)).toBe('sk-legacy');
243+
expect(existsSync(canonicalFile())).toBe(false);
244+
});
245+
246+
it('two different keys: the provider is the one the boot\'s trial chose', async () => {
247+
writeKey(canonicalFile());
248+
const legacy = writeKey(legacyFile());
249+
const handle = await new LocalCryptoProvider({ key: legacy }).encrypt('sk-start', ctx);
250+
vi.spyOn(console, 'warn').mockImplementation(() => {});
251+
const env = oneShotEnv();
252+
const key = await resolveExistingDataKey({ cwd: project, env });
253+
expect(key.unavailable).toBeNull();
254+
expect(env.OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE).toBe(legacyFile());
255+
// What the boot's settings service does at start(), the database open.
256+
await settleKeyFileChannel({
257+
env,
258+
readStoredSecret: async () => ({ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext }),
259+
});
260+
expect(await key.provider!.decrypt(handle, ctx)).toBe('sk-start');
261+
expect(key.provider!.keySource).toBe('file');
262+
});
263+
264+
it('two different keys with no trial yet: nothing has been read, and nothing is minted', async () => {
265+
writeKey(canonicalFile());
266+
writeKey(legacyFile());
267+
const env = oneShotEnv();
268+
const before = readdirSync(join(userHome, '.objectstack'));
269+
const key = await resolveExistingDataKey({ cwd: project, env });
270+
expect(key.provider).not.toBeNull();
271+
expect(readdirSync(join(userHome, '.objectstack'))).toEqual(before);
272+
});
273+
});

0 commit comments

Comments
 (0)