feat(fleet): compose and launch profile-backed seats
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyFleetLaunchComposition,
|
||||
deepMergeSettings,
|
||||
FleetLaunchError,
|
||||
formatFleetLaunchDryRun,
|
||||
parseFleetAgentProfile,
|
||||
registerFleetLaunchCommand,
|
||||
resolveFleetLaunchComposition,
|
||||
} from './fleet-launch-command.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
|
||||
root: string;
|
||||
systemHome: string;
|
||||
userHome: string;
|
||||
agentDir: string;
|
||||
namedBundleDir: string;
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
|
||||
roots.push(root);
|
||||
const systemHome = join(root, 'system');
|
||||
const userHome = join(root, 'user');
|
||||
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
||||
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
|
||||
mkdirSync(join(systemHome, 'framework', 'runtime', 'claude'), { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(namedBundleDir, { recursive: true });
|
||||
writeFileSync(join(systemHome, 'framework', 'runtime', 'claude', 'settings.json'), '{}\n');
|
||||
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
||||
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
|
||||
writeFileSync(
|
||||
join(namedBundleDir, 'account.json'),
|
||||
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
||||
);
|
||||
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
|
||||
return { root, systemHome, userHome, agentDir, namedBundleDir };
|
||||
}
|
||||
|
||||
describe('fleet launch profile schema 1', () => {
|
||||
it('rejects an unknown key and names it', () => {
|
||||
expect(() =>
|
||||
parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
|
||||
).toThrowError(/unknown profile key "pluigns"/);
|
||||
});
|
||||
|
||||
it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
|
||||
try {
|
||||
parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
|
||||
throw new Error('expected parse to fail');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(FleetLaunchError);
|
||||
expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
|
||||
expect((error as Error).message).toMatch(/upgrade Mosaic/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('three-layer settings merge', () => {
|
||||
it('keeps base-only settings', () => {
|
||||
expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
|
||||
});
|
||||
|
||||
it('uses the last layer for scalar conflicts', () => {
|
||||
expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
|
||||
});
|
||||
|
||||
it('replaces arrays instead of appending', () => {
|
||||
expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
|
||||
hooks: ['user'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses null as a key-deleting tombstone', () => {
|
||||
expect(
|
||||
deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
|
||||
).toEqual({ nested: { keep: true } });
|
||||
});
|
||||
|
||||
it('deep-merges all three layers in precedence order', () => {
|
||||
expect(
|
||||
deepMergeSettings(
|
||||
{ nested: { system: true, shared: 'system' }, list: [1] },
|
||||
{ nested: { user: true, shared: 'user' }, list: [2] },
|
||||
{ nested: { agent: true, shared: 'agent' }, list: [3] },
|
||||
),
|
||||
).toEqual({
|
||||
nested: { system: true, user: true, agent: true, shared: 'agent' },
|
||||
list: [3],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile-selected overlay', () => {
|
||||
it('defaults to no overlay when the optional profile field is omitted', () => {
|
||||
const fx = fixture();
|
||||
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');
|
||||
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
expect(plan.settings.merged).toEqual({});
|
||||
expect(plan.settings.layers[2]?.present).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A3 credential validation', () => {
|
||||
it('refuses a symlinked bundle credential file', () => {
|
||||
const fx = fixture();
|
||||
rmSync(join(fx.namedBundleDir, '.credentials.json'));
|
||||
const outside = join(fx.root, 'outside-credentials.json');
|
||||
writeFileSync(outside, '{}\n');
|
||||
symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));
|
||||
|
||||
expect(() =>
|
||||
resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
}),
|
||||
).toThrowError(/real, non-symlink credential file/);
|
||||
});
|
||||
|
||||
it('accepts a real credential file contained in the harness auth root', () => {
|
||||
const fx = fixture();
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
|
||||
expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
|
||||
expect(plan.bundle.display).toBe('primary -> fred_example.com ([email protected])');
|
||||
});
|
||||
|
||||
it('refuses first-auth state when a real file occupies the seat link', () => {
|
||||
const fx = fixture();
|
||||
const seatHome = join(fx.agentDir, '.claude');
|
||||
mkdirSync(seatHome, { recursive: true });
|
||||
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
|
||||
|
||||
expect(() =>
|
||||
resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
}),
|
||||
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
|
||||
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed plugin and skill links', () => {
|
||||
it('installs listed entries and prunes only stale managed symlinks', () => {
|
||||
const fx = fixture({
|
||||
schema: 1,
|
||||
harness: 'claude',
|
||||
plugins: ['keep'],
|
||||
skills: ['mosaic-tools'],
|
||||
});
|
||||
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
|
||||
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
|
||||
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
|
||||
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
||||
mkdirSync(pluginHome, { recursive: true });
|
||||
symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir');
|
||||
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
|
||||
applyFleetLaunchComposition(plan);
|
||||
|
||||
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
|
||||
expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a real directory at a managed link path without deleting it', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
||||
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
|
||||
const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
|
||||
mkdirSync(occupied, { recursive: true });
|
||||
|
||||
expect(() =>
|
||||
resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
}),
|
||||
).toThrowError(/real plugin directory.*refusing to delete/i);
|
||||
expect(lstatSync(occupied).isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet launch command outcomes', () => {
|
||||
it('--dry-run prints without writing or invoking the launcher', () => {
|
||||
const fx = fixture();
|
||||
const program = new Command().exitOverride();
|
||||
const fleet = program.command('fleet');
|
||||
const launcher = vi.fn();
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
||||
userHome: fx.userHome,
|
||||
launcher,
|
||||
});
|
||||
|
||||
try {
|
||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
|
||||
expect(stdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('mosaic fleet launch fred --dry-run'),
|
||||
);
|
||||
expect(launcher).not.toHaveBeenCalled();
|
||||
expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
|
||||
} finally {
|
||||
stdout.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the plan and invokes the existing launch seam with declared values', () => {
|
||||
const fx = fixture({
|
||||
schema: 1,
|
||||
harness: 'claude',
|
||||
model: 'opus',
|
||||
env: { SEAT_FLAG: 'yes' },
|
||||
});
|
||||
const program = new Command().exitOverride();
|
||||
const fleet = program.command('fleet');
|
||||
const launcher = vi.fn();
|
||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
||||
userHome: fx.userHome,
|
||||
launcher,
|
||||
});
|
||||
|
||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
||||
|
||||
expect(launcher).toHaveBeenCalledWith(
|
||||
'claude',
|
||||
['--model', 'opus'],
|
||||
{
|
||||
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
|
||||
MOSAIC_AGENT_NAME: 'fred',
|
||||
SEAT_FLAG: 'yes',
|
||||
},
|
||||
{ agentDir: fx.agentDir },
|
||||
);
|
||||
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets a non-zero exit code and never invokes the launcher', () => {
|
||||
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
|
||||
const program = new Command().exitOverride();
|
||||
const fleet = program.command('fleet');
|
||||
const launcher = vi.fn();
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const priorExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
||||
userHome: fx.userHome,
|
||||
launcher,
|
||||
});
|
||||
|
||||
try {
|
||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(launcher).not.toHaveBeenCalled();
|
||||
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
|
||||
} finally {
|
||||
process.exitCode = priorExitCode;
|
||||
stderr.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry-run composition', () => {
|
||||
it('renders a deterministic full composition and writes nothing', () => {
|
||||
const fx = fixture({
|
||||
schema: 1,
|
||||
harness: 'claude',
|
||||
bundle: 'primary',
|
||||
model: 'opus',
|
||||
overlay: 'overlay.json',
|
||||
plugins: ['code-review'],
|
||||
skills: ['mosaic-tools'],
|
||||
env: { SEAT_FLAG: 'yes' },
|
||||
});
|
||||
writeFileSync(
|
||||
join(fx.systemHome, 'framework', 'runtime', 'claude', 'settings.json'),
|
||||
'{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
|
||||
);
|
||||
mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fx.userHome, 'config', 'claude', 'settings.json'),
|
||||
'{"hooks":["user"],"nested":{"user":true}}\n',
|
||||
);
|
||||
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
|
||||
mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
|
||||
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
|
||||
|
||||
const plan = resolveFleetLaunchComposition('fred', {
|
||||
systemHome: fx.systemHome,
|
||||
userHome: fx.userHome,
|
||||
});
|
||||
const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"mosaic fleet launch fred --dry-run
|
||||
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
|
||||
harness: claude
|
||||
seat-home: <ROOT>/user/fleet/agents/fred/.claude
|
||||
settings sources:
|
||||
system: <ROOT>/system/framework/runtime/claude/settings.json
|
||||
user: <ROOT>/user/config/claude/settings.json
|
||||
agent: <ROOT>/user/fleet/agents/fred/overlay.json
|
||||
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
|
||||
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
|
||||
merged settings:
|
||||
{
|
||||
"hooks": [
|
||||
"user"
|
||||
],
|
||||
"nested": {
|
||||
"agent": true,
|
||||
"system": true,
|
||||
"user": true
|
||||
}
|
||||
}
|
||||
bundle: primary -> fred_example.com ([email protected])
|
||||
symlinks:
|
||||
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
||||
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
|
||||
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
|
||||
declared env:
|
||||
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
|
||||
MOSAIC_AGENT_NAME=fred
|
||||
SEAT_FLAG=yes
|
||||
argv: ["claude","--model","opus"]"
|
||||
`);
|
||||
expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();
|
||||
|
||||
applyFleetLaunchComposition(plan);
|
||||
expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
|
||||
hooks: ['user'],
|
||||
nested: { agent: true, system: true, user: true },
|
||||
});
|
||||
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
|
||||
readFileSync(plan.settings.output, 'utf8'),
|
||||
);
|
||||
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user