import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js'; /** * #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`, * `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could * never get its units placed. These tests pin the three behaviours that fix * gives it, and the two it deliberately does NOT give it. * * The load-bearing negative is that `install` on v2 writes no generated env: * the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a * second writer here — necessarily through the v1 mapping — is exactly the * drift the #791 single-SSOT invariant exists to prevent. */ const rosterV2 = ` version: 2 generation: 4 transport: tmux tmux: socket_name: mosaic-fleet holder_session: _holder defaults: working_directory: /srv/mosaic runtime: pi runtimes: pi: reset_command: /new agents: - name: coder0 alias: Coder 0 class: code runtime: pi provider: openai model: gpt-5.6-sol reasoning: high tool_policy: code working_directory: /srv/mosaic persistent_persona: false reset_between_tasks: true lifecycle: enabled: true desired_state: stopped launch: yolo: true - name: coder1 alias: Coder 1 class: code runtime: pi provider: openai model: gpt-5.6-sol reasoning: medium tool_policy: code working_directory: /srv/other persistent_persona: false reset_between_tasks: true lifecycle: enabled: true desired_state: stopped launch: yolo: true `; let tempHome: string | undefined; const savedHome = process.env.HOME; const savedMosaicHome = process.env.MOSAIC_HOME; afterEach(async (): Promise => { vi.restoreAllMocks(); process.exitCode = undefined; if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME; else process.env.MOSAIC_HOME = savedMosaicHome; if (tempHome) await rm(tempHome, { recursive: true, force: true }); tempHome = undefined; }); /** * A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before * anything has been installed, applied or started. */ async function v2Home(): Promise { tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-')); process.env.HOME = tempHome; delete process.env.MOSAIC_HOME; const mosaicHome = join(tempHome, '.config', 'mosaic'); for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) { await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 }); } await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 }); await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', { mode: 0o600, }); return mosaicHome; } /** * Stands in for a box where nothing is running: every systemctl and tmux probe * fails the way it does before the holder has ever started. `ps` must survive * this — it is the command an operator reaches for to find out *why* there is * no seat, so it has to report the emptiness rather than fail on it. */ const greenfieldRunner: CommandRunner = async (command): Promise => { if (command === 'tmux') { return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 }; } return { stdout: '', stderr: '', exitCode: 1 }; }; function program(runner: CommandRunner = greenfieldRunner): Command { const result = new Command(); result.exitOverride(); registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') }); return result; } function capture(): string[] { const lines: string[] = []; vi.spyOn(console, 'log').mockImplementation((value: string): void => { lines.push(value); }); return lines; } async function exists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } describe('mosaic fleet ps — roster v2', (): void => { it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise => { await v2Home(); const lines = capture(); await expect( program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']), ).resolves.toBeDefined(); const rows = JSON.parse(lines.join('\n')) as { name: string; runtime: string; alias?: string; paneAlive: boolean; source: string; }[]; expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']); // The v2 roster's per-agent fields must survive the read model, not be // flattened into defaults. expect(rows.every((row) => row.runtime === 'pi')).toBe(true); expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0'); // Nothing is running, and that is a report, not an error. expect(rows.every((row) => row.paneAlive === false)).toBe(true); expect(rows.every((row) => row.source === 'roster')).toBe(true); expect(process.exitCode ?? 0).toBe(0); }); }); describe('mosaic fleet install — roster v2', (): void => { it('places the tool files and unit templates', async (): Promise => { const mosaicHome = await v2Home(); capture(); await expect( program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']), ).resolves.toBeDefined(); // Units live in the systemd user dir, not under the Mosaic home. const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user'); for (const unit of [ 'mosaic-tmux-holder.service', 'mosaic-agent@.service', 'mosaic-interaction-agent@.service', ]) { expect(await exists(join(systemdUserDir, unit))).toBe(true); } const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh'); expect(await exists(launcher)).toBe(true); expect((await stat(launcher)).mode & 0o777).toBe(0o755); }); it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise => { const mosaicHome = await v2Home(); capture(); await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); const agentDir = join(mosaicHome, 'fleet', 'agents'); expect(await readdir(agentDir)).toEqual([]); }); it('tells the operator which command does own the env', async (): Promise => { await v2Home(); const lines = capture(); await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); expect(lines.join('\n')).toContain('mosaic fleet apply'); }); }); describe('mosaic-agent@.service', (): void => { const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'); /** The single `ConditionPathExists=` value declared by the unit template. */ async function conditionPath(): Promise { const unit = await readFile(unitPath, 'utf8'); const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? []; expect(matches).toHaveLength(1); return matches[0]!.slice('ConditionPathExists='.length).trim(); } it('will not attempt a seat before the reconciler has written its env', async (): Promise => { // The pairing that makes "install writes no env" safe: install enables the // unit (WantedBy=default.target) but does not start it, so without this // condition a reboot between `install` and the first `apply` would run // ExecStart against an absent env file and fail every seat unit. expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated'); }); /** * The two halves of the guard's *effect*, which no assertion on the literal * string can cover on its own. * * Measured end to end on a real box (canary, 2026-08-16) rather than inferred: * with the condition, `systemctl --user start mosaic-agent@` on an agent * with no generated env returns rc=0, `Result=success`, `ConditionResult=no`, * and journals "skipped, unmet condition check". With the condition removed by * drop-in and nothing else changed, the same start returns rc=1, * `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`. * * systemd is not available in this suite, so these two tests pin the parts * that can drift in code: the condition naming a *different* file than the one * the fleet actually writes, and the launcher quietly becoming tolerant of an * absent env — either of which turns the condition into decoration while the * literal-string assertion above still passes. */ it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise => { const mosaicHome = await v2Home(); const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0'); // The path an installed fleet actually places for this agent. expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')); }); it('guards a real failure — the launcher rejects an absent generated env', async (): Promise => { await v2Home(); await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); // Exactly what ExecStart runs, against the state the condition exists to // catch: unit enabled, reconciler has not written env yet. const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => { const child = execFile( '/bin/bash', [ '--noprofile', '--norc', join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'), 'coder0', ], { env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } }, (_error, _stdout, stderr) => { settle({ code: child.exitCode, stderr }); }, ); }); expect(launched.code).not.toBe(0); expect(launched.stderr).toContain('missing-file'); }); }); describe('mosaic fleet add / remove — roster v2', (): void => { it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise => { await v2Home(); await expect( program().parseAsync([ 'node', 'mosaic', 'fleet', 'add', 'coder2', '--runtime', 'pi', '--class', 'code', ]), ).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/); }); it('remove refuses, and names delete plus apply', async (): Promise => { await v2Home(); await expect( program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']), ).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/); }); // Note: this one passes on the unmodified tree too — there `remove` throws in // the v1 parser, before it can touch anything. It is a regression guard on the // ordering of the new guard clause, not evidence that the fix works. it('refuses BEFORE mutating the roster', async (): Promise => { const mosaicHome = await v2Home(); const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml'); const before = await readFile(rosterPath, 'utf8'); await expect( program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']), ).rejects.toThrow(); expect(await readFile(rosterPath, 'utf8')).toBe(before); }); });