From 55f2ec3dbc9646809c8ddb95545a646b51aea39e Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 12 Aug 2026 20:40:01 -0500 Subject: [PATCH] fix: fail closed on invalid launch inputs --- apps/gateway/src/app.module.spec.ts | 19 +- .../src/chat/chat-runtime-fail-closed.spec.ts | 51 +++++ apps/gateway/src/chat/chat-runtime.ts | 20 +- docs/scratchpads/1182-fail-closed-launch.md | 59 ++++++ packages/config/src/index.ts | 1 + .../src/mosaic-config.fail-closed.spec.ts | 72 +++++++ packages/config/src/mosaic-config.ts | 14 ++ packages/mosaic/README.md | 16 ++ .../launch-fail-closed.integration.spec.ts | 193 ++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 97 ++++++--- 10 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 apps/gateway/src/chat/chat-runtime-fail-closed.spec.ts create mode 100644 docs/scratchpads/1182-fail-closed-launch.md create mode 100644 packages/config/src/mosaic-config.fail-closed.spec.ts create mode 100644 packages/mosaic/src/commands/launch-fail-closed.integration.spec.ts diff --git a/apps/gateway/src/app.module.spec.ts b/apps/gateway/src/app.module.spec.ts index 66a2d237..0128c8be 100644 --- a/apps/gateway/src/app.module.spec.ts +++ b/apps/gateway/src/app.module.spec.ts @@ -451,15 +451,24 @@ describe('AppModule federation gating', (): void => { ); it( - 'attributes an invalid monorepo-root dotenv tier to the default', + 'rejects an invalid explicit monorepo-root dotenv tier with a typed startup refusal', async (): Promise => { - const graph = await loadModuleGraphFromDotenv({ + const failure = await loadModuleGraphFromDotenv({ rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n', expectedProcessTier: 'invalid', - }); + }).then( + (): undefined => undefined, + (error: unknown): unknown => error, + ); - expect(graph.imports).not.toContain(graph.federationModule); - expectBootLogLine(graph.bootLogLines, 'local', 'default'); + expect(failure).toBeInstanceOf(Error); + expect(failure).toMatchObject({ + name: 'MosaicConfigEnvironmentError', + code: 'invalid_storage_tier', + }); + expect((failure as Error).message).toBe( + 'Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".', + ); }, MODULE_IMPORT_TIMEOUT_MS, ); diff --git a/apps/gateway/src/chat/chat-runtime-fail-closed.spec.ts b/apps/gateway/src/chat/chat-runtime-fail-closed.spec.ts new file mode 100644 index 00000000..80e52201 --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-fail-closed.spec.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { resolveChatRuntimeMode } from './chat-runtime.js'; + +interface TypedSelectionFailure { + readonly name: string; + readonly code: string; +} + +describe('#1182 fail closed — a wrong answer must not be read as no answer', () => { + it('FL-07 rejects an invalid explicit CHAT_HARNESS_RUNTIME before embedded construction', () => { + let embeddedConstructionCount = 0; + let failure: unknown; + + try { + const mode = resolveChatRuntimeMode({ CHAT_HARNESS_RUNTIME: 'pi-rpc-typo' }); + if (mode === 'legacy') { + embeddedConstructionCount += 1; + } + } catch (error: unknown) { + failure = error; + } + + expect + .soft(failure, 'invalid explicit runtime must produce a typed selection failure') + .toMatchObject({ + name: 'ChatRuntimeConfigurationError', + code: 'invalid_chat_harness_runtime', + } satisfies TypedSelectionFailure); + expect( + embeddedConstructionCount, + 'invalid explicit runtime must fail before the embedded runtime is constructed', + ).toBe(0); + }); + + it.each([{}, { CHAT_HARNESS_RUNTIME: '' }])( + 'preserves the documented transitional legacy default for true absence: %j', + (env) => { + expect(resolveChatRuntimeMode(env)).toBe('legacy'); + }, + ); + + it('anti-drift: runtime selection has no invalid-enum-to-legacy catch-all', () => { + const source = readFileSync(new URL('./chat-runtime.ts', import.meta.url), 'utf8'); + + expect( + source.includes("env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'"), + 'closed runtime enums must distinguish invalid explicit input from absence', + ).toBe(false); + }); +}); diff --git a/apps/gateway/src/chat/chat-runtime.ts b/apps/gateway/src/chat/chat-runtime.ts index 7300091a..a912e73b 100644 --- a/apps/gateway/src/chat/chat-runtime.ts +++ b/apps/gateway/src/chat/chat-runtime.ts @@ -41,14 +41,28 @@ export class ChatRuntimeUnavailableError extends Error { } } +/** Typed startup refusal for an invalid explicit chat-runtime selection. */ +export class ChatRuntimeConfigurationError extends Error { + readonly code = 'invalid_chat_harness_runtime' as const; + + constructor() { + super('Invalid CHAT_HARNESS_RUNTIME; expected "legacy" or "pi-rpc".'); + this.name = 'ChatRuntimeConfigurationError'; + } +} + /** - * Resolves the process-wide chat runtime mode from the environment. Anything other - * than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime. + * Resolves the process-wide chat runtime mode from the environment. Only true + * absence (unset or empty) retains the documented transitional legacy default; + * any other explicit value must be a member of the closed runtime enum. */ export function resolveChatRuntimeMode( env: Record = process.env, ): ChatRuntimeMode { - return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'; + const runtime = env['CHAT_HARNESS_RUNTIME']; + if (runtime === undefined || runtime === '') return 'legacy'; + if (runtime === 'legacy' || runtime === 'pi-rpc') return runtime; + throw new ChatRuntimeConfigurationError(); } // --------------------------------------------------------------------------- diff --git a/docs/scratchpads/1182-fail-closed-launch.md b/docs/scratchpads/1182-fail-closed-launch.md new file mode 100644 index 00000000..6040ae91 --- /dev/null +++ b/docs/scratchpads/1182-fail-closed-launch.md @@ -0,0 +1,59 @@ +# #1182 — fail closed when a wrong answer is read as no answer + +## Objective + +Implement FL-07 through FL-10 as one narrow fail-closed change: explicit invalid runtime/storage enum values and launcher failures must never be interpreted as absence or success. + +## Tracking + +- Issue: #1182 (child of #1156; W-F1 prerequisite) +- Branch: `fix/1182-fail-closed-launch` +- Verified base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02` + +## Scope and fence + +- Gateway chat runtime enum resolution and focused tests. +- Config storage-tier enum resolution and focused tests. +- Mosaic launcher spawn/provenance failure handling and focused integration/anti-drift tests. +- Narrow operator documentation in `packages/mosaic/README.md` if required by the behavior change. +- Preserve the #1109 lease broker without refactor; do not implement W-F1 composition. +- Do not touch #1178, #1179, #1072, #1080, #1054, `docs/TASKS.md`, or unrelated source/docs. + +## Plan + +1. RED: add one independent negative control per FL finding plus exact anti-drift checks. +2. Pause and report BASE / BRANCH / FENCE / SPLIT / RED to the coordinator. +3. After coordinator confirmation, implement each minimal fail-closed fix and verify each negative control independently. +4. Run affected package and repository test/typecheck/lint/build/format gates, focused security review, then commit/push/PR for independent exact-head verification. + +## Split assessment + +One PR remains reviewable: three narrowly bounded decision boundaries, no shared abstraction, no lease-broker refactor, and four independent tests naming the single defect class. Splitting would separate the same fail-closed invariant without reducing implementation coupling. If RED reveals material launcher harness expansion, split before production edits; the Gateway config half is the direct W-F1 selection prerequisite and would gate first. + +## Budget + +No explicit token or monetary cap supplied. Keep scope to the three production files, focused tests, this scratchpad, and one existing README. + +## Progress + +- Issue #1182 and parent #1156 read directly through Mosaic wrappers. +- Base derived from issue dependency plus repository topology: FL-07 exists on `origin/next` (introduced by #1172) and is absent from `origin/main`; branch reset before edits to the exact `origin/next` head above. + +## Verification evidence + +- RED observed independently for FL-07 through FL-10 before implementation. +- Focused GREEN: Gateway runtime 4/4, config 5/5, launcher 6/6. +- Four final per-finding production reverts discriminated: FL-07 made only FL-07 red; FL-08 made its unit and real Gateway boundary controls red after rebuilding config; FL-09 made only its abnormal-spawn controls red; FL-10 made all three provenance controls (`opencode`, `claudex`, and `yolo claudex`) red while sibling findings stayed green. +- Public config export negative control: removing only the `packages/config/src/index.ts` export caused TS2305 and `MosaicConfigEnvironmentError is not a constructor`; restoring it passed 5/5. +- Gateway full package: 74 files passed, 7 skipped; 829 tests passed, 17 skipped. +- Config full test/typecheck/lint/build: green. +- Mosaic typecheck/lint/build: green. Vitest is qualified-red only on the exact three update-notice stderr assertions tracked by #1190 — bare `--source`, bare `--decisions`, and bare `--observations`; 1528 other tests pass. +- The separate `test:framework-shell` command is red and is not attributed to #1190: `invariant_r_unittest.py` reports the host Pi runtime changed from measured 0.84.1 to 0.80.7. A clean archive of `origin/next@216cd722` reproduces the same single failure. The base test hardcodes the W-B measurement as `PI_VERSION = "0.84.1"`, then resolves `pi` via `shutil.which` and executes `--version`; on this host that is `/home/hermes/.npm-global/bin/pi`, whose global package reports 0.80.7. Issue #1191 tracks the immediate host drift and hardcoded-version design defect; #1184 tracks pinning/approving native Pi 0.84.1. No related source or test is changed here. +- Repository typecheck/lint/format/build: green. +- Codex security review: no findings. Initial code-review blocker (claudex provenance bypass) remediated with normal/yolo claudex coverage; subsequent public-export finding remediated. + +## Risks / blockers + +- Coordinated branch: no merge authority; coordinator routes independent exact-head verification. +- #1179 currently owns `apps/gateway/src/__tests__/required-security-wiring.test.ts`; this change does not touch it. +- #1190 independently tracks the pre-existing CLI smoke/update-notice stderr collision; no #1190 source or test is included here. diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 46a30504..53ff6f60 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -3,6 +3,7 @@ export { DEFAULT_LOCAL_CONFIG, DEFAULT_STANDALONE_CONFIG, DEFAULT_FEDERATED_CONFIG, + MosaicConfigEnvironmentError, loadConfig, validateConfig, detectFromEnv, diff --git a/packages/config/src/mosaic-config.fail-closed.spec.ts b/packages/config/src/mosaic-config.fail-closed.spec.ts new file mode 100644 index 00000000..765e0de0 --- /dev/null +++ b/packages/config/src/mosaic-config.fail-closed.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MosaicConfigEnvironmentError as PublicMosaicConfigEnvironmentError } from '@mosaicstack/config'; +import { detectFromEnv } from './mosaic-config.js'; + +interface TypedSelectionFailure { + readonly name: string; + readonly code: string; +} + +describe('#1182 fail closed — a wrong answer must not be read as no answer', () => { + const originalEnv = process.env; + + it('exports the typed storage-tier refusal from the public @mosaicstack/config entry point', () => { + expect(new PublicMosaicConfigEnvironmentError()).toBeInstanceOf(Error); + }); + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env['DATABASE_URL']; + delete process.env['VALKEY_URL']; + delete process.env['MOSAIC_STORAGE_TIER']; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('FL-08 rejects an invalid explicit MOSAIC_STORAGE_TIER before local PGlite selection', () => { + process.env['MOSAIC_STORAGE_TIER'] = 'federatd'; + let pgliteSelectionCount = 0; + let failure: unknown; + + try { + const config = detectFromEnv(); + if (config.storage.type === 'pglite') { + pgliteSelectionCount += 1; + } + } catch (error: unknown) { + failure = error; + } + + expect + .soft(failure, 'invalid explicit storage tier must produce a typed selection failure') + .toMatchObject({ + name: 'MosaicConfigEnvironmentError', + code: 'invalid_storage_tier', + } satisfies TypedSelectionFailure); + expect( + pgliteSelectionCount, + 'invalid explicit storage tier must fail before local PGlite is selected', + ).toBe(0); + }); + + it.each([undefined, ''])('preserves the local default for true absence: %j', (tier) => { + if (tier === undefined) delete process.env['MOSAIC_STORAGE_TIER']; + else process.env['MOSAIC_STORAGE_TIER'] = tier; + + const config = detectFromEnv(); + expect(config.tier).toBe('local'); + expect(config.storage.type).toBe('pglite'); + }); + + it('anti-drift: storage selection validates a non-empty tier before the local default', () => { + const source = readFileSync(new URL('./mosaic-config.ts', import.meta.url), 'utf8'); + + expect( + /if \(tier !== undefined && tier !== ''[^]*throw new [A-Za-z]+Error/.test(source), + 'closed storage enums must reject invalid explicit input before DEFAULT_LOCAL_CONFIG', + ).toBe(true); + }); +}); diff --git a/packages/config/src/mosaic-config.ts b/packages/config/src/mosaic-config.ts index 19290676..bd85e3df 100644 --- a/packages/config/src/mosaic-config.ts +++ b/packages/config/src/mosaic-config.ts @@ -20,6 +20,16 @@ export interface MosaicConfig { memory: MemoryConfigRef; } +/** Typed startup refusal for an invalid explicit storage-tier selection. */ +export class MosaicConfigEnvironmentError extends Error { + readonly code = 'invalid_storage_tier' as const; + + constructor() { + super('Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".'); + this.name = 'MosaicConfigEnvironmentError'; + } +} + /* ------------------------------------------------------------------ */ /* Defaults */ /* ------------------------------------------------------------------ */ @@ -126,6 +136,10 @@ export function validateConfig(raw: unknown): MosaicConfig { export function detectFromEnv(): MosaicConfig { const tier = process.env['MOSAIC_STORAGE_TIER']; + if (tier !== undefined && tier !== '' && !VALID_TIERS.has(tier)) { + throw new MosaicConfigEnvironmentError(); + } + if (tier === 'federated') { if (process.env['DATABASE_URL']) { return { diff --git a/packages/mosaic/README.md b/packages/mosaic/README.md index 6a1b9c93..bfe1bb9d 100644 --- a/packages/mosaic/README.md +++ b/packages/mosaic/README.md @@ -26,6 +26,15 @@ Set `MOSAIC_ASSUME_YES=1` (or ensure stdin is not a TTY) to skip all interactive | `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No | | `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No | +`MOSAIC_STORAGE_TIER` is a closed enum: `local`, `standalone`, or `federated`. +Unset or empty input selects the documented local default. Any other non-empty +value is a typed startup error and is rejected before a storage adapter is +selected. + +The Gateway process also accepts `CHAT_HARNESS_RUNTIME=legacy|pi-rpc`. Unset or +empty input retains the transitional `legacy` default. Any other non-empty value +is a typed startup error and is rejected before either runtime is selected. + ### Admin user bootstrap | Variable | Default | Required | @@ -55,6 +64,13 @@ mosaic yolo claude # …with --dangerously-skip-permissions mosaic codex | opencode | pi ``` +Every runtime launch requires its immutable `session.launch` provenance record +to be written before spawn. A provenance-write failure exits nonzero, starts no +runtime, and propagates no `MOSAIC_LAUNCH_ID`. Likewise, a spawn error, signal, +or missing numeric child status is reported with the fixed +`runtime_launch_failed` code and exits nonzero rather than being interpreted as +success. + ### `mosaic claudex` (EXPERIMENTAL) Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a diff --git a/packages/mosaic/src/commands/launch-fail-closed.integration.spec.ts b/packages/mosaic/src/commands/launch-fail-closed.integration.spec.ts new file mode 100644 index 00000000..679d1685 --- /dev/null +++ b/packages/mosaic/src/commands/launch-fail-closed.integration.spec.ts @@ -0,0 +1,193 @@ +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { delimiter, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); +const TSX_LOADER_URL = pathToFileURL(require.resolve('tsx')).href; +const COMMANDER_MODULE_URL = pathToFileURL(require.resolve('commander')).href; +const LAUNCH_MODULE_URL = pathToFileURL(join(import.meta.dirname, 'launch.ts')).href; +const DRIVER = ` + const launch = await import(${JSON.stringify(LAUNCH_MODULE_URL)}); + if (process.env['TEST_CLAUDEX_PROVENANCE'] === '1') { + const execute = launch.execRecordedClaudexRuntime; + if (typeof execute !== 'function') { + process.stderr.write('[missing_recorded_claudex_runtime]\\n'); + process.exit(70); + } + execute([], process.env, process.env['TEST_DANGEROUS'] === '1'); + } else { + const { Command } = await import(${JSON.stringify(COMMANDER_MODULE_URL)}); + const program = new Command(); + program.exitOverride(); + launch.registerLaunchCommands(program); + await program.parseAsync(['node', 'mosaic', 'opencode']); + } +`; + +interface LaunchFixture { + readonly root: string; + readonly home: string; + readonly bin: string; + readonly runtimePath: string; +} + +function createFixture(): LaunchFixture { + const root = mkdtempSync('/var/tmp/mosaic-launch-fail-closed-'); + const home = join(root, 'mosaic-home'); + const bin = join(root, 'bin'); + const runtimePath = join(bin, 'opencode'); + mkdirSync(join(home, 'runtime', 'opencode'), { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(home, 'AGENTS.md'), '# test agents\n'); + writeFileSync(join(home, 'SOUL.md'), '# test soul\n'); + writeFileSync(join(home, 'USER.md'), '# test user\n'); + writeFileSync(join(home, 'TOOLS.md'), '# test tools\n'); + writeFileSync(join(home, 'runtime', 'opencode', 'RUNTIME.md'), '# test runtime\n'); + return { root, home, bin, runtimePath }; +} + +function installRuntime(fixture: LaunchFixture, source: string): void { + writeFileSync(fixture.runtimePath, source); + chmodSync(fixture.runtimePath, 0o755); +} + +function runLauncher( + fixture: LaunchFixture, + extraEnv: NodeJS.ProcessEnv = {}, +): SpawnSyncReturns { + const env: NodeJS.ProcessEnv = { + ...process.env, + ...extraEnv, + MOSAIC_HOME: fixture.home, + PATH: `${fixture.bin}${delimiter}${process.env['PATH'] ?? ''}`, + }; + delete env['MOSAIC_AGENT_NAME']; + delete env['MOSAIC_AGENT_CLASS']; + delete env['MOSAIC_AGENT_TOOL_POLICY']; + + return spawnSync( + process.execPath, + ['--import', TSX_LOADER_URL, '--input-type=module', '--eval', DRIVER], + { + cwd: fixture.root, + encoding: 'utf8', + env, + }, + ); +} + +describe('#1182 fail closed — a wrong answer must not be read as no answer', () => { + let fixture: LaunchFixture; + + beforeEach(() => { + fixture = createFixture(); + }); + + afterEach(() => { + rmSync(fixture.root, { recursive: true, force: true }); + }); + + it.each([ + { + condition: 'spawn error', + runtime: '#!/definitely/missing/interpreter\n', + }, + { + condition: 'signal with null status', + runtime: '#!/usr/bin/env bash\nkill -TERM $$\n', + }, + ])('FL-09 converts $condition into a sanitized nonzero launch failure', ({ runtime }) => { + installRuntime(fixture, runtime); + + const result = runLauncher(fixture); + + expect.soft(result.status, 'spawn failure must never be converted into exit 0').not.toBe(0); + expect + .soft(result.stderr, 'spawn failure must report the fixed typed runtime_launch_failed code') + .toContain('[runtime_launch_failed]'); + expect( + result.stderr, + 'spawn diagnostics must not disclose the runtime fixture path', + ).not.toContain(fixture.runtimePath); + }); + + it.each([ + { launcher: 'opencode', extraEnv: {} }, + { + launcher: 'claudex', + extraEnv: { TEST_CLAUDEX_PROVENANCE: '1' }, + }, + { + launcher: 'yolo claudex', + extraEnv: { TEST_CLAUDEX_PROVENANCE: '1', TEST_DANGEROUS: '1' }, + }, + ])( + 'FL-10 refuses $launcher spawn when mandatory provenance cannot be recorded and fabricates no launch ID', + ({ extraEnv }) => { + const spawnMarker = join(fixture.root, 'runtime-spawned'); + const launchIdMarker = join(fixture.root, 'runtime-saw-launch-id'); + installRuntime( + fixture, + `#!/usr/bin/env bash\nprintf 'spawned' > "$TEST_SPAWN_MARKER"\nif [[ -n "\${MOSAIC_LAUNCH_ID:-}" ]]; then printf '%s' "$MOSAIC_LAUNCH_ID" > "$TEST_LAUNCH_ID_MARKER"; fi\n`, + ); + + const ledgerPath = join(fixture.home, 'fleet', 'run', 'sessions', 'events.ndjson'); + mkdirSync(ledgerPath, { recursive: true }); + + const result = runLauncher(fixture, { + ...extraEnv, + TEST_SPAWN_MARKER: spawnMarker, + TEST_LAUNCH_ID_MARKER: launchIdMarker, + }); + + expect.soft(result.status, 'mandatory provenance failure must exit nonzero').not.toBe(0); + expect + .soft(existsSync(spawnMarker), 'mandatory provenance failure must prevent spawn') + .toBe(false); + expect + .soft( + existsSync(launchIdMarker), + 'a failed provenance write must not fabricate or propagate a launch ID', + ) + .toBe(false); + expect + .soft( + result.stderr, + 'provenance refusal must report the fixed typed launch_provenance_failed code', + ) + .toContain('[launch_provenance_failed]'); + expect( + result.stderr, + 'provenance diagnostics must not disclose filesystem details', + ).not.toContain(fixture.root); + }, + ); + + it('anti-drift: launcher contains neither null-status success nor mandatory warn-and-run', () => { + const source = readFileSync(new URL('./launch.ts', import.meta.url), 'utf8'); + + expect + .soft( + source.includes('result.status ?? 0'), + 'spawnSync null status must never default to success', + ) + .toBe(false); + expect + .soft( + source.includes('[mosaic] WARNING: launch record not written:'), + 'mandatory provenance failures must never warn and continue', + ) + .toBe(false); + }); +}); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index c4d8f3d2..574dd1bd 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -162,13 +162,24 @@ function redactArgv(argv: string[]): string[] { ); } -function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void { +interface LaunchRecordSuccess { + readonly ok: true; + readonly launchId: string; +} + +interface LaunchRecordFailure { + readonly ok: false; + readonly code: 'launch_provenance_failed'; +} + +type LaunchRecordResult = LaunchRecordSuccess | LaunchRecordFailure; + +function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): LaunchRecordResult { + // Never let a stale or caller-supplied correlation id masquerade as this launch. + delete process.env['MOSAIC_LAUNCH_ID']; try { mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 }); - // Correlation id for the lease.register half. Set into process.env so it - // propagates through every `...process.env` / `...baseEnv` spread below. const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`; - process.env['MOSAIC_LAUNCH_ID'] = launchId; const record = { seq: Date.now(), kind: 'session.launch', @@ -193,14 +204,26 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, { mode: 0o600, }); - } catch (err) { - // Never block a launch on bookkeeping — but never fail silently either. - console.error( - `[mosaic] WARNING: launch record not written: ${err instanceof Error ? err.message : String(err)}`, - ); + return { ok: true, launchId }; + } catch { + return { ok: false, code: 'launch_provenance_failed' }; } } +function requireLaunchRecord(runtime: RuntimeName, cliArgs: string[], yolo: boolean): string { + const result = recordLaunch(runtime, cliArgs, yolo); + if (!result.ok) { + console.error( + `[mosaic] ERROR [${result.code}]: mandatory launch provenance could not be recorded; runtime was not started.`, + ); + process.exit(1); + } + + // Propagate correlation authority only after its immutable provenance exists. + process.env['MOSAIC_LAUNCH_ID'] = result.launchId; + return result.launchId; +} + // ─── Pre-flight checks ────────────────────────────────────────────────────── function checkMosaicHome(): void { @@ -925,7 +948,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('claude', cliArgs, yolo); + requireLaunchRecord('claude', cliArgs, yolo); execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); break; } @@ -939,7 +962,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('codex', cliArgs, yolo); + requireLaunchRecord('codex', cliArgs, yolo); execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') }); break; } @@ -948,7 +971,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev // opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode. ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md')); console.log(`[mosaic] Launching ${label}${modeStr}...`); - recordLaunch('opencode', args, yolo); + requireLaunchRecord('opencode', args, yolo); execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') }); break; } @@ -964,7 +987,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('pi', cliArgs, yolo); + requireLaunchRecord('pi', cliArgs, yolo); execLeaseGatedRuntime('pi', cliArgs); break; } @@ -1008,19 +1031,31 @@ function execLeaseGatedRuntime( ); } -/** exec into the runtime, replacing the current process. */ +type RuntimeLaunchFailureReason = 'spawn_error' | 'signal' | 'missing_status'; + +interface RuntimeLaunchFailure { + readonly code: 'runtime_launch_failed'; + readonly reason: RuntimeLaunchFailureReason; +} + +function refuseRuntimeLaunch(reason: RuntimeLaunchFailureReason): never { + const failure: RuntimeLaunchFailure = { code: 'runtime_launch_failed', reason }; + console.error( + `[mosaic] ERROR [${failure.code}]: runtime process did not produce a successful exit result (${failure.reason}).`, + ); + process.exit(1); +} + +/** Spawn the runtime and preserve only a real numeric exit status as success. */ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { - try { - // Use execFileSync with inherited stdio to replace the process - const result = spawnSync(cmd, args, { - stdio: 'inherit', - env, - }); - process.exit(result.status ?? 0); - } catch (err) { - console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err); - process.exit(1); - } + const result = spawnSync(cmd, args, { + stdio: 'inherit', + env, + }); + if (result.error !== undefined) refuseRuntimeLaunch('spawn_error'); + if (result.signal !== null) refuseRuntimeLaunch('signal'); + if (result.status === null) refuseRuntimeLaunch('missing_status'); + process.exit(result.status); } /** @@ -1030,6 +1065,15 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce * orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested * logic lives in the DI module, not here. */ +export function execRecordedClaudexRuntime( + args: string[], + env: NodeJS.ProcessEnv, + dangerous: boolean, +): void { + const launchId = requireLaunchRecord('claude', args, dangerous); + execLeaseGatedRuntime('claude', args, { ...env, MOSAIC_LAUNCH_ID: launchId }, dangerous); +} + function launchClaudexProduction(args: string[], yolo: boolean): void { writeSessionLock('claude'); const adapter: ClaudexHarnessAdapter = { @@ -1041,8 +1085,7 @@ function launchClaudexProduction(args: string[], yolo: boolean): void { checkSequentialThinking('claude'); }, composePrompt: () => buildRuntimePrompt('claude'), - execLeaseGated: (cmdArgs, env, dangerous) => - execLeaseGatedRuntime('claude', cmdArgs, env, dangerous), + execLeaseGated: execRecordedClaudexRuntime, }; void launchClaudex(args, yolo, adapter); }