Skip to content

Commit 790d9cf

Browse files
fix(cli,runtime): the ownership refusal os start prints exports the auth secret it keeps in its home (#29913)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBXyqsnJCqZ3DaKgxUVGuVM4)_ domain:server#6 · os-support-ai · session_01WGufzj1WJNGVejm8mrJ71y Fixes #29882 ## What changes - **`@objectstack/runtime`.** `OrganizationOwnershipBootGuardOptions` gains an optional `authSecretFile`: the file a production boot read its auth secret from because the operator set none. On a boot that is not a development boot, the refusal names both ceremony commands with that secret exported from the file. This covers every refusal that names the ceremony: no marker, an outdated generation, an unreadable marker, an unverified marker, a column read that failed, and a birth marker that could not be written. A path a shell would split or expand becomes one double-quoted word. One sentence says why the commands carry the secret. The `--dev` spelling does not change. ``` Run 'OS_AUTH_SECRET="$(cat /srv/app/.objectstack/auth-secret)" os migrate organization-ownership --out PLAN' to write and review its plan, take a backup, then run 'OS_AUTH_SECRET="$(cat /srv/app/.objectstack/auth-secret)" os migrate organization-ownership --apply --plan-file PLAN --backup-taken', and start again. Both commands export the auth secret this server read from /srv/app/.objectstack/auth-secret: the ceremony composes the security plugin only with the secret the deployment serves with, and without it the apply can stop at its authored-conversion step. ``` - **`@objectstack/cli`.** `os start` now tells its `serve` child where the secret came from. It uses a new internal channel, `OS_INTERNAL_AUTH_SECRET_FILE` (`utils/internal-auth-secret-channel.ts`), which follows the same naming and ownership rule as `OS_INTERNAL_ARTIFACT_PATH` and is deliberately undocumented. The parent sets the variable only when the secret came from `<home>/auth-secret`, and only when that file really holds the secret (a generated secret whose write failed lives in the process alone). Otherwise it deletes the variable, so an inherited copy never speaks for the parent. `os serve` passes the file to the guard. - **Docs.** `content/docs/deployment/cli.mdx` › `os migrate organization-ownership` gains one bullet: run the ceremony with the auth secret the deployment serves with, and what an `os start` refusal names. - **Clause-② yes (widening).** `OrganizationOwnershipBootGuardOptions` is re-exported from `@objectstack/runtime`, so the optional `authSecretFile` widens the package's public surface. It is not a protocol change, so the changeset stays `patch`. ## Why On a v17-shaped database, `os start` refused the boot and named the ceremony bare. When the operator had set no secret, `os start` was serving with the secret it persists in its home, and that secret is not in the operator's shell. Run exactly as printed, the ceremony composed no security plugin: the plan was written and the apply stopped at `authored-conversion` ("Did not compose the security plugin…"), and nothing named the secret or where it lived. That is the card's measurement at `589049b0`. #23871 fixed the same gap for `os dev` / `os serve --dev`. This PR fixes the `os start` branch. **Release read (asked by the maintainer):** not blocking for 18.3.0. The text has read this way since 18.0.0, so it is no regression. The stopped apply resumes from its checkpoint with no data lost once `OS_AUTH_SECRET` is exported. The changeset says that for operators who already hit it. **Out of scope, noted:** a boot whose secret the operator passed as `os start --auth-secret X`, with no matching export, still names the bare commands. The refusal has no file to point at, and the secret must never be printed. The changeset says to run them with that secret in `OS_AUTH_SECRET`. The database URL is also not named: `os start -d URL` needs `OS_DATABASE_URL`/`--database-url` on the ceremony, as the card's own reproduction set it. ## Verification Measured on `objectstack-ai/objectstack@00f72f9` (main at branch point) plus this branch. - `packages/runtime` `src/organization-ownership-boot-guard.test.ts`: 23/23 pass. A new case covers the file-sourced spelling in all four refusal states, quoting of a path with a space and `$`, and the `--dev` spelling winning over a file. - New `packages/cli/test/start-organization-ownership-refusal.integration.test.ts` (built CLI, `bin/run.js`, `NODE_ENV` unset): 5/5 pass, file wall time about 29 s locally. It boots a fresh `os start --home H`, stages the 17.x shape (`sys_secret.organization_id` plus no marker), and checks four things: - the refused boot names both commands with `OS_AUTH_SECRET="$(cat H/auth-secret)"`, read from the printed line, and the secret itself is never printed; - a boot under the operator's `OS_AUTH_SECRET` names the bare commands; - the two commands, run **through bash exactly as printed** in a shell with no secret, write the plan and complete the apply ("The ceremony is complete", "Security Plugin initialized", no "Did not compose the security plugin"); - the next `os start` serves the database. - **Reverse check:** with the channel write ablated in `start.ts` (CLI rebuilt), 2 of the 5 cases go red: the refusal names the bare commands, and the apply run as printed never initializes the security plugin. Restored from the commit, all 5 are green again. - `pnpm typecheck` for `@objectstack/runtime` and `@objectstack/cli` (including `check:test-typecheck`): pass. `eslint` on the 6 touched source and test files: clean. - start.ts source pins (`port-contract-single-source`, `console-route-ledger.conformance`, `child-env-source-loader.pin`, `start-port-forwarding-channel.pin`, `artifact-child-env.pin`): 69/69 pass. - `pnpm lint` (repo-wide): exit 0. - `dispatch-gates.mjs --commands` for this change set, plus the 5 roster-adjacent gates it marks ⛔: 67 commands, 64 exit 0 on the first sweep. 3 exited 3 on a missing prerequisite, not on a finding. `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched into the shallow clone; once fetched it exits 0 (48 cases). `check:dual-build-cjs-loads` and `check:i18n-coverage` needed a full `pnpm build`; after it, both exit 0. The 31 docs-family gates the `cli.mdx` edit adds to the derived set also exit 0. Clause-②: yes (widening) Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [ ] 4. One issue, one branch off an up-to-date `main`, one pull request. The branch is the harness-pinned `claude/project-thread-7lmys5`, not `claude/issue-29882-*`. It is off current `main`, with one issue and one PR. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzj1WJNGVejm8mrJ71y <!-- signed: domain:server#6 · os-support-ai · session_01WGufzj1WJNGVejm8mrJ71y --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ed47cb commit 790d9cf

8 files changed

Lines changed: 473 additions & 15 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(cli,runtime): the ownership refusal `os start` prints names the auth secret the ceremony needs
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
On a database 17.x or an 18.0 pre-release created, `os start` refuses to boot and names the ownership ceremony. When the operator had set no auth secret, `os start` was serving with the one it generated and keeps in `<home>/auth-secret`, and that secret is not in the operator's shell. Run exactly as printed, the ceremony composed no security plugin: it wrote the plan and stopped the apply at its `authored-conversion` step, and nothing said which secret was missing. The refusal now names both commands with that secret exported from its file, by path:
12+
13+
```sh
14+
OS_AUTH_SECRET="$(cat /srv/app/.objectstack/auth-secret)" os migrate organization-ownership --out PLAN
15+
OS_AUTH_SECRET="$(cat /srv/app/.objectstack/auth-secret)" os migrate organization-ownership --apply --plan-file PLAN --backup-taken
16+
```
17+
18+
A boot whose secret the operator gave (`OS_AUTH_SECRET` in the environment, or `--auth-secret`) names the bare commands, as before: run them with that secret in `OS_AUTH_SECRET`. `os dev` keeps its `--dev` spelling. `OrganizationOwnershipBootGuardOptions` gains the optional `authSecretFile`, the file a production boot read its secret from.
19+
20+
If an apply already stopped this way, it resumes from its checkpoint: re-run it with `OS_AUTH_SECRET="$(cat <home>/auth-secret)"`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,15 @@ os migrate organization-ownership --apply --plan-file plan.json --backup-taken
18391839
os migrate organization-ownership --dev … # boot as `os serve --dev` does
18401840
```
18411841
1842+
- **Run it with the auth secret the deployment serves with.** The ceremony
1843+
composes the security plugin only when an auth secret resolves
1844+
(`OS_AUTH_SECRET`), as `os serve` does; without it the apply can stop at its
1845+
`authored-conversion` step. A deployment `os start` serves with the secret
1846+
it generated keeps that secret in `<home>/auth-secret`, and its refusal names
1847+
both commands with it exported:
1848+
`OS_AUTH_SECRET="$(cat <home>/auth-secret)" os migrate organization-ownership …`.
1849+
`--dev` composes the plugin as a development boot does.
1850+
18421851
- **The plan writes nothing.** Per table it lists the fate (column dropped,
18431852
mirror rows deleted, owner attributed, rows reported), the row counts, and the
18441853
rows whose owner cannot be derived. It refuses to plan a table it cannot read.

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../uti
1212
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1313
import { registerInstalledPlugins } from '../utils/installed-plugins-boot.js';
1414
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
15+
import { readAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
1516
import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js';
1617
import { makeDirectorySync } from '../utils/make-directory.js';
1718
// The precedence's last rung — whether the cwd config takes part — decided by
@@ -4194,8 +4195,13 @@ export default class Serve extends Command {
41944195
const { OrganizationOwnershipBootGuardPlugin } = await import('@objectstack/runtime');
41954196
// A development boot's refusal names the ceremony with `--dev`, so the
41964197
// ceremony boots the way this one did (its secret, .env* files and
4197-
// `telemetry` sibling).
4198-
await kernel.use(new OrganizationOwnershipBootGuardPlugin({ dev: isDev }));
4198+
// `telemetry` sibling). A boot `os start` supervises with the secret it
4199+
// persisted in its home names that file, so the ceremony runs with the
4200+
// secret this server composes its security plugin with.
4201+
await kernel.use(new OrganizationOwnershipBootGuardPlugin({
4202+
dev: isDev,
4203+
authSecretFile: readAuthSecretFileChannel(process.env),
4204+
}));
41994205
trackPlugin('OrganizationOwnershipBootGuard');
42004206
}
42014207

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '../utils/artifact-precedence.js';
2424
import { ServeRestartCoordinator } from '../utils/dev-restart.js';
2525
import { readEnvWithDeprecation } from '@objectstack/types';
26+
import { writeAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
2627
// The ONE port contract, shared with `dev` and with the `serve` child this
2728
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
2829
// no range, no reader, no wording; a second copy of the bound is exactly what
@@ -312,9 +313,15 @@ export default class Start extends Command {
312313
// return 404 and breaks the Console's login flow.
313314
// Quick-start should "just work" without the user having to
314315
// export AUTH_SECRET.
315-
const authSecret = flags['auth-secret']
316-
?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true })
317-
?? readOrCreateAuthSecret(homeDir);
316+
//
317+
// Where the secret came from travels to the child too: a persisted secret
318+
// is not in the operator's shell, so a command the child prints for them
319+
// to run (the v18 ownership refusal) has to export it from its file
320+
// (`utils/internal-auth-secret-channel.ts`).
321+
const operatorAuthSecret = flags['auth-secret']
322+
?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true });
323+
const persistedAuthSecret = operatorAuthSecret === undefined ? readOrCreateAuthSecret(homeDir) : undefined;
324+
const authSecret = persistedAuthSecret ? persistedAuthSecret.secret : operatorAuthSecret;
318325

319326
// ── Banner ──────────────────────────────────────────────────────
320327
// `Config:` only when the cwd config takes part in this boot — the same
@@ -454,6 +461,7 @@ export default class Start extends Command {
454461
...(flags['database-auth-token'] ? { OS_DATABASE_AUTH_TOKEN: flags['database-auth-token'] } : {}),
455462
AUTH_SECRET: authSecret,
456463
};
464+
writeAuthSecretFileChannel(localEnv, persistedAuthSecret?.file);
457465
// NODE_ENV is only forced to production when the user has not set it.
458466
// Allows `NODE_ENV=development objectstack start` to work for debugging.
459467
//
@@ -601,21 +609,24 @@ function resolveHome(
601609
* Read the persisted AUTH_SECRET from `<home>/auth-secret`, or generate
602610
* one on first run and persist it so subsequent restarts keep existing
603611
* sessions valid. Mode 0o600 to keep the secret reasonably private.
612+
* `file` is the path only when the file holds the returned secret: a
613+
* generated one whose write failed lives in this process alone.
604614
*/
605-
function readOrCreateAuthSecret(homeDir: string): string {
615+
function readOrCreateAuthSecret(homeDir: string): { secret: string; file?: string } {
606616
const secretPath = path.join(homeDir, 'auth-secret');
607617
try {
608618
const existing = fs.readFileSync(secretPath, 'utf8').trim();
609-
if (existing.length >= 32) return existing;
619+
if (existing.length >= 32) return { secret: existing, file: secretPath };
610620
} catch {
611621
// file missing or unreadable — fall through to generation
612622
}
613623
const secret = crypto.randomBytes(32).toString('hex');
614624
try {
615625
makeDirectorySync(homeDir);
616626
fs.writeFileSync(secretPath, secret + '\n', { mode: 0o600 });
627+
return { secret, file: secretPath };
617628
} catch {
618629
// best-effort persist; secret is still returned for this process
619630
}
620-
return secret;
631+
return { secret };
621632
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The CLI's INTERNAL parent-to-child channel for WHERE `os start` read the
5+
* auth secret it hands its `serve` child.
6+
*
7+
* `os start` gives the child a secret on every boot: the operator's
8+
* (`--auth-secret`, `$OS_AUTH_SECRET` and its legacy names) or, when the
9+
* operator set none, the one it keeps in `<home>/auth-secret`, generated on
10+
* the first run. The child sees the secret alone and cannot tell the two
11+
* apart, yet a command the child prints for the operator to run in their own
12+
* shell has to: the operator's secret is already in that shell, the persisted
13+
* one is not. The v18 ownership refusal is that command — the ceremony
14+
* composes the security plugin only with the secret the deployment serves
15+
* with — so the child names the file to export it from
16+
* (`OrganizationOwnershipBootGuardOptions.authSecretFile`).
17+
*
18+
* Same naming and ownership rule as `OS_INTERNAL_ARTIFACT_PATH`
19+
* (`internal-artifact-channel.ts`): `INTERNAL` in the domain slot, a private
20+
* call between two processes the CLI owns both ends of, deliberately not
21+
* listed in `content/docs/deployment/environment-variables.mdx`. The parent
22+
* OWNS the variable in the child env: it is set only when the secret came
23+
* from the file and deleted otherwise, so an inherited copy never speaks for
24+
* a secret the parent took from somewhere else.
25+
*/
26+
export const INTERNAL_AUTH_SECRET_FILE_ENV = 'OS_INTERNAL_AUTH_SECRET_FILE';
27+
28+
/**
29+
* Write the channel into a `serve` child's environment: the file when the
30+
* secret the child is given came from it, otherwise no variable at all.
31+
*/
32+
export function writeAuthSecretFileChannel(env: NodeJS.ProcessEnv, file: string | undefined): void {
33+
if (file) env[INTERNAL_AUTH_SECRET_FILE_ENV] = file;
34+
else delete env[INTERNAL_AUTH_SECRET_FILE_ENV];
35+
}
36+
37+
/** Read the channel in the `serve` child: the file, or `undefined` when the parent set none. */
38+
export function readAuthSecretFileChannel(env: NodeJS.ProcessEnv): string | undefined {
39+
const file = env[INTERNAL_AUTH_SECRET_FILE_ENV];
40+
return file ? file : undefined;
41+
}

0 commit comments

Comments
 (0)