Skip to content

Commit 8d5aab3

Browse files
fix(cli,service-settings): os dev and os start share one data-key file (#30040)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_ Fixes #29934 ## 维护者速读 - 改了什么:`os start` 不再在项目目录里另造一把数据密钥,而是和 `os dev` 用同一个密钥文件(`$HOME/.objectstack/dev-crypto-key`,或 `$OS_HOME` 下的那份);旧版 `os start` 留在项目里的密钥文件照样能读。 - 为什么改:同一个项目、同一个数据库,开发时存的 AI key、SMTP 密码、数据源凭据,换 `os start` 后全部读成"未设置",只在日志里留一句解密失败。 - 风险与回滚:只动"选哪个密钥文件",加解密本身不变;显式 `OS_SECRET_KEY` / `OS_DEV_CRYPTO_KEY` 仍优先。两个文件都在且不同时用共享那份并告警点名两条路径。回滚 = revert 本 PR。 - 先看:`packages/services/service-settings/src/local-crypto-provider.ts`(`resolvePersistedKeyFile` 与文件层读通道)、`packages/cli/src/utils/internal-crypto-key-file-channel.ts`、`packages/cli/src/commands/start.ts` 的 `OS_CRYPTO_AUTOKEY` 一段。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run test/dev-start-shared-data-key.integration.test.ts`,预期 3 条全绿(真起 `os dev` 存一个 AI key,再真起 `os start` 读回)。 - 需要你做的:无,席位落地。 Clause-②: yes (widening) Metadata-protocol: none ## What changes **One key file for one project, under both commands.** `os dev` leaves `OS_HOME` alone, so its `serve` child kept the data key at `$HOME/.objectstack/dev-crypto-key` (or `$OS_HOME/dev-crypto-key`). `os start` sets `OS_HOME` to its own home (`PROJECT/.objectstack` in project mode) for the database, uploads and the rest of its state, and the provider read the key file under that same `OS_HOME`, so it minted a second key. Both commands open the same `PROJECT/.objectstack/data/objectstack.db`. - `@objectstack/service-settings`: `resolvePersistedKeyFile({ env, legacyKeyFile })` is the one resolution. The canonical file is the provider's existing `keyFilePath(env)` (os dev's reading, not a second copy of it). It answers `canonical`, `legacy-only` (only the legacy file holds a key, so that path is used) or `both-differ` (the canonical path is used and the caller warns), and `undefined` when an env key answers first. The provider's file tier now reads the file named on `OS_INTERNAL_CRYPTO_KEY_FILE` (exported as `KEY_FILE_CHANNEL_ENV`) ahead of `keyFilePath(env)`. That is the only behaviour change inside the provider. Env keys still answer first, and minting and refusal are unchanged. - `@objectstack/cli`: `applyCryptoKeyFileChannel` (`utils/internal-crypto-key-file-channel.ts`) is called by both `os dev` and `os start` with their own inputs. It computes the legacy file as `START_HOME/dev-crypto-key`, which is exactly where `os start` kept it before, writes the answer into the child env (or deletes an inherited copy), and returns the one-line notice the command prints. `os start --home X` counts as `OS_HOME=X`, so a pinned home keeps its key where it was. `os dev --fresh` passes its ephemeral home, so a fresh run stays self-contained. `resolveHome` moved unchanged from `start.ts` to `utils/start-home.ts` (`resolveStartHome`), because `os dev` needs to know where `os start` kept the legacy file. - The legacy rules, the same under both commands: - Only `PROJECT/.objectstack/dev-crypto-key` holds a key: it is read, and the boot prints `⚠ Data key: reading LEGACY, the key file an earlier os start kept for this project. os dev and os start now share CANONICAL; move the file there to converge.` Nothing is copied or minted. - Both files hold different keys: the canonical one is used, and `⚠ Data key: two different key files exist, CANONICAL (used) and LEGACY (NOT used). …` names both. - Both files hold the same key, compared as keys, not as bytes: no notice. ## Tests - `packages/cli/test/dev-start-shared-data-key.integration.test.ts` (new) runs the card's reproduction at the public door on the built CLI. With no env key and an empty `HOME`, it runs `os dev`, signs in, and sends `PUT /api/settings/ai {openai_api_key}`. Then it runs `os start --artifact ./dist/objectstack.json` in the same project, signs in, and expects `GET /api/settings/ai` to answer `openai_api_key.source === 'global'` with a value. It also checks that `$HOME/.objectstack/dev-crypto-key` exists and `PROJECT/.objectstack/dev-crypto-key` does not. Both boots use kernel-assigned ports read back from the banner. `test-budget: 20`, class `spawns the real producer once per single-cause fixture`. It is listed in `check-cli-test-child-env`'s built-entrypoint census, which grew from eighteen files to nineteen. - `packages/cli/src/utils/internal-crypto-key-file-channel.test.ts` (new, unit) drives the helper with the inputs `dev` and `start` pass, then builds the provider over the child env. It pins five cases: a dev-sealed secret opens under start; `--home` keeps its home; legacy-only, read by both commands, with a notice naming both paths and no canonical file minted; both-differ, where the canonical key is used and the warning names both; and same key with no notice. It also checks that `OS_SECRET_KEY` / `OS_DEV_CRYPTO_KEY` name no file and remove an inherited channel. - `local-crypto-provider.test.ts` gains a new top-level `describe` in the existing harness. It pins that the channel path is read and minted ahead of `OS_HOME`, that an env key beats the channel, and the `resolvePersistedKeyFile` cases: canonical, legacy-only, both-differ, a legacy file that is the canonical one, an unusable legacy file, and env keys. <details><summary>Ablation (one, on the shared resolution), run twice: before and after the final test edit</summary> `start.ts`: `const keyFileNotice = await applyCryptoKeyFileChannel(localEnv, {` changed to `… = (process.env.OS_ABLATE_29934 ?? 'ablated-29934') ? undefined : await applyCryptoKeyFileChannel(localEnv, {` with `scripts/ablation-replace.mjs`. That means `os start` no longer names the shared file. The cli was rebuilt, and `ablation-dist-preflight` found the marker in `packages/cli/dist/commands/start.js`. - Ablated: `Tests 2 failed | 1 passed (3)`. The card's symptom came back verbatim: `openai_api_key = {"value":null,"source":"default", … "cascadeChain":[{"scope":"global","value":null,…}]}` with `expected 'default' to be 'global'`, and the project-local key was minted again (`expected true to be false`). - Restored with `ablation-replace --restore`: the blob equals HEAD and `git diff HEAD` is empty. The cli was rebuilt, and `preflight --absent` was clean over 824 files. Result: `Tests 3 passed (3)`. </details> ## Acceptance notes - Out of scope, ruled by the seat: a boot-time check that refuses or warns when `sys_secret` rows exist that the resolved key cannot open. The seat files it. - One-shot commands (`os migrate …`, `os secret rewrap` / `orphans`) still resolve the key from `process.env` alone, which is the canonical file. They do not read the legacy project-local file and do not apply `--home`. This is unchanged by this PR and is not a regression: they never read the `os start` file before either. It is reported to the seat. - `keyFilePath` reads `OS_HOME` without expanding `~` (os dev's existing reading), while `os start` and the runtime expand it. With `OS_HOME=~/x` written in a `.env` file, both commands now agree on os dev's reading. The file the old `os start` minted under the expanded path is still read as the legacy file. This is reported to the seat, not changed here. - `OS_INTERNAL_CRYPTO_KEY_FILE` follows the `OS_INTERNAL_*` rule: the parent owns it, it is not an operator setting, and it is not listed in `environment-variables.mdx`. ## Verification <details><summary>Builds, package suites, lint, gates (final head 21f0d7d)</summary> - `pnpm --filter '@objectstack/cli...' build` under the lock: `VERDICT command-exit 0`. Then a full `pnpm build`: `VERDICT command-exit 0`. - `pnpm --filter @objectstack/service-settings test`: `Test Files 67 passed (67)`, `Tests 1128 passed (1128)`. `typecheck`: exit 0. - `pnpm --filter @objectstack/cli typecheck`: exit 0, including `check:test-typecheck OK`. The first run hit a heap OOM on the shared box, and the rerun used `NODE_OPTIONS=--max-old-space-size=8192`. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 378 of 379 files passed on head 49ab54c. The one red was `released-port-production-spawn.pin`, caused by the first version of the new e2e drawing released ports. It was fixed with kernel-assigned ports. The pin, `vitest-tiers-partition`, the new unit file and the e2e were then rerun: `Test Files 4 passed (4)`, `Tests 36 passed (36)`. - `pnpm lint`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` on the final head listed 83 families. All 83 exited 0 after the full build, with the teardown fixture commit fetched. `--ran`: `83 derived famil(ies) accounted for — 83 run, 0 NOT-MEASURED`. - Dogfood: n/a. There is no `!` in the title and no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfcLBsxsQsMRKDD3to)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfcLBsxsQsMRKDD3to <!-- signed: domain:server#6 · os-support-ai · session_01WGufzfcLBsxsQsMRKDD3to --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9895a1 commit 8d5aab3

11 files changed

Lines changed: 763 additions & 32 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-settings': patch
4+
---
5+
6+
fix(cli): `os dev` and `os start` share one data-key file, so a secret saved under `npm run dev` reads back under `os start`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/cli`.** Both commands open the same project database, but `os start` used to mint its own data key under its own home (`PROJECT/.objectstack/dev-crypto-key` in project mode) while `os dev` kept the key at `$HOME/.objectstack/dev-crypto-key` (or `$OS_HOME`'s). Every secret written during development then read back as unset under `os start`. Both commands now resolve the key file the way `os dev` always has (`os start --home` counts as `OS_HOME`) and hand it to their `serve` child on the internal `OS_INTERNAL_CRYPTO_KEY_FILE` channel. The project-local file an earlier `os start` minted is not orphaned: when it alone holds a key it is still read, and the boot says which file; when it and the shared file hold different keys, the shared one is used and a warning names both paths. An explicit `OS_SECRET_KEY` / `OS_DEV_CRYPTO_KEY` still wins, unchanged.
12+
- **`@objectstack/service-settings`.** New exports `resolvePersistedKeyFile` (the one resolution both commands take, beside the provider's own key-file path) and `KEY_FILE_CHANNEL_ENV`; the local crypto provider's file tier reads the file that channel names, ahead of `OS_HOME`.

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp';
4040
// no range, no reader, no wording; a second copy of the bound is exactly what
4141
// #12620 and #12662 protected against.
4242
import { describePortSource, parseRequestedPort, formatInvalidPortNotice, portFlagEnv } from '../utils/port-contract.js';
43+
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
4344
// The ONE dev-TLS contract, shared with the `serve` child this command spawns
4445
// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no
4546
// certificate is ever generated — the developer brings their own.
@@ -809,6 +810,15 @@ export default class Dev extends Command {
809810
...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}),
810811
...(flags['auth-secret'] ? { OS_AUTH_SECRET: flags['auth-secret'] } : {}),
811812
};
813+
// The data key's file: the ONE answer `os start` takes too (#29934), so a
814+
// secret saved here reads back under `os start` over the same project —
815+
// and the file an earlier `os start` kept in the project is still read.
816+
const keyFileNotice = await applyCryptoKeyFileChannel(localEnv, {
817+
env: freshHome ? { ...process.env, OS_HOME: freshHome } : process.env,
818+
cwd: process.cwd(),
819+
hasProjectConfig: configExists,
820+
});
821+
if (keyFileNotice) console.log(chalk.yellow(` ⚠ ${keyFileNotice}`));
812822
printKV('Environment ID', environmentId, '🎯');
813823
if (artifactUrl) {
814824
// Redacted: the reference may be a pre-signed URL whose query string IS

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

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { spawn, spawnSync } from 'child_process';
66
import crypto from 'crypto';
77
import dotenvFlow from 'dotenv-flow';
88
import fs from 'fs';
9-
import os from 'os';
109
import path from 'path';
1110
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
1211
import { makeDirectorySync } from '../utils/make-directory.js';
@@ -24,6 +23,8 @@ import {
2423
import { ServeRestartCoordinator } from '../utils/dev-restart.js';
2524
import { readEnvWithDeprecation } from '@objectstack/types';
2625
import { writeAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
26+
import { applyCryptoKeyFileChannel } from '../utils/internal-crypto-key-file-channel.js';
27+
import { resolveStartHome } from '../utils/start-home.js';
2728
// The ONE port contract, shared with `dev` and with the `serve` child this
2829
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
2930
// no range, no reader, no wording; a second copy of the bound is exactly what
@@ -186,7 +187,7 @@ export default class Start extends Command {
186187
// ── Home directory ──────────────────────────────────────────────
187188
// Priority: --home > $OS_HOME > <cwd>/.objectstack (project mode)
188189
// > ~/.objectstack (global mode)
189-
const homeDir = resolveHome(flags.home, { hasProjectConfig, cwd });
190+
const homeDir = resolveStartHome(flags.home, { hasProjectConfig, cwd });
190191
try {
191192
// Bounded (#23858): a home that can never be made — a name under
192193
// `/proc` — is refused here, at once, with the OS's code and the path.
@@ -482,13 +483,25 @@ export default class Start extends Command {
482483
// Single-node self-host quickstart: forcing production above would make
483484
// LocalCryptoProvider refuse to boot without OS_SECRET_KEY, breaking the
484485
// documented zero-config `os start`. Opt the crypto provider into minting
485-
// + persisting a key file (~/.objectstack/dev-crypto-key) so it works out
486-
// of the box. A multi-node deploy (OS_CLUSTER_DRIVER set) must provision a
487-
// shared OS_SECRET_KEY instead — each node minting its own key would
488-
// diverge — so we do NOT opt in there; the provider still fails loud.
486+
// + persisting a key file so it works out of the box. A multi-node deploy
487+
// (OS_CLUSTER_DRIVER set) must provision a shared OS_SECRET_KEY instead —
488+
// each node minting its own key would diverge — so we do NOT opt in
489+
// there; the provider still fails loud.
489490
if (!localEnv.OS_CLUSTER_DRIVER && !localEnv.OS_SECRET_KEY) {
490491
localEnv.OS_CRYPTO_AUTOKEY = '1';
491492
}
493+
// WHICH key file: the one `os dev` uses for this project
494+
// (~/.objectstack/dev-crypto-key, or $OS_HOME's), never a second one under
495+
// this command's own home (#29934). `localEnv.OS_HOME` above is this
496+
// command's state dir, so the key's location travels on its own channel;
497+
// the file an earlier `os start` minted here is still read, and named.
498+
const keyFileNotice = await applyCryptoKeyFileChannel(localEnv, {
499+
env: process.env,
500+
cwd,
501+
hasProjectConfig,
502+
homeFlag: flags.home,
503+
});
504+
if (keyFileNotice) console.log(chalk.yellow(` ⚠ ${keyFileNotice}`));
492505

493506
const binPath = process.argv[1];
494507

@@ -587,24 +600,6 @@ export async function resolveStartDatabase(opts: {
587600
});
588601
}
589602

590-
function resolveHome(
591-
flagValue: string | undefined,
592-
opts: { hasProjectConfig: boolean; cwd: string },
593-
): string {
594-
const raw = flagValue ?? process.env.OS_HOME;
595-
if (raw && raw.trim().length > 0) {
596-
const v = raw.trim();
597-
if (v.startsWith('~')) return path.resolve(os.homedir(), v.slice(1).replace(/^[/\\]/, ''));
598-
return path.resolve(v);
599-
}
600-
// Project mode: keep state next to the source so each project is
601-
// self-contained and `os start` from another cwd doesn't reuse it.
602-
if (opts.hasProjectConfig) {
603-
return path.resolve(opts.cwd, '.objectstack');
604-
}
605-
return path.resolve(os.homedir(), '.objectstack');
606-
}
607-
608603
/**
609604
* Read the persisted AUTH_SECRET from `<home>/auth-secret`, or generate
610605
* one on first run and persist it so subsequent restarts keep existing
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #29934 — the ONE data-key file `os dev` and `os start` hand their `serve`
5+
* child. Each case drives `applyCryptoKeyFileChannel` with the inputs the
6+
* command passes it (see `commands/dev.ts` and `commands/start.ts`), then
7+
* builds the provider the child builds over the child env, so the assertion is
8+
* about the key the child ends up with, not about a path string alone.
9+
*
10+
* The public-door reproduction (a real `os dev` boot, then a real `os start`
11+
* boot, one settings secret) is `test/dev-start-shared-data-key.integration.test.ts`.
12+
*/
13+
14+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16+
import { tmpdir } from 'node:os';
17+
import { join } from 'node:path';
18+
import { randomBytes } from 'node:crypto';
19+
import { LocalCryptoProvider } from '@objectstack/service-settings';
20+
import { applyCryptoKeyFileChannel } from './internal-crypto-key-file-channel.js';
21+
22+
const CHANNEL = 'OS_INTERNAL_CRYPTO_KEY_FILE';
23+
const ctx = { scope: 'settings', namespace: 'ai', key: 'openai_api_key' } as const;
24+
25+
let home: string;
26+
let project: string;
27+
/** The operator's environment: no key of any spelling, `HOME` is this test's. */
28+
let operatorEnv: NodeJS.ProcessEnv;
29+
30+
beforeEach(() => {
31+
home = mkdtempSync(join(tmpdir(), 'os-29934-home-'));
32+
project = mkdtempSync(join(tmpdir(), 'os-29934-project-'));
33+
writeFileSync(join(project, 'objectstack.config.ts'), 'export default {};\n');
34+
operatorEnv = { HOME: home };
35+
});
36+
afterEach(() => {
37+
rmSync(home, { recursive: true, force: true });
38+
rmSync(project, { recursive: true, force: true });
39+
});
40+
41+
const canonicalFile = () => join(home, '.objectstack', 'dev-crypto-key');
42+
const legacyFile = () => join(project, '.objectstack', 'dev-crypto-key');
43+
44+
function writeKey(file: string): Buffer {
45+
const key = randomBytes(32);
46+
mkdirSync(join(file, '..'), { recursive: true });
47+
writeFileSync(file, key.toString('base64'), { mode: 0o600 });
48+
return key;
49+
}
50+
51+
/** `os dev`'s child: the operator env, development posture. */
52+
async function devChild(env = operatorEnv) {
53+
const child: NodeJS.ProcessEnv = { ...env, NODE_ENV: 'development' };
54+
const notice = await applyCryptoKeyFileChannel(child, { env, cwd: project, hasProjectConfig: true });
55+
return { child, notice };
56+
}
57+
58+
/** `os start`'s child: its own home as `OS_HOME`, production posture, the auto-key opt-in. */
59+
async function startChild(env = operatorEnv, homeFlag?: string) {
60+
const child: NodeJS.ProcessEnv = {
61+
...env,
62+
OS_HOME: homeFlag ?? join(project, '.objectstack'),
63+
NODE_ENV: 'production',
64+
OS_CRYPTO_AUTOKEY: '1',
65+
};
66+
const notice = await applyCryptoKeyFileChannel(child, { env, cwd: project, hasProjectConfig: true, homeFlag });
67+
return { child, notice };
68+
}
69+
70+
describe('#29934 — one key file for `os dev` and `os start`', () => {
71+
it('a secret sealed under `os dev` opens under `os start` over the same project', async () => {
72+
const dev = await devChild();
73+
const sealed = await new LocalCryptoProvider({ env: dev.child }).encrypt('sk-test', ctx);
74+
75+
const start = await startChild();
76+
expect(start.child[CHANNEL]).toBe(canonicalFile());
77+
expect(await new LocalCryptoProvider({ env: start.child }).decrypt(sealed, ctx)).toBe('sk-test');
78+
expect(existsSync(legacyFile())).toBe(false);
79+
expect([dev.notice, start.notice]).toEqual([undefined, undefined]);
80+
});
81+
82+
it('`--home` is start\'s spelling of `OS_HOME`: the key stays in that home', async () => {
83+
const pinned = join(home, 'pinned-home');
84+
const start = await startChild(operatorEnv, pinned);
85+
expect(start.child[CHANNEL]).toBe(join(pinned, 'dev-crypto-key'));
86+
expect(start.notice).toBeUndefined();
87+
});
88+
});
89+
90+
describe('#29934 — the key file an earlier `os start` kept in the project', () => {
91+
it('only the legacy file holds a key: both commands read it, and say which file', async () => {
92+
const legacy = writeKey(legacyFile());
93+
const sealed = await new LocalCryptoProvider({ key: legacy }).encrypt('sk-legacy', ctx);
94+
95+
for (const { child, notice } of [await startChild(), await devChild()]) {
96+
expect(child[CHANNEL]).toBe(legacyFile());
97+
expect(notice).toContain(legacyFile());
98+
expect(notice).toContain(canonicalFile());
99+
expect(await new LocalCryptoProvider({ env: child }).decrypt(sealed, ctx)).toBe('sk-legacy');
100+
}
101+
// Read, never copied or re-minted: the canonical file is still absent.
102+
expect(existsSync(canonicalFile())).toBe(false);
103+
});
104+
105+
it('both files hold different keys: the canonical one is used, and the warning names both', async () => {
106+
writeKey(legacyFile());
107+
const canonical = writeKey(canonicalFile());
108+
const sealed = await new LocalCryptoProvider({ key: canonical }).encrypt('sk-canonical', ctx);
109+
110+
for (const { child, notice } of [await startChild(), await devChild()]) {
111+
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');
115+
}
116+
});
117+
118+
it('both files hold the same key: no warning', async () => {
119+
const key = writeKey(canonicalFile());
120+
mkdirSync(join(project, '.objectstack'), { recursive: true });
121+
writeFileSync(legacyFile(), key.toString('hex'), { mode: 0o600 });
122+
const start = await startChild();
123+
expect(start.child[CHANNEL]).toBe(canonicalFile());
124+
expect(start.notice).toBeUndefined();
125+
});
126+
});
127+
128+
describe('#29934 — an explicit env key keeps winning, unchanged', () => {
129+
it.each([
130+
['OS_SECRET_KEY', randomBytes(32).toString('hex')],
131+
['OS_DEV_CRYPTO_KEY', randomBytes(32).toString('base64')],
132+
])('%s set: no file is named and an inherited channel is removed', async (name, value) => {
133+
writeKey(legacyFile());
134+
const env = { ...operatorEnv, [name]: value, [CHANNEL]: '/inherited/elsewhere' };
135+
for (const { child, notice } of [await startChild(env), await devChild(env)]) {
136+
expect(child[CHANNEL]).toBeUndefined();
137+
expect(notice).toBeUndefined();
138+
}
139+
});
140+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import path from 'path';
4+
import { resolveStartHome } from './start-home.js';
5+
6+
/**
7+
* The ONE data-key file `os dev` and `os start` hand their `serve` child
8+
* (#29934), on the CLI's internal channel `OS_INTERNAL_CRYPTO_KEY_FILE`.
9+
*
10+
* ## The defect
11+
*
12+
* Both commands open the same project database (`<project>/.objectstack/data/
13+
* objectstack.db`), but each child resolved its persisted data key from its
14+
* own `OS_HOME`: `os dev` leaves `OS_HOME` alone, so its key lived at
15+
* `$HOME/.objectstack/dev-crypto-key`; `os start` sets `OS_HOME` to its home
16+
* (`<project>/.objectstack` in project mode), so it minted a second key there.
17+
* A secret saved under `npm run dev` then read back as unset under `os start`.
18+
*
19+
* ## The answer
20+
*
21+
* One resolution for both commands: the settings package's
22+
* `resolvePersistedKeyFile` over the env the child sees, with `OS_HOME` as dev
23+
* reads it (`--home` counts as `OS_HOME`, being start's spelling of it). The
24+
* 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.
27+
*
28+
* An env key (`OS_SECRET_KEY`, a usable `OS_DEV_CRYPTO_KEY`) still answers
29+
* first, unchanged: no file is named and the channel is removed. The parent
30+
* OWNS the variable in the child env, so an inherited copy never speaks for a
31+
* file this run did not choose — the same rule as `OS_INTERNAL_ARTIFACT_PATH`
32+
* and `OS_INTERNAL_AUTH_SECRET_FILE`, and like them it is not an operator
33+
* setting (not listed in `environment-variables.mdx`).
34+
*
35+
* The settings package loads at the point of use: oclif imports every command
36+
* module on every invocation while building its table.
37+
*/
38+
export async function applyCryptoKeyFileChannel(
39+
childEnv: NodeJS.ProcessEnv,
40+
opts: {
41+
/** The env the child reads its key from, `OS_HOME` as the child sees it. */
42+
env: NodeJS.ProcessEnv;
43+
cwd: string;
44+
hasProjectConfig: boolean;
45+
/** `os start --home`; `os dev` has none. */
46+
homeFlag?: string;
47+
},
48+
): Promise<string | undefined> {
49+
const { resolvePersistedKeyFile, KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings');
50+
const startHome = resolveStartHome(opts.homeFlag, {
51+
hasProjectConfig: opts.hasProjectConfig,
52+
cwd: opts.cwd,
53+
env: opts.env,
54+
});
55+
const keyEnv = opts.homeFlag ? { ...opts.env, OS_HOME: startHome } : opts.env;
56+
const answer = resolvePersistedKeyFile({
57+
env: keyEnv,
58+
legacyKeyFile: path.join(startHome, 'dev-crypto-key'),
59+
});
60+
if (!answer) {
61+
delete childEnv[KEY_FILE_CHANNEL_ENV];
62+
return undefined;
63+
}
64+
childEnv[KEY_FILE_CHANNEL_ENV] = answer.path;
65+
if (answer.kind === 'legacy-only') {
66+
return (
67+
`Data key: reading ${answer.legacy}, the key file an earlier \`os start\` kept for this project. ` +
68+
`\`os dev\` and \`os start\` now share ${answer.canonical}; move the file there to converge.`
69+
);
70+
}
71+
if (answer.kind === 'both-differ') {
72+
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}.`
76+
);
77+
}
78+
return undefined;
79+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import os from 'os';
4+
import path from 'path';
5+
6+
/**
7+
* The home directory `os start` keeps its state in.
8+
*
9+
* Priority: `--home` > `$OS_HOME` > `<cwd>/.objectstack` (project mode)
10+
* > `~/.objectstack` (global mode)
11+
*
12+
* Project mode keeps state next to the source so each project is
13+
* self-contained and `os start` from another cwd doesn't reuse it.
14+
*
15+
* Moved here from `commands/start.ts` unchanged (#29934) so `os dev` can read
16+
* where an earlier `os start` kept the data key of the same project — see
17+
* `internal-crypto-key-file-channel.ts`. `env` defaults to `process.env`.
18+
*/
19+
export function resolveStartHome(
20+
flagValue: string | undefined,
21+
opts: { hasProjectConfig: boolean; cwd: string; env?: NodeJS.ProcessEnv },
22+
): string {
23+
const raw = flagValue ?? (opts.env ?? process.env).OS_HOME;
24+
if (raw && raw.trim().length > 0) {
25+
const v = raw.trim();
26+
if (v.startsWith('~')) return path.resolve(os.homedir(), v.slice(1).replace(/^[/\\]/, ''));
27+
return path.resolve(v);
28+
}
29+
if (opts.hasProjectConfig) {
30+
return path.resolve(opts.cwd, '.objectstack');
31+
}
32+
return path.resolve(os.homedir(), '.objectstack');
33+
}

0 commit comments

Comments
 (0)