From 326a1a58b5ede591ed2974160d35bde8fd815d04 Mon Sep 17 00:00:00 2001 From: terra Date: Thu, 13 Aug 2026 15:31:46 -0500 Subject: [PATCH] fix(fleet): harden managed launch composition AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path. --- .../mosaic-ensure-sequential-thinking | 18 ++-- .../fleet-agent-scaffold-command.spec.ts | 12 ++- .../src/commands/fleet-launch-command.spec.ts | 87 ++++++++++++++---- .../src/commands/fleet-launch-command.ts | 68 +++++++------- packages/mosaic/src/commands/launch.spec.ts | 92 +++++++++++++++++++ packages/mosaic/src/commands/launch.ts | 68 +++++++++++++- .../mosaic/src/fleet/fleet-agent-scaffold.ts | 5 + .../src/runtime/claude-settings-base.spec.ts | 86 +++++++++++------ 8 files changed, 347 insertions(+), 89 deletions(-) diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking index 3f116ad6..4fb2e4e8 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking +++ b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking @@ -86,7 +86,9 @@ config_dir = os.environ.get("CLAUDE_CONFIG_DIR") p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" if not p.exists() and not config_dir: p = Path.home() / ".claude" / "settings.json" -if not p.exists(): +# Only explicit fleet seats require a private, non-symlink config. Operator +# config remains compatible with pre-existing permission conventions. +if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0): raise SystemExit(1) try: data = json.loads(p.read_text(encoding="utf-8")) @@ -137,7 +139,7 @@ PY } check_codex_config() { - local cfg="$HOME/.codex/config.toml" + local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" [[ -f "$cfg" ]] || return 1 grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \ grep -q '^command = "npx"' "$cfg" && \ @@ -145,7 +147,7 @@ check_codex_config() { } apply_codex_config() { - local cfg="$HOME/.codex/config.toml" + local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" mkdir -p "$(dirname "$cfg")" [[ -f "$cfg" ]] || touch "$cfg" @@ -168,10 +170,11 @@ apply_codex_config() { } check_opencode_config() { - python3 - <<'PY' + XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".config" / "opencode" / "config.json" +p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" if not p.exists(): raise SystemExit(1) try: @@ -194,10 +197,11 @@ PY } apply_opencode_config() { - python3 - <<'PY' + XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".config" / "opencode" / "config.json" +p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" p.parent.mkdir(parents=True, exist_ok=True) if p.exists(): try: diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts index ad403184..7e41c926 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -63,6 +63,7 @@ describe('mosaic fleet agent new', (): void => { expect(await files(agent)).toEqual([ '.claude/.claude.json', '.claude/.credentials.json', + '.claude/.mosaic-managed-links.json', '.claude/CLAUDE.md', 'SOUL.md', 'overlay.json', @@ -86,9 +87,13 @@ describe('mosaic fleet agent new', (): void => { }, }, }); - expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe( - join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'), - ); + const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'); + expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget); + expect( + JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')), + ).toEqual({ + links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget }, + }); }); it('creates a Pi home without Claude onboarding state', async (): Promise => { @@ -104,6 +109,7 @@ describe('mosaic fleet agent new', (): void => { 'pi', ]); expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([ + '.pi/.mosaic-managed-links.json', '.pi/AGENTS.md', '.pi/auth.json', 'SOUL.md', diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 595f2c63..0e9948f8 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -1,5 +1,6 @@ import { chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -401,6 +402,29 @@ describe('managed plugin and skill links', () => { expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign); }); + it('performs no writes when a late foreign install link is refused', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); + const target = join(fx.userHome, 'plugins', 'keep'); + const link = join(fx.agentDir, '.claude', 'plugins', 'keep'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(link, '..'), { recursive: true }); + writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n'); + symlinkSync(target, link, 'dir'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const snapshot = join(fx.agentDir, 'settings.generated.json'); + const temp = join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(existsSync(snapshot)).toBe(false); + expect(existsSync(temp)).toBe(false); + expect(readlinkSync(link)).toBe(target); + }); + it('prunes a recorded matching stale symlink', () => { const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); @@ -455,6 +479,23 @@ describe('managed plugin and skill links', () => { expect(readlinkSync(link)).toBe(foreignTarget); }); + it('refuses a tampered manifest entry outside this seat and leaves it intact', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const seatHome = join(fx.agentDir, '.claude'); + mkdirSync(seatHome, { recursive: true }); + const manifest = join(seatHome, '.mosaic-managed-links.json'); + const crossSeat = join(fx.userHome, 'fleet', 'agents', 'other', '.claude', 'plugins', 'keep'); + writeFileSync( + manifest, + JSON.stringify({ links: { [crossSeat]: join(fx.userHome, 'plugins', 'keep') } }), + ); + + expect(() => + resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), + ).toThrowError(/escapes an approved seat\/store root/); + expect(readFileSync(manifest, 'utf8')).toContain(crossSeat); + }); + it('refuses a symlinked manifest temporary path without modifying its target', () => { const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); const target = join(fx.userHome, 'plugins', 'keep'); @@ -472,33 +513,45 @@ describe('managed plugin and skill links', () => { expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n'); }); - it('adopts a pre-manifest credential link inside auth root before retargeting it', () => { - const fx = fixture({ schema: 1, harness: 'claude', bundle: 'next' }); - const previousBundle = join(fx.userHome, 'auth', 'claude', 'previous'); - const nextBundle = join(fx.userHome, 'auth', 'claude', 'next'); + it('refuses an exact-target unrecorded credential symlink', () => { + const fx = fixture(); const seatHome = join(fx.agentDir, '.claude'); - mkdirSync(previousBundle, { recursive: true }); - mkdirSync(nextBundle, { recursive: true }); - writeFileSync(join(previousBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); - writeFileSync(join(nextBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); + const link = join(seatHome, '.credentials.json'); mkdirSync(seatHome, { recursive: true }); - symlinkSync( - join(previousBundle, '.credentials.json'), - join(seatHome, '.credentials.json'), - 'file', - ); - + symlinkSync(join(fx.namedBundleDir, '.credentials.json'), link, 'file'); const plan = resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome, }); - applyFleetLaunchComposition(plan); - expect(readlinkSync(join(seatHome, '.credentials.json'))).toBe( - join(nextBundle, '.credentials.json'), + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, ); + expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, '.credentials.json')); }); + it.each(['plugins', 'skills'] as const)( + 'refuses an exact-target unrecorded %s symlink', + (kind) => { + const fx = fixture({ schema: 1, harness: 'claude', [kind]: ['keep'] }); + const target = join(fx.userHome, kind, 'keep'); + const link = join(fx.agentDir, '.claude', kind, 'keep'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(link, '..'), { recursive: true }); + writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n'); + symlinkSync(target, link, 'dir'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(link)).toBe(target); + }, + ); + it('refuses an unrecorded mismatched credential symlink', () => { const fx = fixture(); const seatHome = join(fx.agentDir, '.claude'); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 72aeb4bf..a012ecaf 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -390,7 +390,7 @@ function resolveCredential( profile: FleetAgentLaunchProfile, userHome: string, seatHome: string, - managedLinks: ManagedLinkState, + _managedLinks: ManagedLinkState, ): Pick { assertRealDirectory(userHome, 'user Mosaic root'); const realUserHome = realpathSync(userHome); @@ -442,16 +442,6 @@ function resolveCredential( const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]); const seatInfo = lstatIfPresent(credentialLink); - if (seatInfo?.isSymbolicLink() && !managedLinks.existed) { - const existingTarget = currentLinkTarget(credentialLink); - try { - assertContained(resolvedAuthRoot, realpathSync(existingTarget), 'credential migration'); - // A pre-manifest seat may adopt only a credential link inside the central auth root. - managedLinks.links.set(credentialLink, existingTarget); - } catch { - // Foreign links remain unrecorded and are refused by apply before any writes. - } - } if (seatInfo && !seatInfo.isSymbolicLink()) { throw new FleetLaunchError( 'FIRST_AUTH_REFUSAL', @@ -571,7 +561,11 @@ function resolveManagedLinks( return { installs, prune }; } -function readManagedLinkState(seatHome: string): ManagedLinkState { +function readManagedLinkState( + seatHome: string, + profile: FleetAgentLaunchProfile, + userHome: string, +): ManagedLinkState { const path = join(seatHome, '.mosaic-managed-links.json'); const info = lstatIfPresent(path); if (!info) return { path, links: new Map(), existed: false }; @@ -605,6 +599,24 @@ function readManagedLinkState(seatHome: string): ManagedLinkState { `managed link manifest has invalid entry: ${path}`, ); } + const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]); + const pluginRoot = join(seatHome, 'plugins'); + const skillRoot = join(seatHome, 'skills'); + const authRoot = join(userHome, 'auth', profile.harness); + const inRoot = (root: string, candidate: string): boolean => { + const rel = relative(resolve(root), resolve(candidate)); + return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); + }; + const valid = + (link === credential && inRoot(authRoot, target)) || + (inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) || + (inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target)); + if (!valid) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest entry escapes an approved seat/store root: ${path}`, + ); + } links.set(link, target); } return { path, links, existed: true }; @@ -720,7 +732,7 @@ export function resolveFleetLaunchComposition( : readSettingsLayer('agent', overlayPath, false), ]; const merged = deepMergeSettings(...layers.map((layer) => layer.value)); - const managedLinks = readManagedLinkState(seatHome); + const managedLinks = readManagedLinkState(seatHome, profile, roots.userHome); const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks); const plugins = resolveManagedLinks( 'plugin', @@ -773,10 +785,6 @@ function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkSt const info = lstatIfPresent(link); if (info?.isSymbolicLink()) { const current = currentLinkTarget(link); - if (current === target) { - managedLinks.links.set(link, target); - return; - } if (managedLinks.links.get(link) !== current) { throw new FleetLaunchError( 'COMPOSITION_FAILED', @@ -809,7 +817,6 @@ function assertManagedLinkMutationAllowed( ); } const current = currentLinkTarget(link); - if (target !== undefined && current === target) return; if (managedLinks.links.get(link) !== current) { throw new FleetLaunchError( 'COMPOSITION_FAILED', @@ -830,22 +837,20 @@ function canonicalJson(value: unknown): unknown { /** Apply a previously resolved plan. No caller should apply a dry-run plan. */ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void { + // All link-state checks must complete before the first filesystem mutation. + // This makes a late foreign/retargeted link refusal leave the seat untouched. + assertManagedLinkMutationAllowed(plan.credential.link, plan.credential.target, plan.managedLinks); + for (const path of plan.prune) + assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); + for (const install of plan.installs) { + assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks); + } + mkdirSync(plan.seatHome, { recursive: true }); const preparedManifest = prepareManagedLinkManifest(plan.managedLinks); + let descriptorOpen = true; let committedManifest = false; try { - assertManagedLinkMutationAllowed( - plan.credential.link, - plan.credential.target, - plan.managedLinks, - ); - for (const path of plan.prune) { - assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); - } - for (const install of plan.installs) { - assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks); - } - const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`; writeFileSync(plan.settings.output, settings, { mode: 0o600 }); writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 }); @@ -874,11 +879,12 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void } writeManagedLinkState(plan.managedLinks, preparedManifest); closeSync(preparedManifest.descriptor); + descriptorOpen = false; renameSync(preparedManifest.path, plan.managedLinks.path); committedManifest = true; } finally { if (!committedManifest) { - closeSync(preparedManifest.descriptor); + if (descriptorOpen) closeSync(preparedManifest.descriptor); rmSync(preparedManifest.path, { force: true }); } } diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index 9768b612..8909fbc2 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -20,6 +20,7 @@ import { piForceSkillNames, registerRuntimeLaunchers, checkSequentialThinking, + launchFleetRuntimeForTest, type RuntimeLaunchHandler, type ClaudexLaunchHandler, } from './launch.js'; @@ -99,6 +100,95 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => { }); describe('checkSequentialThinking', () => { + it('runs the real fleet launch preflight against the injected seat, not HOME', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + expect( + JSON.parse( + readFileSync( + join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'), + 'utf8', + ), + ).mcpServers['sequential-thinking'], + ).toEqual({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }); + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + { mode: 0o600 }, + ); + vi.stubEnv('HOME', home); + const final = vi.fn((): never => { + throw new Error('final runtime boundary'); + }); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final), + ).toThrow('final runtime boundary'); + expect(final).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('fails the real fleet launch preflight when only operator HOME is seeded', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync( + join(home, '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + vi.stubEnv('HOME', home); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { + throw new Error('must not execute'); + }), + ).toThrow('process.exit called'); + } finally { + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + it('passes with a seeded seat even when operator HOME has no MCP configuration', () => { const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); @@ -121,6 +211,7 @@ describe('checkSequentialThinking', () => { }, }, }), + { mode: 0o600 }, ); vi.stubEnv('MOSAIC_HOME', installed); vi.stubEnv('HOME', home); @@ -156,6 +247,7 @@ describe('checkSequentialThinking', () => { writeFileSync( join(agentDir, '.claude', '.claude.json'), JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }), + { mode: 0o600 }, ); const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }; expect( diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index e59b195a..499f6084 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -20,7 +20,7 @@ import { import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { homedir, hostname } from 'node:os'; -import { join, dirname, relative, resolve, sep } from 'node:path'; +import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path'; import type { Command } from 'commander'; import { buildResolvedFleetCommsBlock, @@ -346,6 +346,15 @@ function printSettingsWarnings(audit: SettingsAudit): void { ); } +function resolveExecutable(name: string): string { + const result = spawnSync('which', [name], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + if (!path || !isAbsolute(path) || !existsSync(path)) { + throw new Error(`required helper executable is unavailable: ${name}`); + } + return path; +} + function trustedFleetHelper(mosaicHome: string): string { const root = resolve(mosaicHome); const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); @@ -392,6 +401,13 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne if (!existsSync(checker)) return; // Skip if checker doesn't exist const fleetClaudeConfig = runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined; + const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined; + const fleetOpenCodeHome = + runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined; + const python = resolveExecutable('python3'); + const node = resolveExecutable('node'); + const npx = resolveExecutable('npx'); + const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':'); const result = spawnSync( checker, [ @@ -400,7 +416,22 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne runtime, ...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]), ], - { stdio: 'ignore' }, + { + stdio: 'ignore', + env: { + HOME: process.env['HOME'] ?? '', + PATH: capabilityPath, + LANG: process.env['LANG'] ?? 'C.UTF-8', + ...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined + ? {} + : { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }), + ...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined + ? {} + : { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }), + ...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }), + ...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }), + }, + }, ); if (result.status !== 0) { console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); @@ -968,6 +999,11 @@ function getMissionPrompt(): string { interface RuntimeLaunchContext { readonly fleet?: FleetHarnessContext; readonly declaredEnv?: Readonly>; + /** Test seam: bypass only final runtime binary discovery. */ + readonly runtimeCheck?: (runtime: RuntimeName) => void; + /** Test seam: receives the fully composed final runtime invocation. */ + readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void; + readonly recordLaunch?: boolean; } function minimalLaunchEnv(declared: Readonly>): NodeJS.ProcessEnv { @@ -1000,7 +1036,7 @@ function launchRuntime( checkMosaicHome(); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); checkSoul(); - checkRuntime(runtime); + (context.runtimeCheck ?? checkRuntime)(runtime); // Pi doesn't need sequential-thinking (has native thinking levels) if (runtime !== 'pi') { @@ -1050,11 +1086,16 @@ function launchRuntime( cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv); + if (context.recordLaunch !== false) + recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv); if (process.env['MOSAIC_LAUNCH_ID']) { launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID']; } - execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet); + if (context.finalExecutor) { + context.finalExecutor('claude', cliArgs, launchEnv); + } else { + execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet); + } break; } @@ -1170,6 +1211,23 @@ export function launchFleetRuntime( return launchRuntime(runtime, args, false, { fleet, declaredEnv }); } +/** Bounded production-path test seam; all preflight and composition remain real. */ +export function launchFleetRuntimeForTest( + runtime: RuntimeName, + args: string[], + declaredEnv: Readonly>, + fleet: FleetHarnessContext, + finalExecutor: NonNullable, +): never { + return launchRuntime(runtime, args, false, { + fleet, + declaredEnv, + runtimeCheck: () => undefined, + finalExecutor, + recordLaunch: false, + }); +} + /** exec into the runtime, replacing the current process. */ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { try { diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts index e929f3cb..ea1f1d78 100644 --- a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -64,6 +64,7 @@ export async function scaffoldFleetAgent( ...(model === undefined ? {} : { model }), env: { MOSAIC_AGENT_NAME: name }, }; + const credentialLink = join(agentDir, homeName, credentialName); const entries: [string, ExpectedFile][] = [ ['profile.json', { type: 'file', content: json(profile) }], ['SOUL.md', { type: 'file', content: soul(name) }], @@ -73,6 +74,10 @@ export async function scaffoldFleetAgent( { type: 'file', content: identityBootstrap(name) }, ], [join(homeName, credentialName), { type: 'symlink', target: credentialTarget }], + [ + join(homeName, '.mosaic-managed-links.json'), + { type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) }, + ], ]; if (harness === 'claude') { entries.push([ diff --git a/packages/mosaic/src/runtime/claude-settings-base.spec.ts b/packages/mosaic/src/runtime/claude-settings-base.spec.ts index 71cf7c95..acae7166 100644 --- a/packages/mosaic/src/runtime/claude-settings-base.spec.ts +++ b/packages/mosaic/src/runtime/claude-settings-base.spec.ts @@ -20,28 +20,11 @@ function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } -// This fixture contract composes hook event arrays additively. It is deliberately -// limited to verifying that the split is lossless; production launch merge -// semantics remain owned by W-F1. +// Production composition uses universal last-layer-wins array replacement. The +// lease overlay therefore carries complete affected event arrays, including the +// two QA carry-forward entries needed to avoid dropping non-lease hooks. function deepMerge(base: Json, overlay: Json): Json { - if (Array.isArray(base) && Array.isArray(overlay)) { - const merged = [...base]; - for (const entry of overlay) { - if (!isObject(entry) || !Array.isArray(entry['hooks'])) { - merged.push(entry); - continue; - } - const matchingIndex = merged.findIndex( - (candidate) => - isObject(candidate) && - Array.isArray(candidate['hooks']) && - candidate['matcher'] === entry['matcher'], - ); - if (matchingIndex === -1) merged.push(entry); - else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry); - } - return merged; - } + if (Array.isArray(base) && Array.isArray(overlay)) return overlay; if (isObject(base) && isObject(overlay)) { const merged: JsonObject = { ...base }; for (const [key, value] of Object.entries(overlay)) { @@ -107,6 +90,30 @@ describe('canonical Claude base and lease-promotion overlay', () => { const preSplit = readJson(gatedFixturePath); const expected: JsonObject = { ...preSplit, + hooks: { + ...(preSplit['hooks'] as JsonObject), + Stop: [ + { + hooks: [ + { + type: 'command', + command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh', + timeout: 15, + }, + ], + }, + { + hooks: [ + { + type: 'command', + command: + 'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status', + timeout: 15, + }, + ], + }, + ], + }, mcpServers: { 'sequential-thinking': sequentialThinking }, }; @@ -118,14 +125,41 @@ describe('canonical Claude base and lease-promotion overlay', () => { expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking }); }); - it('limits the overlay to lease hook entries', () => { + it('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => { const overlay = readJson(overlayPath); expect(Object.keys(overlay)).toEqual(['hooks']); const commands = hookCommands(overlay); - expect(commands).toHaveLength(6); - for (const command of commands) { - expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/); - } + const lease = commands.filter((command) => + /mutator-gate|receipt-observer|promote-|revoke-lease/.test(command), + ); + const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command)); + expect(lease).toHaveLength(6); + expect(qa).toHaveLength(2); + expect(commands).toHaveLength(8); }); + + it.each(['prevent-memory-write', 'reflect-stop'])( + 'fails lossless reconstruction if QA carry-forward %s is removed', + (marker) => { + const base = readJson(basePath); + const overlay = readJson(overlayPath); + const expected = { + ...readJson(gatedFixturePath), + mcpServers: { 'sequential-thinking': sequentialThinking }, + }; + const hooks = overlay['hooks'] as JsonObject; + const mutated: JsonObject = { + hooks: Object.fromEntries( + Object.entries(hooks).map(([event, entries]) => [ + event, + Array.isArray(entries) + ? entries.filter((entry) => !JSON.stringify(entry).includes(marker)) + : entries, + ]), + ), + }; + expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected)); + }, + ); });