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 169a7d36..a276ab7e 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -263,4 +263,35 @@ describe('mosaic fleet agent new', (): void => { expect(process.exitCode).toBeUndefined(); expect((await lstat(credential)).isSymbolicLink()).toBe(true); }); + + it('scaffolds against canonical settings that declare no mcpServers', async (): Promise => { + // The framework's shipped runtime/claude/settings.json has no mcpServers key, so + // requiring one refused to scaffold any Claude seat on a clean install. Measured on a + // greenfield Debian 13 VM against framework main. + const dataHome = await fleetDataHome(); + const command = program(dataHome); + const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json'); + await writeFile(settings, JSON.stringify({ model: 'opus', hooks: {} })); + + await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + + expect(process.exitCode).toBeUndefined(); + const claudeJson = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.claude.json'); + expect(JSON.parse(await readFile(claudeJson, 'utf8'))).toEqual({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: {}, + }); + }); + + it('still refuses canonical settings whose mcpServers is the wrong shape', async (): Promise => { + const dataHome = await fleetDataHome(); + const command = program(dataHome); + const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json'); + await writeFile(settings, JSON.stringify({ mcpServers: ['sequential-thinking'] })); + + await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + + expect(process.exitCode).toBe(1); + }); }); diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 6048f7b3..63534bdc 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -291,6 +291,65 @@ describe('profile-selected overlay', () => { }); }); +describe('system settings layer on a real install', () => { + it('composes a harness whose runtime ships no settings.json', () => { + // Measured on a greenfield Debian 13 VM against framework main: the install ships + // runtime// for claude, codex, opencode and pi but a settings.json only for + // claude. Requiring the file made every pi seat unlaunchable. + const fx = fixture({ schema: 1, harness: 'pi' }); + rmSync(join(fx.systemHome, 'runtime', 'pi', 'settings.json')); + writeFileSync(join(fx.systemHome, 'runtime', 'pi', 'RUNTIME.md'), '# pi\n'); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + expect(plan.settings.layers[0]?.present).toBe(false); + expect(plan.settings.merged).toEqual({}); + }); + + it('still refuses a harness the framework does not carry', () => { + const fx = fixture({ schema: 1, harness: 'pi' }); + rmSync(join(fx.systemHome, 'runtime', 'pi'), { recursive: true }); + + try { + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + throw new Error('expected resolution to fail'); + } catch (error: unknown) { + const launchError = error as FleetLaunchError; + expect(launchError.code).toBe('COMPOSITION_FAILED'); + expect(launchError.message).toMatch(/harness runtime is not installed/); + } + }); +}); + +describe('never-enrolled hosts', () => { + it('names the enroll command instead of reporting a shape violation', () => { + // A host that has simply never logged in has no ~/.mosaic/auth at all. Reusing the + // wrong-shape wording there told the operator their auth directory "must be a real, + // non-symlink directory", which reads as tampering rather than "enroll a bundle". + const fx = fixture(); + rmSync(join(fx.userHome, 'auth'), { recursive: true }); + + try { + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + throw new Error('expected resolution to fail'); + } catch (error: unknown) { + const launchError = error as FleetLaunchError; + expect(launchError.code).toBe('COMPOSITION_FAILED'); + expect(launchError.message).toMatch(/does not exist/); + expect(launchError.message).toMatch(/mosaic auth enroll/); + expect(launchError.message).not.toMatch(/non-symlink/); + } + }); +}); + describe('unscaffolded agent names', () => { it('points an unscaffolded name at mosaic fleet agent new', () => { const fx = fixture(); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 29e663be..0392119f 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -318,9 +318,20 @@ function lstatIfPresent(path: string): Stats | undefined { } } -function assertRealDirectory(path: string, label: string): void { +function assertRealDirectory(path: string, label: string, absentHint?: string): void { const info = lstatIfPresent(path); - if (!info?.isDirectory() || info.isSymbolicLink()) { + // Absent and wrong-shaped are different problems and want different words. A host that has + // simply never enrolled a bundle was being told its auth directory "must be a real, + // non-symlink directory", which reads as a tampering report rather than "log in first". + if (!info) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + absentHint + ? `${label} does not exist: ${path} — ${absentHint}` + : `${label} does not exist: ${path}`, + ); + } + if (!info.isDirectory() || info.isSymbolicLink()) { throw new FleetLaunchError( 'COMPOSITION_FAILED', `${label} must be a real, non-symlink directory: ${path}`, @@ -338,6 +349,22 @@ function assertContained(root: string, candidate: string, label: string): void { } } +/** + * Proves the framework is installed and knows this harness. This is the check the required + * system settings layer used to stand in for, moved to the thing that is actually always + * present: the runtime directory. A missing one means an uninstalled framework or a harness + * the install does not carry, and both are worth failing on before a seat is composed. + */ +function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void { + const runtimeDir = join(systemHome, 'runtime', harness); + if (!lstatIfPresent(runtimeDir)?.isDirectory()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`, + ); + } +} + function readSettingsLayer( name: SettingsLayer['name'], path: string, @@ -403,9 +430,10 @@ function resolveCredential( assertRealDirectory(userHome, 'user Mosaic root'); const realUserHome = realpathSync(userHome); const authDirectory = join(userHome, 'auth'); - assertRealDirectory(authDirectory, 'auth directory'); + const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`; + assertRealDirectory(authDirectory, 'auth directory', enrollHint); const authRoot = join(authDirectory, profile.harness); - assertRealDirectory(authRoot, `${profile.harness} auth root`); + assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint); const resolvedAuthRoot = realpathSync(authRoot); assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`); @@ -735,11 +763,16 @@ export function resolveFleetLaunchComposition( } const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json'); assertContained(agentDir, overlayPath, 'agent overlay'); + // The framework ships a runtime directory per harness but a settings.json only where it + // has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made + // every pi, codex and opencode seat unlaunchable on a clean install. The install is what + // has to be present; an absent base layer just means the harness has no system settings. + assertHarnessRuntimeInstalled(roots.systemHome, profile.harness); const layers: SettingsLayer[] = [ readSettingsLayer( 'system', join(roots.systemHome, 'runtime', profile.harness, 'settings.json'), - true, + false, ), readSettingsLayer( 'user', diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index 8909fbc2..4bbc22cd 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -122,6 +122,7 @@ describe('checkSequentialThinking', () => { join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), checker, ); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); mkdirSync(join(agentDir, '.claude'), { recursive: true }); writeFileSync( join(agentDir, '.claude', '.claude.json'), @@ -174,6 +175,9 @@ describe('checkSequentialThinking', () => { }, }), ); + // Scaffolded seat, unconfigured harness: the seat's own identity is present so this + // still fails on the missing MCP configuration rather than on a missing SOUL.md. + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); vi.stubEnv('HOME', home); expect(() => launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { @@ -200,6 +204,7 @@ describe('checkSequentialThinking', () => { join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), checker, ); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); mkdirSync(join(agentDir, '.claude'), { recursive: true }); writeFileSync( join(agentDir, '.claude', '.claude.json'), @@ -234,6 +239,7 @@ describe('checkSequentialThinking', () => { const bin = join(installed, 'bin'); try { mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); mkdirSync(join(agentDir, '.claude'), { recursive: true }); mkdirSync(bin, { recursive: true }); copyFileSync( @@ -281,6 +287,37 @@ describe('checkSequentialThinking', () => { } }); + it('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => { + // Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md + // reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at + // the pane the seat parked on the wizard's menu -- tmux session live, unit reporting + // fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an + // unattended launch must fail loudly rather than wait for a keystroke. + const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-')); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + vi.stubEnv('HOME', home); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { + throw new Error('must not execute'); + }), + ).toThrow('process.exit called'); + expect(exit).toHaveBeenCalledWith(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md'))); + expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new')); + } finally { + error.mockRestore(); + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + it('rejects a group-writable installed helper root', () => { const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index b5ccb92d..1ea286c4 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -244,7 +244,22 @@ function checkRuntime(cmd: string): void { } } -function checkSoul(): void { +function checkSoul(fleet?: FleetHarnessContext): void { + // A fleet seat carries its own identity -- `mosaic fleet agent new` writes SOUL.md into the + // seat home -- so the operator's system-wide SOUL.md is not the file to check, and the + // interactive wizard is never the right answer for an unattended seat. Measured on a + // greenfield VM: a seat launched into tmux parked on the wizard's menu with nobody at the + // pane. The session was live, the unit reported fine, and no agent ever started. + if (fleet) { + const seatSoul = join(fleet.agentDir, 'SOUL.md'); + if (!existsSync(seatSoul)) { + console.error(`[mosaic] ERROR: seat identity not found: ${seatSoul}`); + console.error('[mosaic] Scaffold the seat first: mosaic fleet agent new '); + process.exit(1); + } + return; + } + const soulPath = join(MOSAIC_HOME, 'SOUL.md'); if (!existsSync(soulPath)) { console.log('[mosaic] SOUL.md not found. Running setup wizard...'); @@ -1035,7 +1050,7 @@ function launchRuntime( ): never { checkMosaicHome(); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); - checkSoul(); + checkSoul(context.fleet); (context.runtimeCheck ?? checkRuntime)(runtime); // Pi doesn't need sequential-thinking (has native thinking levels) diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts index 215cca95..ab05acb3 100644 --- a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -292,21 +292,24 @@ function onboardingState(mosaicHome: string): Record { `canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`, ); } - if ( - typeof authored !== 'object' || - authored === null || - Array.isArray(authored) || - !('mcpServers' in authored) || - typeof authored.mcpServers !== 'object' || - authored.mcpServers === null || - Array.isArray(authored.mcpServers) - ) { + if (typeof authored !== 'object' || authored === null || Array.isArray(authored)) { throw new FleetAgentScaffoldError( 'invalid-request', - `canonical Claude settings lack an mcpServers object: ${settingsPath}`, + `canonical Claude settings must be a JSON object: ${settingsPath}`, ); } - return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers }; + // The shipped settings.json has no mcpServers key at all, so demanding one refused to + // scaffold any Claude seat on a clean install. Absent and empty mean the same thing here: + // no MCP servers. A present-but-wrong-typed key is still an error -- that is a real + // mistake in the file rather than a section the author had nothing to put in. + const servers = 'mcpServers' in authored ? authored.mcpServers : {}; + if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) { + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings have a non-object mcpServers: ${settingsPath}`, + ); + } + return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: servers }; } function soul(name: string): string {