Skip to content

Commit a8adf66

Browse files
fix(cli): on a first run, os start's compile step writes to stderr, so the stdio MCP transport's stdout carries only JSON-RPC (#24214)
Fixes #24192 Clause-②: no With `OS_MCP_STDIO_ENABLED=true`, a first-run `os start` (no `dist/objectstack.json`) now writes only JSON-RPC frames to stdout. The automatic compile step's progress now goes to stderr, next to the rest of `os start`'s own output. ## What was wrong (measured on `f792290df`, built CLI through `bin/run.js`, `NODE_ENV` unset) - **H1 holds.** `start.ts` calls `redirectStdoutToStderr()` (`utils/json-stdout.ts`) at the top of `run()`. That call patches `process.stdout.write` in this process only. The compile `spawnSync` gave its child `stdio: ['ignore', 'inherit', 'inherit']`, so the child wrote to the raw fd 1. That is the same fd the `serve` child's stdio transport writes JSON-RPC frames to. - **Re-measured once, with stdio enabled:** the new pin, run against the unfixed build, went RED with `stdout carries 24 line(s) a JSON-RPC client would fail to parse`. Those 24 lines are 19 text lines and 5 blank lines: `◆ Compile`, the rule, the `→ …` steps, `✓ Build complete`, the `Data:`/`UI:`/`Logic:`/`Security:`/`Runtime:` summary and `Artifact: …`. All of them came ahead of the `initialize` answer frame. The card's 27 is the #24002 dev's count at `fd16e46f6` on another fixture. A host-plugin variant of this fixture also measured 24 here. The producer and the mechanism are the same; only the count differs. - **H2 is falsified: the plain `os start` gets the same treatment.** H2 asked whether the compile lines on stdout were the intended human output of a plain `os start` (stdio not enabled). Measured on the same first-run fixture with stdio OFF, unfixed build: - stdout carried exactly 19 lines, and every one came from the compile child. - Everything else `os start` prints was already on stderr: its own header (`◆ ObjectStack`, `→ Compiling objectstack.config.ts → dist/objectstack.json...`, `Config:`, `Home:`, `Artifact:`, `Database:`, `Environment:`, `→ Starting server...`) and the whole `serve` banner. - The compile lines were the one part of `os start`'s output that was not on stderr. `start` decides nothing by stdio mode. `redirectStdoutToStderr()` is UNCONDITIONAL there and in `serve` (#7915), and `json-stdout.ts` gives the reason: a conditional needs a reliable signal at the moment each line prints, and it fails silently in the worse direction. So always-stderr is already the contract, and the fix does not branch on stdio mode. ## The fix (`packages/cli/src/commands/start.ts`, the compile `spawnSync` only) `stdio: ['ignore', 'inherit', 'inherit']` becomes `stdio: ['ignore', 2, 'inherit']`, so the compile child's stdout is this process's fd 2. - stdin stays `'ignore'` (#24002), and stderr stays inherited. - `--compile` on a later run takes the same spawn, so it is covered too. - The `serve` child keeps `stdio: 'inherit'`, because its transport owns fd 1. - Nothing is dropped: the same lines still print, on stderr. - `os compile`'s own output when you run it directly is unchanged (held off by the claim). Measured after the fix, plain `os start`, same fixture: stdout carries 0 lines, and `✓ Build complete` is on stderr. ## Pin: `packages/cli/test/start-stdio-stdout-purity.integration.test.ts` The pin sits beside `serve-stdio-stdout-purity.e2e.test.ts`, which pins the same invariant for `os serve`. **Fixture and boots:** - One minting `serve --dev` boot (stdio auto-start is fail-closed, ADR-0101), then one first-run `os start`. - The `os start` boot runs with `OS_MCP_STDIO_ENABLED=true` and the minted key, with `initialize` written at spawn. - Both are bounded children: a detached process group, SIGTERM then SIGKILL, and a 120 s answer deadline. **On that one boot, the pin asserts:** 1. Every complete stdout line, through the `initialize` answer and a 750 ms settle window after it, parses as a JSON-RPC 2.0 frame. Blank lines count. 2. The answer carries the request's own `id` and no `error`, so the channel is live and not merely quiet. 3. The compile child's `◆ Compile` and `✓ Build complete` are on stderr: the output moved and was not deleted. 4. Positive control: `dist/objectstack.json` exists afterwards, so the boot really took the auto-compile leg. **Tier and budget:** - The file is in the `integration` tier: the `vitest-tiers.ts` predicate classifies it because it spawns `bin/run.js`. The `.integration` name keeps it on the queue run. - `test-budget: 20`: 15.0 s, the slowest of three local runs (15.01 / 13.41 / 13.85 s), x 1.25. There is no CI reading yet. - It spawns `bin/run.js` with `NODE_ENV: undefined`, so `scripts/check-cli-test-child-env.mjs`'s pinned built-entrypoint population gains it ("eleven" becomes "twelve"). It carries the same `[#N] Joined the population deliberately` note as the #24002 pin. This is the edit shape PR #24184 used. **Reverse verification.** The fix was committed first (`45a7b8667`). One leg, run under a `trap … EXIT INT TERM` restore: - `scripts/ablation-replace.mjs` swapped `stdio: ['ignore', 2, 'inherit']` back to `stdio: ['ignore', 'inherit', 'inherit']`. Anchor 1 became 0, and the blob went from `733683e27b67` to `1dc9de46b023`. - The CLI was rebuilt. `ablation-dist-preflight.mjs` found the mutant marker in `dist/commands/start.js` (exit 0). - The pin went **RED**: `stdout carries 24 line(s) a JSON-RPC client would fail to parse (#24192)`. - Restored: the blob equals HEAD (`733683e27b67`), `git diff HEAD` is empty and `git status` is clean. After the rebuild, the mutant marker is absent from `dist/` (`--absent`, exit 0) and the fix marker is present (exit 0). ## Local verification - Pin at `8319dd69b` (after merging `main`), run together with the #24002 pin: `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=1 test/start-stdio-stdout-purity.integration.test.ts test/start-mcp-stdio-early-initialize.integration.test.ts` gave 2 files and 2 tests passed. The rest of the `integration` tier is declared to CI. - Unit tier at `81612d923`: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` gave 310 files and 4599 tests passed. The merge brought no `packages/cli` path. - `pnpm --filter @objectstack/cli typecheck` exited 0, and `check:test-typecheck` was OK. `tsc -p tsconfig.test.json --noEmit --listFiles` lists the new pin once, and it has no errors. - `pnpm exec eslint --no-inline-config` over the three changed source files exited 0 with no output. The repo-wide `pnpm lint` is left to CI. - `node scripts/check-cli-test-child-env.mjs --self-test` passed 152 cases. Before the population edit, it failed on exactly that case. - Derived gate union at `8319dd69b`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` named 85 commands. All 85 exited 0, each exit code captured before any pipe. `--ran` reconciled them: `85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN`, exit 0. The union includes `check:cli-test-child-env` (14 built-CLI spawns, all out of development/test), `check:pm-dispatch-gates` (fast tier, 1884 cases), `check:nul-bytes`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. The derivation names `origin/main` as 3 commits ahead after the merge, with one changed derivation input, `scripts/check-route-envelope.mjs`. That edit moves `packages/rest` MODULES counts only, and this diff touches no `packages/rest` path. CI runs on the merge ref. ## Acceptance notes - **A stale sentence in the #24002 pin's header.** `start-mcp-stdio-early-initialize.integration.test.ts` says "⛔ Stdout PURITY is not asserted: the compile child's progress lines reach the same stdout. That is a separate defect, not this card's". After this PR the first half is no longer true, and the purity half is pinned here. The file is outside this claim's surface, so it is left as is. Noted only (carrier: none). - **The 24 vs 27 count.** Both fixtures here measured 24 lines: one object, and the same with a host plugin. The card's 27 came from another build and fixture. The mechanism and the producer are identical. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f11af5 commit a8adf66

4 files changed

Lines changed: 378 additions & 2 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(cli): on a first run, `os start` writes its compile step's progress to stderr, so stdout carries only the stdio MCP transport's JSON-RPC frames
8+
9+
- **What was wrong.** With `OS_MCP_STDIO_ENABLED=true`, stdout is the stdio MCP transport's protocol channel, and an MCP client parses every line it reads there as JSON-RPC. `os start` already writes its own banner and progress to stderr, but in a project with no `dist/objectstack.json` it compiles the config first, and that compile step wrote its progress (`◆ Compile`, `✓ Build complete`, `Artifact: …`, 24 lines for a one-object app) to stdout, ahead of the answer to the client's `initialize`.
10+
- **What changed.** The automatic compile step's output now goes to stderr, next to the rest of `os start`'s own output, with or without the stdio transport. Nothing is dropped: the same lines still print, on stderr.
11+
- **Unchanged:** the flags and environment variables of `os start` and `os compile`, when `os start` compiles, the artifact it writes, and `os compile`'s own output when you run it directly.

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,22 @@ export default class Start extends Command {
249249
// on "Config file not found: …/{"jsonrpc":"2.0",…}" and `initialize` was
250250
// consumed before any transport existed. The compile reads no input, so
251251
// it gets none: `'ignore'` keeps every byte in the pipe for `serve`.
252+
//
253+
// ── stdout belongs to the protocol, the compile step included (#24192) ──
254+
// `redirectStdoutToStderr()` above patches `process.stdout.write` in
255+
// THIS process only; a child handed `'inherit'` writes to the raw fd 1,
256+
// the fd the `serve` child's stdio transport writes JSON-RPC frames to.
257+
// Measured on a first run with the transport enabled: the compile's 24
258+
// progress lines (`◆ Compile`, `✓ Build complete`, `Artifact: …`) reached
259+
// the client ahead of the `initialize` answer. So the child's stdout is
260+
// this process's stderr (fd 2), where the header above already goes —
261+
// unconditionally, for the reason the top of `run()` gives: everything
262+
// this supervisor prints is a diagnostic, stdio transport or not.
263+
// Pinned by `test/start-stdio-stdout-purity.integration.test.ts`.
252264
const compileResult = spawnSync(
253265
process.execPath,
254266
[binPath, 'compile', '--output', outputPath],
255-
{ stdio: ['ignore', 'inherit', 'inherit'], env: process.env },
267+
{ stdio: ['ignore', 2, 'inherit'], env: process.env },
256268
);
257269
if (compileResult.status !== 0) {
258270
printError('Compile failed — fix errors above before starting the server');
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// test-budget: 20 — spawns the real producer once per single-cause fixture: two boots of the built CLI (a `serve --dev` that mints the stdio key, then the first-run `os start` under test, its compile and serve children included); 15.0 s, the slowest of three local runs on a shared container, x 1.25, no CI reading yet
3+
4+
/**
5+
* #24192 — a FIRST-RUN `os start` with the stdio MCP transport enabled puts
6+
* nothing but protocol frames on stdout, its auto-compile step included.
7+
*
8+
* `serve-stdio-stdout-purity.e2e.test.ts` (#7915) pins this invariant for
9+
* `os serve`. This file pins it for the other documented stdio door,
10+
* `OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_… os start`, on the boot
11+
* that door takes on a project's first run: no `dist/objectstack.json`, so
12+
* `start` compiles the config in a one-shot child before it spawns `serve`.
13+
*
14+
* ## The defect, measured before the fix
15+
*
16+
* `start` routes its OWN stdout to stderr (`redirectStdoutToStderr()`), but
17+
* that patches `process.stdout.write` in this process only. The compile child
18+
* was spawned with `stdio[1] = 'inherit'`, so it wrote to the raw fd 1 — the
19+
* fd the `serve` child's transport writes JSON-RPC frames to. Every line of its
20+
* progress (`◆ Compile`, `✓ Build complete`, `Artifact: …`) reached the client
21+
* ahead of the `initialize` answer, and a client that parses each stdout line
22+
* as JSON-RPC (MCP stdio framing) reads each one as a transport error. The
23+
* line count measured on this fixture is in the PR that landed this file.
24+
*
25+
* ## What is pinned, on ONE boot
26+
*
27+
* 1. PURITY: every complete line on stdout — from spawn through the
28+
* `initialize` answer and a settle window after it — parses as a
29+
* JSON-RPC 2.0 frame. Empty lines count: a client's line reader hands an
30+
* empty line to `JSON.parse` too.
31+
* 2. The channel is live, not merely quiet: the transport answered the
32+
* client's own `initialize` (its `id`, no `error`). Silence would satisfy
33+
* (1) and is the worse bug.
34+
* 3. Moved, not deleted: the compile child's progress is on STDERR — the
35+
* operator keeps it.
36+
* 4. Positive control: `dist/objectstack.json` exists afterwards, so the
37+
* boot really took the auto-compile leg. A fixture that grew a `dist/`
38+
* would skip the compile child and leave (1) green for the wrong reason.
39+
*
40+
* The `initialize` is written AT SPAWN, as an MCP client does; that the line
41+
* survives the compile child is #24002's pin
42+
* (`start-mcp-stdio-early-initialize.integration.test.ts`), not this one's.
43+
*
44+
* ## Why a minting boot first
45+
*
46+
* stdio auto-start is fail-closed (ADR-0101): without an `OS_MCP_STDIO_API_KEY`
47+
* that resolves to a real identity there is no transport, so nothing would
48+
* answer and (2) could not hold. A `serve --dev` boot mints a key through the
49+
* product route (`POST /keys`) against a file-backed database that the
50+
* `os start` boot then reuses. It names its config positional, gets no stdin,
51+
* and must leave no `dist/` behind.
52+
*
53+
* ## Why `bin/run.js` with `NODE_ENV` unset, and `.integration`
54+
*
55+
* `os start` forces a production `serve` child unless `NODE_ENV` is set, and
56+
* the production supervisor is the documented stdio invocation. `NODE_ENV`
57+
* unset is also what makes oclif resolve the command from `dist/`, so this file
58+
* needs a built CLI (`@objectstack/cli#test` dependsOn `build`). The `.e2e`
59+
* name would move it to the nightly run; its subject is one spawn line in
60+
* `start.ts` that any edit there can break, so it rides the queue run.
61+
*/
62+
63+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
64+
import { spawn, type ChildProcess } from 'node:child_process';
65+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
66+
import { tmpdir } from 'node:os';
67+
import { join, resolve } from 'node:path';
68+
import { fileURLToPath } from 'node:url';
69+
import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js';
70+
import {
71+
childEnv,
72+
E2E_SECRET_KEY,
73+
portContentionError,
74+
portDriftError,
75+
probeThroughChild,
76+
randomPort,
77+
requireBuiltCli,
78+
RUN_JS_RESOLVES_FROM_DIST,
79+
} from './helpers/serve-process.js';
80+
81+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
82+
/** `bin/run.js` — the SHIPPED entrypoint. See the header for why this one. */
83+
const CLI = resolve(HERE, '../bin/run.js');
84+
85+
/** The banner's tail — every row above it has printed. */
86+
const READY = /Press Ctrl\+C to stop/;
87+
const BOOT_TIMEOUT_MS = 150_000;
88+
/** How long the boot under test has to answer: compile, boot, then the reply. */
89+
const ANSWER_TIMEOUT_MS = 120_000;
90+
/**
91+
* After the answer: the frame is detected as soon as its line is complete, and
92+
* anything else already in flight on the pipe gets this long to arrive and be
93+
* judged by the purity assertion too.
94+
*/
95+
const SETTLE_MS = 750;
96+
97+
/** The compile child's own lines — the producer this file is about. */
98+
const COMPILE_HEADER = '◆ Compile';
99+
const COMPILE_DONE = /✓ Build complete/;
100+
101+
/** The request id the client's `initialize` carries; the answer must echo it. */
102+
const REQUEST_ID = 24192;
103+
const INITIALIZE = JSON.stringify({
104+
jsonrpc: '2.0',
105+
id: REQUEST_ID,
106+
method: 'initialize',
107+
params: {
108+
protocolVersion: '2024-11-05',
109+
capabilities: {},
110+
clientInfo: { name: 'objectstack-start-stdout-purity', version: '0.0.0' },
111+
},
112+
});
113+
114+
const STACK_LITERAL = `{
115+
manifest: { id: 'com.example.fx24192', namespace: 'fx', version: '1.0.0', type: 'app', name: 'Fx 24192' },
116+
objects: [{ name: 'fx_task', label: 'Task', pluralLabel: 'Tasks', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
117+
}`;
118+
119+
const groups: ChildProcess[] = [];
120+
let dir: string;
121+
let db: string;
122+
let apiKey: string;
123+
124+
/**
125+
* The environment both boots share; `undefined` unsets. Each spawn states its
126+
* child's `NODE_ENV` itself, at the call (`check:cli-test-child-env`, rule 3).
127+
*/
128+
function bootEnv(overrides: Record<string, string | undefined>): Record<string, string | undefined> {
129+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
130+
return childEnv({
131+
NO_COLOR: '1',
132+
OS_LOG_LEVEL: 'warn',
133+
OS_DISABLE_CONSOLE: '1',
134+
OS_CLOUD_URL: 'off',
135+
OS_HOME: join(dir, '.os-home'),
136+
OS_SECRET_KEY: E2E_SECRET_KEY,
137+
OS_ARTIFACT_URL: undefined,
138+
OS_ARTIFACT_PATH: undefined,
139+
OS_DATABASE_URL: undefined,
140+
OS_MCP_STDIO_ENABLED: undefined,
141+
OS_MCP_STDIO_API_KEY: undefined,
142+
...overrides,
143+
});
144+
}
145+
146+
/** SIGTERM the whole process group (a supervisor and its children), SIGKILL if it lingers. */
147+
async function stopGroup(child: ChildProcess): Promise<void> {
148+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
149+
await new Promise<void>((done) => {
150+
const give = setTimeout(() => {
151+
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
152+
done();
153+
}, 15_000);
154+
child.once('exit', () => { clearTimeout(give); done(); });
155+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); }
156+
});
157+
}
158+
159+
/** One stdout line, read as a client's line reader reads it: a JSON-RPC 2.0 frame, or nothing. */
160+
function parseFrame(line: string): Record<string, unknown> | undefined {
161+
try {
162+
const msg = JSON.parse(line) as unknown;
163+
return msg !== null && typeof msg === 'object' && (msg as { jsonrpc?: unknown }).jsonrpc === '2.0'
164+
? (msg as Record<string, unknown>)
165+
: undefined;
166+
} catch {
167+
return undefined;
168+
}
169+
}
170+
171+
/**
172+
* The COMPLETE lines of a stream so far. The last element of the split is
173+
* dropped: it is the empty string after a trailing newline, or a line the pipe
174+
* has not finished delivering — a chunk boundary is evidence of nothing.
175+
*/
176+
function completeLines(text: string): string[] {
177+
return text.split('\n').slice(0, -1).map((line) => line.replace(/\r$/, ''));
178+
}
179+
180+
beforeAll(async () => {
181+
// Build prerequisite first: both boots resolve their command from `dist/`.
182+
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
183+
184+
dir = mkdtempSync(join(tmpdir(), 'os-start-stdout-purity-'));
185+
mkdirSync(join(dir, '.os-home'), { recursive: true });
186+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx-24192', private: true, type: 'module' }));
187+
writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSourceFromLiteral(STACK_LITERAL));
188+
linkSpec(dir);
189+
db = `file:${join(dir, 'probe.db')}`;
190+
191+
// ── Boot 1: mint the stdio key through the product route ────────────────
192+
const port = randomPort();
193+
const mint = spawn(
194+
process.execPath,
195+
[CLI, 'serve', 'objectstack.config.ts', '-p', port, '--dev', '--no-ui'],
196+
{
197+
cwd: dir,
198+
stdio: ['ignore', 'pipe', 'pipe'],
199+
detached: true,
200+
env: bootEnv({
201+
OS_DATABASE_URL: db,
202+
// Truly unset: oclif resolves the command from `dist/`.
203+
NODE_ENV: undefined,
204+
}),
205+
},
206+
);
207+
groups.push(mint);
208+
let output = '';
209+
await new Promise<void>((ready, fail) => {
210+
const timer = setTimeout(
211+
() => fail(new Error(`the minting serve never printed ${READY}\n--- output ---\n${output.slice(-4000)}`)),
212+
BOOT_TIMEOUT_MS,
213+
);
214+
const onData = (d: unknown) => {
215+
output += String(d);
216+
if (!READY.test(output)) return;
217+
clearTimeout(timer);
218+
// `--dev` opens serve's port auto-shift: a taken port is a GREEN boot
219+
// elsewhere, so the port is read back out of the child's own banner.
220+
const drift = portDriftError(output, 'os serve --dev (the minting boot)', port);
221+
if (drift) fail(drift);
222+
else ready();
223+
};
224+
mint.stdout!.on('data', onData);
225+
mint.stderr!.on('data', onData);
226+
mint.on('exit', (code) => {
227+
clearTimeout(timer);
228+
fail(portContentionError(output, 'os serve --dev (the minting boot)', port)
229+
?? new Error(`the minting serve exited ${String(code)} before ${READY}\n--- output ---\n${output.slice(-4000)}`));
230+
});
231+
});
232+
233+
const base = `http://localhost:${port}/api/v1`;
234+
// ⛔ No assertion inside a probe: any throw in there reads as a transport failure.
235+
const probe = <T>(what: string, request: () => Promise<T>): Promise<T> =>
236+
probeThroughChild(
237+
{
238+
child: mint,
239+
transcript: () => `\n--- child output ---\n${output.slice(-4000)}`,
240+
label: 'start-stdio-stdout-purity',
241+
what: `${what} on port ${port}`,
242+
},
243+
request,
244+
);
245+
const exchange = async (path: string, body: unknown, token?: string) => {
246+
const res = await fetch(`${base}${path}`, {
247+
method: 'POST',
248+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
249+
body: JSON.stringify(body),
250+
});
251+
let parsed: unknown = null;
252+
try { parsed = await res.json(); } catch { /* non-JSON error body */ }
253+
return { status: res.status, body: parsed };
254+
};
255+
256+
// `serve --dev` seeds this admin on an empty database.
257+
const signIn = await probe('the sign-in probe', () =>
258+
exchange('/auth/sign-in/email', { email: 'admin@objectos.ai', password: 'admin123' }));
259+
expect(signIn.status, `sign-in answered ${JSON.stringify(signIn.body)}`).toBe(200);
260+
const token = (signIn.body as { token?: string } | null)?.token;
261+
expect(token).toBeTruthy();
262+
263+
const minted = await probe('the key-mint probe', () => exchange('/keys', { name: 'start-stdout-purity' }, token));
264+
expect(minted.status, `key mint answered ${JSON.stringify(minted.body)}`).toBe(201);
265+
apiKey = String((minted.body as { data: { key: string } }).data.key);
266+
expect(apiKey.startsWith('osk_')).toBe(true);
267+
268+
await stopGroup(mint);
269+
// The boot under test is a FIRST run: nothing above may have compiled the config.
270+
expect(existsSync(join(dir, 'dist', 'objectstack.json')), 'the minting boot left a dist/ behind').toBe(false);
271+
}, 240_000);
272+
273+
afterAll(async () => {
274+
for (const child of groups) await stopGroup(child);
275+
if (dir) rmSync(dir, { recursive: true, force: true });
276+
}, 60_000);
277+
278+
describe('a first-run os start with the stdio MCP transport writes nothing but protocol frames to stdout (#24192)', () => {
279+
it('keeps the compile child off stdout, answers initialize there, and keeps the compile progress on stderr', async () => {
280+
const port = randomPort();
281+
const child = spawn(
282+
process.execPath,
283+
[CLI, 'start', '-p', port, '--no-ui', '--database', db],
284+
{
285+
cwd: dir,
286+
stdio: ['pipe', 'pipe', 'pipe'],
287+
detached: true,
288+
env: bootEnv({
289+
OS_MCP_STDIO_ENABLED: 'true',
290+
OS_MCP_STDIO_API_KEY: apiKey,
291+
// Truly unset: production posture, and oclif resolves the command from `dist/`.
292+
NODE_ENV: undefined,
293+
}),
294+
},
295+
);
296+
groups.push(child);
297+
// AT SPAWN, as an MCP client does — the line waits in the pipe through the compile.
298+
child.stdin!.write(`${INITIALIZE}\n`);
299+
300+
let stdout = '';
301+
let stderr = '';
302+
const outcome = await new Promise<{ answered: boolean; why: string }>((settle) => {
303+
const timer = setTimeout(() => settle({ answered: false, why: `no answer within ${ANSWER_TIMEOUT_MS} ms` }), ANSWER_TIMEOUT_MS);
304+
child.stdout!.on('data', (d) => {
305+
stdout += String(d);
306+
if (completeLines(stdout).some((line) => parseFrame(line)?.id === REQUEST_ID)) {
307+
clearTimeout(timer);
308+
settle({ answered: true, why: 'answered' });
309+
}
310+
});
311+
child.stderr!.on('data', (d) => { stderr += String(d); });
312+
child.on('exit', (code) => {
313+
clearTimeout(timer);
314+
settle({
315+
answered: false,
316+
why: portContentionError(stdout + stderr, 'os start (bin/run.js, NODE_ENV unset ⇒ production)', port)?.message
317+
?? `os start exited ${String(code)} before answering`,
318+
});
319+
});
320+
});
321+
if (outcome.answered) await new Promise((r) => setTimeout(r, SETTLE_MS));
322+
323+
const lines = completeLines(stdout);
324+
const seen = `\n--- ${outcome.why} ---\n--- stdout (head) ---\n${stdout.slice(0, 4000)}\n--- stderr (tail) ---\n${stderr.slice(-4000)}`;
325+
326+
// 2. The channel is live: the transport answered the client's own request.
327+
expect(outcome.answered, `initialize was never answered${seen}`).toBe(true);
328+
const frame = lines.map(parseFrame).find((msg) => msg?.id === REQUEST_ID);
329+
expect(frame?.error, `initialize was answered with an error${seen}`).toBeUndefined();
330+
331+
// 1. Purity — every line a client has read so far is a frame.
332+
const nonFrameLines = lines.filter((line) => parseFrame(line) === undefined);
333+
expect(
334+
nonFrameLines,
335+
`stdout carries ${nonFrameLines.length} line(s) a JSON-RPC client would fail to parse (#24192)${seen}`,
336+
).toEqual([]);
337+
338+
// 3. Moved, not deleted: the compile child's progress reached the operator on stderr.
339+
expect(stderr, `the compile child's header is missing from stderr${seen}`).toContain(COMPILE_HEADER);
340+
expect(stderr, `the compile child's completion line is missing from stderr${seen}`).toMatch(COMPILE_DONE);
341+
342+
// 4. Positive control: the boot took the auto-compile leg, so the compile child ran.
343+
expect(existsSync(join(dir, 'dist', 'objectstack.json')), `the boot never compiled the config${seen}`).toBe(true);
344+
345+
await stopGroup(child);
346+
}, 240_000);
347+
});

0 commit comments

Comments
 (0)