fix(fleet): enforce exact comms authority (#787)
This commit was merged in pull request #787.
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter } from '../config/file-adapter.js';
|
||||
import { composeContract } from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -22,7 +35,9 @@ import { composeContract } from './launch.js';
|
||||
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||
const USER = '# operator\n\nName: Test Operator\n';
|
||||
const TOOLS = '# tools index\n';
|
||||
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
|
||||
|
||||
function makeHome(): { home: string; root: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||
@@ -31,6 +46,12 @@ function makeHome(): { home: string; root: string } {
|
||||
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||
}
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||
writeFileSync(join(home, 'USER.md'), USER);
|
||||
@@ -42,16 +63,31 @@ describe('composeContract — overlay composer', () => {
|
||||
let fixture: ReturnType<typeof makeHome>;
|
||||
let prevCwd: string;
|
||||
let cwdDir: string;
|
||||
let prevAgentName: string | undefined;
|
||||
let prevAgentClass: string | undefined;
|
||||
let prevAgentToolPolicy: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeHome();
|
||||
prevCwd = process.cwd();
|
||||
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
|
||||
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
|
||||
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
|
||||
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
|
||||
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
rmSync(cwdDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -64,13 +100,17 @@ describe('composeContract — overlay composer', () => {
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' host: w-jarvis',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -82,19 +122,266 @@ describe('composeContract — overlay composer', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
||||
// local peer → no -H; cross-host peer → -H ssh
|
||||
expect(out).toContain('-s orchestrator -m "…"');
|
||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
||||
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
|
||||
composeContract(runtime, fixture.home),
|
||||
);
|
||||
for (const out of outputs) {
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toContain('Host: `w-jarvis`');
|
||||
expect(out).toContain('Agent/session: `enhancer`');
|
||||
expect(out).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(out).toContain(
|
||||
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
|
||||
);
|
||||
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
|
||||
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
|
||||
}
|
||||
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
|
||||
const authoritative = commsSection(outputs[0]!);
|
||||
expect(outputs.map(commsSection)).toEqual([
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
]);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
' tool_policy: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('CANONICAL-CODE-MANDATE');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
expect(out).not.toContain('ambient-policy-must-not-win');
|
||||
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'rejects an ambient class that mismatches the canonical roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
|
||||
|
||||
expect(() => composeContract(runtime, fixture.home)).toThrow(
|
||||
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
|
||||
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'solo';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
|
||||
|
||||
const out = composeContract('claude', fixture.home);
|
||||
|
||||
expect(out).toContain('## Mandate');
|
||||
expect(out).toContain('## Boundaries');
|
||||
expect(out).toContain('Role/class: `orchestrator`');
|
||||
expect(out).toContain('## Solo authority boundaries');
|
||||
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out).toContain('no peer, orchestrator, or remote communication authority');
|
||||
});
|
||||
|
||||
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
|
||||
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
|
||||
const installedHome = join(installRoot, 'mosaic-home');
|
||||
mkdirSync(installedHome, { recursive: true });
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
|
||||
try {
|
||||
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
|
||||
const installedToolsPath = join(installedHome, 'TOOLS.md');
|
||||
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
|
||||
expect(sourceTools).toContain('fleet-comms-contract: 1');
|
||||
expect(sourceTools).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
|
||||
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
|
||||
|
||||
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(installedHome, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: exact-socket',
|
||||
'agents:',
|
||||
' - name: exact-self',
|
||||
' runtime: pi',
|
||||
' class: orchestrator',
|
||||
' host: local-host',
|
||||
' - name: exact-peer',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: local-host',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
|
||||
|
||||
const composed = composeContract('pi', installedHome);
|
||||
expect(composed).toContain(sourceTools);
|
||||
expect(composed).toContain(`Helper: \`${helper}\``);
|
||||
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
|
||||
expect(composed).not.toContain('# Fleet Comms Installation Status');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through a target symlink for %s',
|
||||
(runtime) => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const external = join(fixture.root, 'attacker-tools.md');
|
||||
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
rmSync(toolsPath);
|
||||
symlinkSync(external, toolsPath);
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through an ancestor symlink for %s',
|
||||
(runtime) => {
|
||||
const realHome = join(fixture.root, 'real-mosaic-home');
|
||||
renameSync(fixture.home, realHome);
|
||||
symlinkSync(realHome, fixture.home, 'dir');
|
||||
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
|
||||
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('reports stale preserved TOOLS.md without rewriting it', () => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const stale = '# user-customized tools without fleet contract marker\n';
|
||||
writeFileSync(toolsPath, stale);
|
||||
|
||||
const out = composeContract('pi', fixture.home);
|
||||
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('does not byte-match');
|
||||
expect(out).toContain('active context was not rewritten');
|
||||
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
@@ -105,6 +392,24 @@ describe('composeContract — overlay composer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when an explicitly requested fleet identity is unknown', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
|
||||
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||
@@ -240,10 +545,14 @@ describe('composeContract — overlay composer', () => {
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
||||
Reference in New Issue
Block a user