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'),
|
||||
|
||||
@@ -288,8 +288,8 @@ describe('fleet roster parsing', () => {
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
// Reflects the roster's non-default `class: implementer` (A3a).
|
||||
'MOSAIC_AGENT_CLASS=implementer',
|
||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_MODEL=',
|
||||
'MOSAIC_AGENT_REASONING=',
|
||||
@@ -3505,7 +3505,66 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example.test',
|
||||
userId: '@mosaic:example.test',
|
||||
roomId: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
|
||||
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
try {
|
||||
await writeFile(rosterPath, yaml);
|
||||
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example.test',
|
||||
user_id: '@mosaic:example.test',
|
||||
room_id: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.json');
|
||||
try {
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
|
||||
connector,
|
||||
}),
|
||||
);
|
||||
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
|
||||
const rosterWithOptionals: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
@@ -3517,6 +3576,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
workingDirectory: '/tmp/work',
|
||||
persistentPersona: true,
|
||||
resetBetweenTasks: false,
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3524,6 +3586,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
|
||||
expect(yaml).toContain('working_directory: /tmp/work');
|
||||
expect(yaml).toContain('persistent_persona: true');
|
||||
expect(yaml).toContain('host: 10.1.10.37');
|
||||
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
|
||||
expect(yaml).toContain('socket: mosaic-fleet');
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
@@ -3533,6 +3598,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
|
||||
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
|
||||
expect(loaded.agents[0]!.persistentPersona).toBe(true);
|
||||
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
|
||||
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
|
||||
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -18,6 +18,19 @@ import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
@@ -86,72 +99,6 @@ export interface FleetCommandDeps {
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
mosaicHome: string;
|
||||
rosterPath: string;
|
||||
@@ -170,8 +117,6 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||
* fallback for a socket-less roster (that now resolves to the default socket).
|
||||
*/
|
||||
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
|
||||
/**
|
||||
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
||||
@@ -195,13 +140,6 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
|
||||
* Configurable via `--verify-timeout <ms>` on `agent send`.
|
||||
*/
|
||||
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||
return {
|
||||
mosaicHome,
|
||||
@@ -229,20 +167,6 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const rawText = await readFile(path, 'utf8');
|
||||
const parsed = parseRosterText(rawText, path);
|
||||
return normalizeRoster(parsed);
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
||||
//
|
||||
@@ -557,7 +481,7 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2157,14 +2081,11 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('comms-block <role>')
|
||||
.description(
|
||||
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
|
||||
)
|
||||
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
|
||||
.action((role: string, opts: { host?: string }) => {
|
||||
.command('comms-block <exact-member>')
|
||||
.description('Print the Fleet Comms contract for one exact roster member')
|
||||
.action((exactMember: string) => {
|
||||
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
||||
const res = resolveCommsBlock(mosaicHome, role, opts.host);
|
||||
const res = resolveCommsBlock(mosaicHome, exactMember);
|
||||
if (!res.ok) {
|
||||
console.error(`[mosaic] comms-block: ${res.error}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2490,249 +2411,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
||||
const trimmed = text.trim();
|
||||
if (path.endsWith('.json')) {
|
||||
return JSON.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
return YAML.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) {
|
||||
throw new Error('Fleet roster version must be 1.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') {
|
||||
throw new Error('Fleet roster transport must be "tmux".');
|
||||
}
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
|
||||
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
|
||||
// unaffected; only socket-less rosters get default-socket behavior.
|
||||
socketName: stringValue(
|
||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
),
|
||||
holderSession: stringValue(
|
||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) {
|
||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
raw.working_directory ?? raw.workingDirectory,
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
raw.reasoning_level ?? raw.reasoningLevel,
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
raw.tool_policy ?? raw.toolPolicy,
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
config.reset_command ?? config.resetCommand,
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
@@ -2889,6 +2567,23 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
||||
};
|
||||
}
|
||||
|
||||
function serializeConnector(
|
||||
connector: NonNullable<FleetRoster['connector']>,
|
||||
): Record<string, unknown> {
|
||||
if (connector.kind === 'tmux') return { kind: 'tmux' };
|
||||
if (connector.kind === 'discord') {
|
||||
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
|
||||
}
|
||||
return {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: connector.matrix.homeserverUrl,
|
||||
user_id: connector.matrix.userId,
|
||||
room_id: connector.matrix.roomId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a FleetRoster to YAML text (snake_case keys).
|
||||
* The output is parseable by loadFleetRoster.
|
||||
@@ -2906,6 +2601,15 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
if (agent.provider !== undefined) {
|
||||
raw['provider'] = agent.provider;
|
||||
}
|
||||
if (agent.host !== undefined) {
|
||||
raw['host'] = agent.host;
|
||||
}
|
||||
if (agent.ssh !== undefined) {
|
||||
raw['ssh'] = agent.ssh;
|
||||
}
|
||||
if (agent.socket !== undefined) {
|
||||
raw['socket'] = agent.socket;
|
||||
}
|
||||
if (agent.workingDirectory !== undefined) {
|
||||
raw['working_directory'] = agent.workingDirectory;
|
||||
}
|
||||
@@ -2947,6 +2651,7 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
},
|
||||
runtimes,
|
||||
agents,
|
||||
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
|
||||
};
|
||||
|
||||
return YAML.stringify(raw);
|
||||
@@ -3093,6 +2798,5 @@ export async function resolveRosterPath(
|
||||
if (await canRead(yamlPath)) {
|
||||
return yamlPath;
|
||||
}
|
||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
||||
return jsonPath;
|
||||
return resolveInstalledFleetRosterPath(mosaicHome);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,17 @@ import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
buildResolvedFleetCommsBlock,
|
||||
renderToolsContractStatus,
|
||||
resolveFleetIdentity,
|
||||
} from '../fleet/comms-onboarding.js';
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
|
||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
@@ -185,6 +192,17 @@ function readOptional(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledToolsSecure(mosaicHome: string): string {
|
||||
try {
|
||||
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
|
||||
}).content.toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
@@ -361,9 +379,25 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||
}
|
||||
|
||||
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (!fleetIdentity.ok) {
|
||||
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||
}
|
||||
const canonicalMember = fleetIdentity.identity?.member;
|
||||
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||
if (ambientClass !== canonicalMember.className) {
|
||||
throw new Error(
|
||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
||||
const tools = readInstalledToolsSecure(mosaicHome);
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
|
||||
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
|
||||
|
||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||
// inject only the small `.local` delta by value so the customization reaches
|
||||
@@ -385,24 +419,23 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
// Persona contract (A3b): when this agent was spawned with a class
|
||||
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
|
||||
// role contract so its identity (mandate + boundaries) is resident from the
|
||||
// first turn. Override-aware via the persona resolver: a user-customized
|
||||
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
|
||||
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
|
||||
// silently when the class is unset/unknown (mirrors the comms block).
|
||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||
// Fleet launches derive every identity projection from the one canonical roster
|
||||
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
||||
// and tool-policy behavior.
|
||||
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
|
||||
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
||||
if (persona) parts.push('\n\n' + persona);
|
||||
|
||||
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||
const toolPolicyName = canonicalMember
|
||||
? canonicalMember.toolPolicy
|
||||
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
if (fleetIdentity.identity) {
|
||||
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user