Files
stack/packages/mosaic/src/commands/fleet-agent-crud-command.spec.ts
jason.woltje bc5e73629e
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(fleet): add generation-guarded agent CRUD (#773)
2026-07-15 12:03:05 +00:00

658 lines
21 KiB
TypeScript

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<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<never> => {
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<void> => {
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<void> => {
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<never> => {
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<void> => {
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<void> => {
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');
});
});