import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js'; import { registerFleetCommand, type FleetCommandDeps } from './fleet.js'; const roster = ` version: 2 generation: 7 transport: tmux tmux: socket_name: mosaic-fleet holder_session: _holder defaults: working_directory: /srv/mosaic runtime: pi runtimes: pi: reset_command: /new agents: - name: orchestrator alias: Orchestrator class: orchestrator runtime: pi provider: openai model: gpt-5.6-sol reasoning: high tool_policy: orchestrator working_directory: /srv/mosaic persistent_persona: true reset_between_tasks: false lifecycle: enabled: true desired_state: stopped launch: yolo: true `; const agent = { name: 'coder0', alias: 'Coder 0', className: 'code', runtime: 'pi', provider: 'openai', model: 'gpt-5.6-sol', reasoning: 'high', toolPolicy: 'code', workingDirectory: '/srv/mosaic', persistentPersona: false, resetBetweenTasks: true, launch: { yolo: true }, }; let cleanup: string | undefined; afterEach(async (): Promise => { vi.restoreAllMocks(); process.exitCode = undefined; if (cleanup) await rm(cleanup, { recursive: true, force: true }); cleanup = undefined; }); async function fleetHome(): Promise { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-')); const fleetDir = join(cleanup, 'fleet'); const agentsDir = join(fleetDir, 'agents'); const rolesDir = join(fleetDir, 'roles'); await mkdir(agentsDir, { recursive: true, mode: 0o700 }); await mkdir(rolesDir, { recursive: true, mode: 0o700 }); await chmod(cleanup, 0o700); await chmod(fleetDir, 0o700); await chmod(agentsDir, 0o700); await chmod(rolesDir, 0o700); await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 }); for (const className of ['orchestrator', 'code']) { await writeFile( join(rolesDir, `${className}.md`), `# ${className}\n\n(\`class: ${className}\`)\n`, { mode: 0o600, }, ); } return cleanup; } function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command { const result = new Command(); result.exitOverride(); registerFleetCommand(result, { ...deps, mosaicHome }); return result; } function output(): { lines: string[] } { const lines: string[] = []; vi.spyOn(console, 'log').mockImplementation((value: string): void => { lines.push(value); }); return { lines }; } describe('mosaic fleet local roster mutations', (): void => { it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => { expect( fleetAgentMutationExitCode({ recovery: { code: 'projection-apply-failed', rosterPath: '/private/fleet/roster.yaml', action: 'regenerate-projections-from-roster', }, }), ).toBe(1); }); it('returns zero for a mutation result without recovery work', (): void => { expect(fleetAgentMutationExitCode({})).toBe(0); }); it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise => { const home = await fleetHome(); const root = program(home); const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet'); expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual( expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']), ); }); it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise => { const home = await fleetHome(); const root = new Command(); root.exitOverride(); registerFleetCommand(root, { mosaicHome: home }); const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet'); expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual( expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']), ); }); it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const captured = output(); const root = new Command(); root.exitOverride(); registerFleetCommand(root, { mosaicHome: home }); await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']); expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ generation: 7, agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } }, }); expect(await readFile(rosterPath, 'utf8')).toBe(roster); }); it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise => { const home = await fleetHome(); const captured = output(); await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']); expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } }); expect(process.exitCode).toBe(1); }); it('prints a deterministic create plan without changing roster or generated projections', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ applied: false, plan: { operation: 'create', currentGeneration: 7, nextGeneration: 8, agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } }, }, }); expect(await readFile(rosterPath, 'utf8')).toBe(roster); await expect( readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'), ).rejects.toThrow(); }); it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const captured = output(); const updatedCoder = { ...agent, alias: 'Coder Updated' }; await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); const beforePlans = await readFile(rosterPath, 'utf8'); const beforeOrchestratorProjection = await readFile( join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8', ); captured.lines.length = 0; await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'update', 'coder0', '--expected-generation', '8', '--agent', JSON.stringify(updatedCoder), ]); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'delete', 'coder0', '--expected-generation', '8', ]); const plans = captured.lines.map((line: string): unknown => JSON.parse(line)); expect(plans).toMatchObject([ { plan: { operation: 'update', agent: { name: 'coder0' } } }, { plan: { operation: 'delete', agent: { name: 'coder0' } } }, ]); expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans); expect( await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'), ).toBe(beforeOrchestratorProjection); }); it.each([ ['plan create', ['fleet', 'plan', 'create'], 'top-level'], ['create', ['fleet', 'create'], 'top-level'], ['update', ['fleet', 'update', 'orchestrator'], 'top-level'], ['plan create', ['fleet', 'plan', 'create'], 'launch'], ['create', ['fleet', 'create'], 'launch'], ['update', ['fleet', 'update', 'orchestrator'], 'launch'], ])( 'rejects unknown and prototype-sensitive agent fields without mutation', async (_operation: string, command: string[], shape: string): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); const artifactContents = new Map([ ['orchestrator.env.generated', 'generated-before\n'], ['orchestrator.env.local', 'local-before\n'], ['orchestrator.env', 'legacy-before\n'], ['orchestrator.env.quarantine', 'quarantine-before\n'], ]); for (const [filename, content] of artifactContents) { await writeFile(join(agentsDir, filename), content, { mode: 0o600 }); } const rejectedValue = 'must-not-appear-in-diagnostics'; const unsafeAgent = { ...agent, name: 'orchestrator', launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true }, ...(shape === 'top-level' ? { command: rejectedValue, channel: rejectedValue, secretRef: rejectedValue, constructor: rejectedValue, prototype: rejectedValue, } : {}), }; const payload = shape === 'top-level' ? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}') : JSON.stringify(unsafeAgent); await program(home).parseAsync([ 'node', 'mosaic', ...command, '--expected-generation', '7', '--agent', payload, ]); const diagnostic = captured.lines.pop() ?? ''; expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } }); expect(diagnostic).not.toContain(rejectedValue); expect(process.exitCode).toBe(1); expect(await readFile(rosterPath, 'utf8')).toBe(roster); for (const [filename, content] of artifactContents) { expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content); } }, ); it('rejects missing update/delete plan names and an extra create plan name', async (): Promise => { const home = await fleetHome(); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'delete', '--expected-generation', '7', ]); expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } }); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'create', 'coder0', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } }); }); it('plans persisted start as desired state without starting a runtime', async (): Promise => { const home = await fleetHome(); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'plan', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), '--persisted-start', ]); expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ applied: false, plan: { agent: { lifecycle: { desiredState: 'running' } } }, }); }); it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); await program(home, { projectionApplier: async (): Promise => { throw new Error('injected projection failure'); }, }).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({ applied: false, authoritativeRoster: 'committed', projections: 'incomplete', recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' }, }); expect(process.exitCode).toBe(1); expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8'); expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0'); await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); }); it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600, }); await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600, }); await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', { mode: 0o600, }); await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', { mode: 0o600, }); const beforeDryRun = await Promise.all([ readFile(rosterPath, 'utf8'), readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'), readFile(join(agentsDir, 'coder0.env.local'), 'utf8'), readFile(join(agentsDir, 'coder0.env'), 'utf8'), readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'), readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'), ]); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8', '--dry-run', ]); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', }); expect( await Promise.all([ readFile(rosterPath, 'utf8'), readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'), readFile(join(agentsDir, 'coder0.env.local'), 'utf8'), readFile(join(agentsDir, 'coder0.env'), 'utf8'), readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'), readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'), ]), ).toEqual(beforeDryRun); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8', ]); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true, authoritativeRoster: 'committed', projections: 'complete', }); await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]); expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]); expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]); expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe( beforeDryRun[5], ); expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9'); }); it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); captured.lines.length = 0; await program(home, { projectionApplier: async (): Promise => { throw new Error('injected projection failure'); }, }).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: false, authoritativeRoster: 'committed', projections: 'incomplete', recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' }, }); expect(process.exitCode).toBe(1); expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9'); expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0'); expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain( 'MOSAIC_AGENT_NAME=coder0', ); }); it.each(['unsafe directory permissions', 'symlinked directory'] as const)( 'rejects delete before roster mutation for %s', async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); const beforeRoster = await readFile(rosterPath, 'utf8'); let generatedPath = join(agentsDir, 'coder0.env.generated'); if (hazard === 'unsafe directory permissions') { await chmod(agentsDir, 0o777); } else { const targetDir = join(home, 'fleet', 'agents-target'); await rename(agentsDir, targetDir); await symlink(targetDir, agentsDir, 'dir'); generatedPath = join(targetDir, 'coder0.env.generated'); } try { captured.lines.length = 0; await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8', ]); } finally { if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700); } expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'mutation-failed' }, }); expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster); expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0'); }, ); it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise => { const home = await fleetHome(); const rosterPath = join(home, 'fleet', 'roster.yaml'); const agentsDir = join(home, 'fleet', 'agents'); const captured = output(); const updated = { ...agent, alias: 'Coder Zero' }; await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'create', '--expected-generation', '7', '--agent', JSON.stringify(agent), ]); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true }); expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain( 'MOSAIC_AGENT_NAME=coder0', ); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'update', 'coder0', '--expected-generation', '8', '--agent', JSON.stringify(updated), ]); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true, plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } }, }); await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', { mode: 0o600, }); await program(home).parseAsync([ 'node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '9', ]); expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true, plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } }, }); await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow(); expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe( 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', ); expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10'); expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0'); }); });