fleet: fix four defects that made no seat launchable on a clean install

Found by rehearsing the full install on a greenfield Debian 13 VM
(mosaic-sbx-dev) rather than on a host that already had a working Mosaic
tree. Each one is invisible on a developer machine and fatal on a new host.

1. Required system settings layer. The framework ships runtime/<harness>/
   for claude, codex, opencode and pi but a settings.json only for claude,
   so requiring the file made every pi, codex and opencode seat refuse to
   compose. The system layer is now optional; what must exist is the
   harness runtime directory, which is the thing that actually proves the
   framework is installed and carries that harness.

2. Required mcpServers in canonical Claude settings. The shipped
   settings.json has no such key, so `fleet agent new` refused to scaffold
   any Claude seat. Absent now means the same as empty. A present but
   wrong-typed value is still an error.

3. Never-enrolled hosts were told their auth directory "must be a real,
   non-symlink directory", which reads as a tampering report when the real
   situation is that nobody has logged in yet. Absent and wrong-shaped are
   now separate messages, and the absent one names `mosaic auth enroll`.

4. A fleet seat whose host had no system SOUL.md reached checkSoul(),
   which spawns the interactive `mosaic wizard` with inherited stdio. On a
   detached tmux seat that parks the pane on a menu with nobody at it: the
   session is live, the systemd unit reports fine, and no agent ever
   starts. A seat's identity is its own SOUL.md, written by `fleet agent
   new`, so the fleet path checks that and fails loudly instead.

Each fix has a regression test verified red against the unfixed source.
The launch.spec.ts seat fixtures gained a SOUL.md they always should have
had -- without it those tests were satisfied by whatever SOUL.md the
developer's real ~/.config/mosaic happened to contain.

Full suite before and after: the same 5 pre-existing failures in
mutator-gate.acceptance.spec.ts and install-ordering-guard.spec.ts,
1585 -> 1591 passing. typecheck and eslint clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
This commit is contained in:
terra
2026-08-14 19:03:30 -05:00
co-authored by Claude Opus 5
parent c1a42cdb81
commit 309a99a600
6 changed files with 196 additions and 18 deletions
@@ -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<void> => {
// 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<void> => {
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);
});
});
@@ -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/<harness>/ 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();
@@ -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',
@@ -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-'));
+17 -2
View File
@@ -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 <name>');
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)
@@ -292,21 +292,24 @@ function onboardingState(mosaicHome: string): Record<string, unknown> {
`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 {