Merge origin/main into feat/758-v1-v2-migrator
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
checkForAllUpdates,
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
repairFleetCommsTools,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readRosterAgentNames,
|
||||
@@ -420,115 +421,142 @@ program
|
||||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||
)
|
||||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||
const reseedFramework = (reason: string): void => {
|
||||
console.log(reason);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (!reseed.ok) {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
.option(
|
||||
'--repair-tools',
|
||||
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||
)
|
||||
.action(
|
||||
async (opts: {
|
||||
check?: boolean;
|
||||
reseed?: boolean;
|
||||
relaunch?: boolean;
|
||||
repairTools?: boolean;
|
||||
}) => {
|
||||
if (opts.repairTools) {
|
||||
const repair = repairFleetCommsTools();
|
||||
if (!repair.ok) {
|
||||
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
repair.changed
|
||||
? 'Fleet communications tools repaired from the supported current framework.'
|
||||
: 'Fleet communications tools already match the supported current framework.',
|
||||
);
|
||||
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
|
||||
console.log('No active context or session was rewritten.');
|
||||
return;
|
||||
}
|
||||
console.log('✔ Framework re-seeded.');
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
if (units.refreshed.length > 0) {
|
||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length === 0) return;
|
||||
if (opts.relaunch) {
|
||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||
const reseedFramework = (reason: string): void => {
|
||||
console.log(reason);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (!reseed.ok) {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
console.log('✔ Framework re-seeded.');
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
if (units.refreshed.length > 0) {
|
||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length === 0) return;
|
||||
if (opts.relaunch) {
|
||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const results = checkForAllUpdates({ skipCache: true });
|
||||
|
||||
console.log('');
|
||||
console.log(formatAllPackagesTable(results));
|
||||
|
||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||
if (outdated.length === 0) {
|
||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||
if (!anyInstalled) {
|
||||
console.error('No @mosaicstack/* packages are installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✔ All packages up to date.');
|
||||
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
||||
// direct `npm i -g`), leaving the framework files stale even though no
|
||||
// package is reported outdated. Detect that via the framework version and
|
||||
// re-seed so shipped launcher/runtime fixes still activate.
|
||||
const drift = checkFrameworkDrift();
|
||||
if (drift.drifted && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||
'~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const results = checkForAllUpdates({ skipCache: true });
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(formatAllPackagesTable(results));
|
||||
|
||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||
if (outdated.length === 0) {
|
||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||
if (!anyInstalled) {
|
||||
console.error('No @mosaicstack/* packages are installed.');
|
||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||
try {
|
||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||
const cmd = getInstallAllCommand(outdated);
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✔ All packages up to date.');
|
||||
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
||||
// direct `npm i -g`), leaving the framework files stale even though no
|
||||
// package is reported outdated. Detect that via the framework version and
|
||||
// re-seed so shipped launcher/runtime fixes still activate.
|
||||
|
||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
||||
// framework is older than the freshly-installed one (#642 — e.g. only
|
||||
// sibling packages were outdated but the CLI was already ahead).
|
||||
const mosaicUpdated = outdated.some(
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
const drift = checkFrameworkDrift();
|
||||
if (drift.drifted && opts.reseed !== false) {
|
||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||
'~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||
try {
|
||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||
const cmd = getInstallAllCommand(outdated);
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
||||
// framework is older than the freshly-installed one (#642 — e.g. only
|
||||
// sibling packages were outdated but the CLI was already ahead).
|
||||
const mosaicUpdated = outdated.some(
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
const drift = checkFrameworkDrift();
|
||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -335,8 +335,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=',
|
||||
@@ -3552,7 +3552,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: [
|
||||
@@ -3564,6 +3623,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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3571,6 +3633,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');
|
||||
@@ -3580,6 +3645,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,
|
||||
@@ -91,72 +104,6 @@ export interface FleetCommandDeps {
|
||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -175,8 +122,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
|
||||
@@ -200,13 +145,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,
|
||||
@@ -234,20 +172,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
|
||||
//
|
||||
@@ -562,7 +486,7 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2166,14 +2090,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;
|
||||
@@ -2499,249 +2420,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;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
@@ -2894,6 +2572,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.
|
||||
@@ -2911,6 +2606,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;
|
||||
}
|
||||
@@ -2952,6 +2656,7 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
},
|
||||
runtimes,
|
||||
agents,
|
||||
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
|
||||
};
|
||||
|
||||
return YAML.stringify(raw);
|
||||
@@ -3098,6 +2803,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');
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||
writeFileSync(
|
||||
join(defaultsDir, 'TOOLS.md'),
|
||||
'# TOOLS default\n\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
|
||||
// Non-contract files we must NOT seed on first install.
|
||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||
@@ -71,9 +74,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
for (const name of DEFAULT_SEED_FILES) {
|
||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||
}
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||
'# TOOLS default',
|
||||
);
|
||||
const sourceTools = readFileSync(join(fixture.defaultsDir, 'TOOLS.md'), 'utf-8');
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(sourceTools);
|
||||
});
|
||||
|
||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||
@@ -153,17 +155,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||
});
|
||||
|
||||
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for the roster-loss bug (#631): user-authored fleet files must
|
||||
it('preserves user fleet data (YAML/JSON rosters, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for roster loss (#631/#766): user-authored fleet files must
|
||||
// survive the framework re-seed that `mosaic update` runs.
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), '{"mine":true}\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
||||
// The framework ships fleet/examples — it should still seed/refresh.
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), '{"stale":true}\n');
|
||||
// The framework ships fleet/examples and roster.schema.json — both refresh.
|
||||
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'roster.schema.json'), '{"fresh":true}\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
@@ -171,10 +176,16 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
||||
'version: 1\nMINE\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), 'utf-8')).toBe(
|
||||
'{"mine":true}\n',
|
||||
);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
||||
// framework-owned fleet/examples is seeded
|
||||
// Framework-owned fleet assets are refreshed; unrelated user YAML is not preserved.
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), 'utf-8')).toBe(
|
||||
'{"fresh":true}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
|
||||
@@ -177,7 +177,8 @@ export class FileConfigAdapter implements ConfigService {
|
||||
// The framework seeds only fleet/examples + fleet/roles +
|
||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||
'fleet/*.yaml',
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/agents',
|
||||
'fleet/run',
|
||||
]
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
|
||||
import {
|
||||
parseRosterAgents,
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
resolveCommsBlock,
|
||||
type CommsPeer,
|
||||
resolvePeerCommand,
|
||||
renderToolsContractStatus,
|
||||
} from './comms-onboarding.js';
|
||||
|
||||
const ROSTER = [
|
||||
'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',
|
||||
' runtime: pi',
|
||||
' class: implementer',
|
||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -33,206 +46,687 @@ const ROSTER = [
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('parseRosterAgents', () => {
|
||||
it('parses name + class + optional host/ssh', () => {
|
||||
const peers = parseRosterAgents(ROSTER);
|
||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
||||
className: 'implementer',
|
||||
function roster(source = ROSTER): FleetRoster {
|
||||
return parseFleetRosterV1(source, 'yaml');
|
||||
}
|
||||
|
||||
describe('shared fleet roster v1 resolver', () => {
|
||||
it('resolves comms fields and the global socket through the canonical roster contract', () => {
|
||||
const resolved = roster();
|
||||
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
|
||||
className: 'code',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
});
|
||||
// local agents have no host/ssh
|
||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses an optional per-agent socket', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
|
||||
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||
/unknown field/i,
|
||||
);
|
||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
|
||||
});
|
||||
|
||||
it('stops at the next top-level key', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
||||
'\n',
|
||||
it('rejects an unsupported independent per-agent socket instead of targeting a nonexistent session', () => {
|
||||
expect(() =>
|
||||
roster(
|
||||
ROSTER.replace(
|
||||
' host: w-jarvis\n - name: coder0',
|
||||
' host: w-jarvis\n socket: other-socket\n - name: coder0',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
||||
).toThrow(/independent per-agent sockets are not supported/i);
|
||||
});
|
||||
|
||||
it('rejects unsafe operational targeting values', () => {
|
||||
expect(() =>
|
||||
roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37', ' ssh: host;touch-owned')),
|
||||
).toThrow(/unsupported targeting characters/i);
|
||||
});
|
||||
|
||||
it('normalizes matching connector settings for YAML and JSON rosters', () => {
|
||||
const yamlSource = `${ROSTER}connector:\n kind: discord\n discord:\n channel_id: "123"\n`;
|
||||
const jsonSource = JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector: {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseFleetRosterV1(yamlSource, 'yaml').connector).toEqual({
|
||||
kind: 'discord',
|
||||
discord: { channelId: '123' },
|
||||
});
|
||||
expect(parseFleetRosterV1(jsonSource, 'json').connector).toEqual({
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example',
|
||||
userId: '@a:example',
|
||||
roomId: '!room:example',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['discord channel_id', { kind: 'discord', discord: { channel_id: '' } }],
|
||||
['discord channel_id whitespace', { kind: 'discord', discord: { channel_id: ' ' } }],
|
||||
[
|
||||
'matrix homeserver_url',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix homeserver_url whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '\t', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: ' ',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects empty or whitespace-only parser-required connector string: %s',
|
||||
(_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow(/required/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['tmux with discord settings', { kind: 'tmux', discord: { channel_id: '123' } }],
|
||||
['discord without discord settings', { kind: 'discord' }],
|
||||
[
|
||||
'discord with matrix settings',
|
||||
{ kind: 'discord', discord: { channel_id: '123' }, matrix: {} },
|
||||
],
|
||||
['matrix without matrix settings', { kind: 'matrix' }],
|
||||
[
|
||||
'matrix with discord settings',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
discord: { channel_id: '123' },
|
||||
},
|
||||
],
|
||||
])('rejects connector kind/settings mismatch: %s', (_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux socket', ['tmux', 'socket_name'], ['tmux', 'socketName'], 'same', 'different'],
|
||||
['tmux holder', ['tmux', 'holder_session'], ['tmux', 'holderSession'], 'same', 'different'],
|
||||
[
|
||||
'defaults working directory',
|
||||
['defaults', 'working_directory'],
|
||||
['defaults', 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'runtime reset command',
|
||||
['runtimes', 'claude', 'reset_command'],
|
||||
['runtimes', 'claude', 'resetCommand'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent working directory',
|
||||
['agents', 0, 'working_directory'],
|
||||
['agents', 0, 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent model hint',
|
||||
['agents', 0, 'model_hint'],
|
||||
['agents', 0, 'modelHint'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent reasoning level',
|
||||
['agents', 0, 'reasoning_level'],
|
||||
['agents', 0, 'reasoningLevel'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent tool policy',
|
||||
['agents', 0, 'tool_policy'],
|
||||
['agents', 0, 'toolPolicy'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent persistent persona',
|
||||
['agents', 0, 'persistent_persona'],
|
||||
['agents', 0, 'persistentPersona'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent reset between tasks',
|
||||
['agents', 0, 'reset_between_tasks'],
|
||||
['agents', 0, 'resetBetweenTasks'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent kickstart template',
|
||||
['agents', 0, 'kickstart_template'],
|
||||
['agents', 0, 'kickstartTemplate'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
] as const)(
|
||||
'rejects conflicting %s aliases and accepts identical aliases',
|
||||
(_label, snake, camel, same, different) => {
|
||||
const base: Record<string, unknown> = {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {},
|
||||
defaults: {},
|
||||
runtimes: { claude: {} },
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
};
|
||||
const assign = (
|
||||
root: Record<string, unknown>,
|
||||
path: readonly (string | number)[],
|
||||
value: unknown,
|
||||
) => {
|
||||
let cursor: unknown = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
cursor = (cursor as Record<string | number, unknown>)[segment];
|
||||
}
|
||||
(cursor as Record<string | number, unknown>)[path.at(-1)!] = value;
|
||||
};
|
||||
assign(base, snake, same);
|
||||
assign(base, camel, different);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).toThrow(
|
||||
/aliases .* conflict/i,
|
||||
);
|
||||
assign(base, camel, same);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
||||
describe('renderPeerReach — exact same-host/cross-host/socket targeting', () => {
|
||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const base: FleetAgent = {
|
||||
name: 'peer',
|
||||
runtime: 'claude',
|
||||
className: 'worker',
|
||||
};
|
||||
|
||||
it('renders the short form for a same-host peer', () => {
|
||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
||||
});
|
||||
|
||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
it('renders the global named socket and omits -H for a same-host peer', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
||||
});
|
||||
|
||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
||||
});
|
||||
|
||||
it('emits NO -L for an unset/default socket', () => {
|
||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
||||
});
|
||||
|
||||
it('emits -L <socket> for a named socket', () => {
|
||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s coder0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
||||
const peer: CommsPeer = {
|
||||
it('uses only the explicit roster ssh target for a cross-host peer', () => {
|
||||
const peer: FleetAgent = {
|
||||
...base,
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a cross-host peer has no explicit roster ssh target', () => {
|
||||
const peer: FleetAgent = { ...base, name: 'x', host: '10.0.0.9' };
|
||||
expect(() => renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toThrow(
|
||||
/explicit roster ssh target/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only the fleet-wide supported socket', () => {
|
||||
const peer: FleetAgent = { ...base, socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves hostless peers against the stable fleet-host baseline, not the viewer host', () => {
|
||||
const peer: FleetAgent = { ...base, ssh: 'fleet-user@w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'remote-host', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H fleet-user@w-jarvis -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('shell-quotes an exact helper path that contains spaces', () => {
|
||||
expect(
|
||||
renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', '/home/test user/send.sh'),
|
||||
).toBe(`'/home/test user/send.sh' -L mosaic-fleet -s peer -m "…"`);
|
||||
});
|
||||
|
||||
it('omits -L only for the literal default socket', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', '', send)).toBe(`${send} -s peer -m "…"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFleetCommsBlock', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const agents = parseRosterAgents(ROSTER);
|
||||
|
||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
||||
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
agents,
|
||||
fleetHost: 'w-jarvis',
|
||||
roster: roster(),
|
||||
localHost: 'ignored-process-host',
|
||||
agentSendPath: send,
|
||||
});
|
||||
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('You are **enhancer**');
|
||||
// criterion 1: agent's own [host:session] identity
|
||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
||||
// self excluded
|
||||
expect(block).toContain('Host: `w-jarvis`');
|
||||
expect(block).toContain('Agent/session: `enhancer`');
|
||||
expect(block).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(block).toContain(`Helper: \`${send}\``);
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||
// peers present
|
||||
expect(block).toContain('| orchestrator |');
|
||||
expect(block).toContain('point of contact');
|
||||
// same-host peer short form
|
||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
||||
// cross-host peer -H form + host annotation
|
||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain('host `10.1.10.37`');
|
||||
// conventions
|
||||
expect(block).toContain('FLIP the preamble');
|
||||
expect(block).toContain('ACCEPTED');
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain(`mosaic agent comms-block enhancer`);
|
||||
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i);
|
||||
expect(block).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
expect(block).not.toContain('FLIP the preamble');
|
||||
});
|
||||
|
||||
it('returns empty when the agent has no peers', () => {
|
||||
expect(
|
||||
it('changes the generation when a rendered peer role changes', () => {
|
||||
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1];
|
||||
const before = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: roster(),
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
const changedRoster = roster(ROSTER.replace('class: implementer', 'class: reviewer'));
|
||||
const after = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: changedRoster,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(generation(before)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(generation(after)).not.toBe(generation(before));
|
||||
});
|
||||
|
||||
it('fails closed when any rendered cross-host row lacks ssh', () => {
|
||||
const bad = roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37\n', ''));
|
||||
expect(() =>
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
||||
fleetHost: 'h',
|
||||
selfName: 'enhancer',
|
||||
roster: bad,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).toBe('');
|
||||
).toThrow(/explicit roster ssh target/i);
|
||||
});
|
||||
|
||||
it('still renders authoritative local identity when the agent has no peers', () => {
|
||||
const solo = roster(
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
].join('\n'),
|
||||
);
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
roster: solo,
|
||||
localHost: 'h',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(block).toContain('Host: `h`');
|
||||
expect(block).toContain('Agent/session: `solo`');
|
||||
expect(block).toContain('Role/class: `orchestrator`');
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).toContain('This roster has no peers');
|
||||
expect(block).toContain('## Solo authority boundaries');
|
||||
expect(block).toContain('no peer, orchestrator, or remote communication authority');
|
||||
expect(block).toContain('Do not send, infer a target, or claim fleet coordination');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
||||
describe('resolvePeerCommand', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
|
||||
it('returns one exact known-peer row', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'coder0-0', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.command).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
});
|
||||
|
||||
it('fails closed for an unknown peer with exact-name discovery guidance', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'invented-host', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.command).toBe('');
|
||||
expect(result.error).toContain('invented-host');
|
||||
expect(result.error).toContain('orchestrator, coder0, coder0-0');
|
||||
expect(result.error).toContain('mosaic agent comms-block enhancer');
|
||||
expect(result.error).not.toContain('tmux ls');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('| enhancer |');
|
||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
||||
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.output).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
});
|
||||
|
||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||
it('fails closed for a requested fleet identity that is absent', () => {
|
||||
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('Known exact names');
|
||||
});
|
||||
|
||||
it('resolves a supported JSON-only installed roster', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-fleet' },
|
||||
agents: [
|
||||
{ name: 'enhancer', runtime: 'claude', class: 'enhancer', host: 'w-jarvis' },
|
||||
{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator', host: 'w-jarvis' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
});
|
||||
|
||||
it('fails closed on a YAML I/O error instead of falling back to JSON', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
mkdirSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'enhancer', runtime: 'claude', class: 'enhancer' }],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('invalid fleet roster at');
|
||||
expect(result.error).toContain('roster.yaml');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', () => rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'))],
|
||||
[
|
||||
'directory',
|
||||
() => {
|
||||
rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'symlink',
|
||||
() => {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
rmSync(helper);
|
||||
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
|
||||
symlinkSync(join(home, 'real-send.sh'), helper);
|
||||
},
|
||||
],
|
||||
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('mosaic update --repair-tools');
|
||||
expect(result.error).toContain('no active context or session was rewritten');
|
||||
});
|
||||
|
||||
it('does not rewrite the roster while resolving context', () => {
|
||||
const path = join(home, 'fleet', 'roster.yaml');
|
||||
const before = readFileSync(path, 'utf8');
|
||||
readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
|
||||
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
|
||||
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
|
||||
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
|
||||
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
|
||||
describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-'));
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'defaults', 'TOOLS.md'),
|
||||
'# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('uses the supported repair command when installed TOOLS.md is missing', () => {
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).not.toContain('--reseed');
|
||||
expect(status).toContain('authorized operator');
|
||||
});
|
||||
|
||||
it('reports stale preserved content without rewriting it', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
const stale = '# customized tools\n';
|
||||
writeFileSync(path, stale);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('fleet-comms-contract: 1');
|
||||
expect(status).toContain('digest-qualified backup');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).toContain('active context was not rewritten');
|
||||
expect(readFileSync(path, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does not accept marker-only customized content as current', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
writeFileSync(path, '<!-- fleet-comms-contract: 1 -->\ncorrupt\n');
|
||||
expect(renderToolsContractStatus(home)).toContain('does not byte-match');
|
||||
});
|
||||
|
||||
it('rejects markerless byte-equal source and installed content', () => {
|
||||
const content = '# markerless but equal\n';
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), content);
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('does not declare the expected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['source', join('defaults', 'TOOLS.md')],
|
||||
['installed', 'TOOLS.md'],
|
||||
])('rejects a wrong contract version in %s content', (_case, relativePath) => {
|
||||
const current = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, relativePath), current.replace('contract: 1', 'contract: 2'));
|
||||
expect(renderToolsContractStatus(home)).not.toBe('');
|
||||
});
|
||||
|
||||
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
|
||||
const external = join(home, 'external-tools.md');
|
||||
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, externalContent);
|
||||
symlinkSync(external, join(home, 'TOOLS.md'));
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('unavailable');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||
});
|
||||
|
||||
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
|
||||
const external = join(home, 'external-source.md');
|
||||
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, content);
|
||||
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('accepts byte-equal bounded source and installed contracts', () => {
|
||||
const source = readFileSync(join(home, 'defaults', 'TOOLS.md'), 'utf8');
|
||||
writeFileSync(join(home, 'TOOLS.md'), source);
|
||||
expect(renderToolsContractStatus(home)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommsBlock — mosaic agent comms-block', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('returns ok + the cheat-sheet for a roster member', () => {
|
||||
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('# Fleet Comms');
|
||||
expect(res.output).toContain('| enhancer |');
|
||||
expect(res.error).toBeUndefined();
|
||||
it('returns the exact contract for a roster member', () => {
|
||||
const result = resolveCommsBlock(home, 'enhancer');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
|
||||
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.output).toBe('');
|
||||
expect(res.error).toContain('stranger');
|
||||
it('fails loud and lists known exact names for a non-member', () => {
|
||||
const result = resolveCommsBlock(home, 'stranger');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('stranger');
|
||||
expect(result.error).toContain('orchestrator');
|
||||
expect(result.error).toContain('enhancer');
|
||||
});
|
||||
|
||||
it('fails loud when no roster exists at the mosaic home', () => {
|
||||
it('fails loud when no roster exists', () => {
|
||||
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true });
|
||||
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
const result = resolveCommsBlock(noRoster, 'orchestrator');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('no fleet roster');
|
||||
rmSync(noRoster, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails loud for a missing role argument', () => {
|
||||
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('honors a host override so a peer can preview its own cross-host view', () => {
|
||||
// coder0-0 viewing with its own host → its self-identity line uses that host.
|
||||
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
|
||||
const result = resolveCommsBlock(home, undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('requires');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,226 +1,423 @@
|
||||
/**
|
||||
* Fleet onboarding-injection (#620).
|
||||
* Exact roster-resolved fleet communications contract (#766).
|
||||
*
|
||||
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
||||
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
||||
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
||||
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
||||
*
|
||||
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
||||
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
||||
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
||||
* peer. Same-host peers render the short form.
|
||||
*
|
||||
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
||||
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
||||
* is best-effort onboarding, never a hard dependency.
|
||||
* The runtime composer and `mosaic fleet` command surface share the canonical
|
||||
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or
|
||||
* mutates an active session.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface CommsPeer {
|
||||
name: string;
|
||||
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
||||
className: string;
|
||||
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
||||
host?: string;
|
||||
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
||||
ssh?: string;
|
||||
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
||||
* dependency on the full fleet roster parser; the format is `- name:` list items
|
||||
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
||||
*/
|
||||
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
||||
const peers: CommsPeer[] = [];
|
||||
let current: CommsPeer | null = null;
|
||||
let inAgents = false;
|
||||
const scalar = (line: string, key: string): string | null => {
|
||||
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
||||
return m ? (m[1] as string).trim() : null;
|
||||
};
|
||||
for (const rawLine of yamlText.split('\n')) {
|
||||
const line = rawLine.replace(/\s+$/, '');
|
||||
if (/^agents:\s*$/.test(line)) {
|
||||
inAgents = true;
|
||||
continue;
|
||||
}
|
||||
if (!inAgents) continue;
|
||||
// A new top-level key (no leading space) ends the agents block.
|
||||
if (/^\S/.test(line)) break;
|
||||
|
||||
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (nameMatch) {
|
||||
if (current) peers.push(current);
|
||||
current = { name: nameMatch[1] as string, className: 'worker' };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const cls = scalar(line, 'class');
|
||||
if (cls) current.className = cls;
|
||||
const host = scalar(line, 'host');
|
||||
if (host) current.host = host;
|
||||
const ssh = scalar(line, 'ssh');
|
||||
if (ssh) current.ssh = ssh;
|
||||
const socket = scalar(line, 'socket');
|
||||
if (socket) current.socket = socket;
|
||||
}
|
||||
if (current) peers.push(current);
|
||||
return peers;
|
||||
}
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
getRosterAgent,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from './fleet-roster-v1.js';
|
||||
|
||||
export interface FleetCommsOptions {
|
||||
/** This agent's name (it is excluded from its own peer list). */
|
||||
/** Exact current roster member. */
|
||||
selfName: string;
|
||||
/** All roster agents (including self; filtered out internally). */
|
||||
agents: CommsPeer[];
|
||||
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
||||
fleetHost: string;
|
||||
/** Absolute path to agent-send.sh in this install. */
|
||||
/** Canonically resolved roster. */
|
||||
roster: FleetRoster;
|
||||
/** Stable fleet-host baseline for members whose roster host is absent. */
|
||||
localHost: string;
|
||||
/** Absolute helper path in this installation. */
|
||||
agentSendPath: string;
|
||||
}
|
||||
|
||||
/** Is this peer on a different host than the fleet baseline? */
|
||||
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
||||
return peer.host !== undefined && peer.host !== fleetHost;
|
||||
export interface CommsBlockResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exact agent-send command to reach a peer (session = agent name).
|
||||
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
||||
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
||||
*/
|
||||
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
||||
const parts = [agentSendPath];
|
||||
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
||||
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
||||
parts.push('-s', peer.name, '-m', '"…"');
|
||||
export interface ResolvedFleetIdentity {
|
||||
readonly roster: FleetRoster;
|
||||
readonly member: FleetAgent;
|
||||
readonly requestedName: string;
|
||||
readonly agentSendPath: string;
|
||||
readonly localHost: string;
|
||||
}
|
||||
|
||||
export interface FleetIdentityResult {
|
||||
ok: boolean;
|
||||
identity?: ResolvedFleetIdentity;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PeerCommandResult {
|
||||
ok: boolean;
|
||||
command: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const FLEET_COMMS_TOOLS_CONTRACT = 'fleet-comms-contract: 1';
|
||||
const MAX_TOOLS_CONTRACT_BYTES = 256 * 1024;
|
||||
|
||||
function shortHostname(): string {
|
||||
return hostname().split('.')[0] || 'localhost';
|
||||
}
|
||||
|
||||
function resolvedHost(agent: FleetAgent, fleetHost: string): string {
|
||||
return agent.host ?? fleetHost;
|
||||
}
|
||||
|
||||
function displaySocket(socket: string): string {
|
||||
return socket || '(default)';
|
||||
}
|
||||
|
||||
function knownNames(roster: FleetRoster, except?: string): string {
|
||||
return roster.agents
|
||||
.filter((agent) => agent.name !== except)
|
||||
.map((agent) => agent.name)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function missingMemberError(roster: FleetRoster, selfName: string): string {
|
||||
return `Agent "${selfName}" is not in the fleet roster. Known exact names: ${knownNames(roster)}. Select an exact roster name; do not infer or fuzzy-match a tmux session.`;
|
||||
}
|
||||
|
||||
/** Render one shell argument without changing already-safe exact values. */
|
||||
function shellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@=+-]+$/.test(value)) return value;
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
/** Render the exact command for one peer. Throws rather than guessing. */
|
||||
export function renderPeerReach(
|
||||
peer: FleetAgent,
|
||||
selfHost: string,
|
||||
fleetHost: string,
|
||||
rosterSocket: string,
|
||||
agentSendPath: string,
|
||||
): string {
|
||||
const parts = [shellArg(agentSendPath)];
|
||||
if (rosterSocket) parts.push('-L', shellArg(rosterSocket));
|
||||
|
||||
const peerHost = resolvedHost(peer, fleetHost);
|
||||
if (peerHost !== selfHost) {
|
||||
if (!peer.ssh) {
|
||||
throw new Error(
|
||||
`Cross-host peer "${peer.name}" (${peerHost}) requires an explicit roster ssh target; refusing to substitute its host value.`,
|
||||
);
|
||||
}
|
||||
parts.push('-H', shellArg(peer.ssh));
|
||||
}
|
||||
parts.push('-s', shellArg(peer.name), '-m', '"…"');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
||||
* the agent has no peers (a single-agent roster has no one to talk to).
|
||||
*/
|
||||
/** Resolve one requested peer without fuzzy lookup. */
|
||||
export function resolvePeerCommand(
|
||||
roster: FleetRoster,
|
||||
selfName: string,
|
||||
peerName: string,
|
||||
localHost: string,
|
||||
agentSendPath: string,
|
||||
): PeerCommandResult {
|
||||
const self = roster.agents.find((agent) => agent.name === selfName);
|
||||
if (!self) return { ok: false, command: '', error: missingMemberError(roster, selfName) };
|
||||
const peer = roster.agents.find((agent) => agent.name === peerName && agent.name !== selfName);
|
||||
if (!peer) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error:
|
||||
`Peer "${peerName}" is absent from the fleet roster. Known exact peer names: ${knownNames(roster, selfName)}. ` +
|
||||
`Run \`mosaic agent comms-block ${selfName}\` to rediscover exact rendered rows; do not infer or fuzzy-match a tmux session.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
resolvedHost(self, localHost),
|
||||
localHost,
|
||||
roster.tmux.socketName,
|
||||
agentSendPath,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedRow {
|
||||
readonly peer: FleetAgent;
|
||||
readonly host: string;
|
||||
readonly socket: string;
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
function resolveRows(opts: FleetCommsOptions, self: FleetAgent): readonly ResolvedRow[] {
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
return opts.roster.agents
|
||||
.filter((agent) => agent.name !== opts.selfName)
|
||||
.map(
|
||||
(peer): ResolvedRow => ({
|
||||
peer,
|
||||
host: resolvedHost(peer, opts.localHost),
|
||||
socket: opts.roster.tmux.socketName,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
selfHost,
|
||||
opts.localHost,
|
||||
opts.roster.tmux.socketName,
|
||||
opts.agentSendPath,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function commsGeneration(
|
||||
self: FleetAgent,
|
||||
selfHost: string,
|
||||
selfSocket: string,
|
||||
helper: string,
|
||||
rows: readonly ResolvedRow[],
|
||||
): string {
|
||||
const canonical = JSON.stringify({
|
||||
self: { ...self, resolvedHost: selfHost, resolvedSocket: selfSocket, helper },
|
||||
peers: rows.map((row) => ({
|
||||
...row.peer,
|
||||
resolvedHost: row.host,
|
||||
resolvedSocket: row.socket,
|
||||
exactCommand: row.command,
|
||||
})),
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
/** Build the authoritative Markdown contract for one exact roster member. */
|
||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
||||
if (peers.length === 0) return '';
|
||||
const self = opts.roster.agents.find((agent) => agent.name === opts.selfName);
|
||||
if (!self) throw new Error(missingMemberError(opts.roster, opts.selfName));
|
||||
const rows = resolveRows(opts, self);
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
const selfSocket = opts.roster.tmux.socketName;
|
||||
const generation = commsGeneration(self, selfHost, selfSocket, opts.agentSendPath, rows);
|
||||
const orchestrator = rows.find((row) => row.peer.className === 'orchestrator');
|
||||
const peerSection =
|
||||
rows.length === 0
|
||||
? 'This roster has no peers. Do not invent a target.'
|
||||
: `| Agent | Role | Host | Socket | Exact command |
|
||||
| ----- | ---- | ---- | ------ | ------------- |
|
||||
${rows
|
||||
.map((row) => {
|
||||
const pointOfContact = row.peer.className === 'orchestrator' ? ' ← point of contact' : '';
|
||||
return `| ${row.peer.name} | ${row.peer.className}${pointOfContact} | ${row.host} | ${displaySocket(row.socket)} | \`${row.command}\` |`;
|
||||
})
|
||||
.join('\n')}`;
|
||||
const contact = orchestrator
|
||||
? `Your point of contact is **${orchestrator.peer.name}**. Select that exact peer row for status, questions, and decisions.`
|
||||
: rows.length === 0
|
||||
? 'No peer coordination target exists in this roster.'
|
||||
: 'This roster has no orchestrator. Select an exact peer row for coordination.';
|
||||
const soloAuthority =
|
||||
rows.length === 0
|
||||
? `\n## Solo authority boundaries\n\nThis member is normalized as role/class **${self.className}**. The roster grants no peer, orchestrator, or remote communication authority. Do not send, infer a target, or claim fleet coordination until an exact peer is added to the canonical roster and this block is recomposed.\n`
|
||||
: '';
|
||||
|
||||
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
||||
const rows = peers
|
||||
.map((p) => {
|
||||
const where = isRemote(p, opts.fleetHost)
|
||||
? `${p.className} · host \`${p.host}\``
|
||||
: p.className;
|
||||
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
||||
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
||||
})
|
||||
.join('\n');
|
||||
return `# Fleet Comms — authoritative exact targets
|
||||
|
||||
const orchLine = orchestrator
|
||||
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
||||
`status, and decisions there.`
|
||||
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
||||
## Local identity
|
||||
|
||||
return `# Fleet Comms — reach your peers
|
||||
- Host: \`${selfHost}\`
|
||||
- Agent/session: \`${self.name}\`
|
||||
- Role/class: \`${self.className}\`
|
||||
- tmux socket: \`${displaySocket(selfSocket)}\`
|
||||
- Helper: \`${opts.agentSendPath}\`
|
||||
- Comms generation: \`${generation}\`
|
||||
|
||||
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
||||
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
||||
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
||||
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
||||
The roster-resolved rows below are the only valid operational targets. Select the row whose Agent value
|
||||
exactly matches the requested peer. Never invent, substitute, or fuzzy-match host, session, socket, SSH,
|
||||
or helper-path values. If the peer is absent, stop and run \`mosaic agent comms-block ${self.name}\` to
|
||||
rediscover this exact member's rows; if it is still absent, report the unknown peer.
|
||||
|
||||
## Peers
|
||||
|
||||
| Agent | Role | Reach (session = agent name) |
|
||||
| ----- | ---- | ---------------------------- |
|
||||
${rows}
|
||||
${peerSection}
|
||||
|
||||
${orchLine}
|
||||
${contact}
|
||||
${soloAuthority}
|
||||
## Context freshness
|
||||
|
||||
## Conventions
|
||||
This block is a snapshot; Mosaic does not rewrite an active agent's context. Compare its Comms generation
|
||||
with fresh output from \`mosaic agent comms-block ${self.name}\`. If they differ, report stale composed
|
||||
context and have an authorized operator relaunch only this exact roster member with
|
||||
\`mosaic fleet restart ${self.name}\`. Do not restart or mutate a session automatically.`;
|
||||
}
|
||||
|
||||
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
||||
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
||||
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return `helper is unavailable or unsafe: ${path} (${reason})`;
|
||||
}
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||
}
|
||||
|
||||
export function resolveFleetIdentity(
|
||||
mosaicHome: string,
|
||||
requestedName: string | undefined,
|
||||
localHost: string = shortHostname(),
|
||||
): FleetIdentityResult {
|
||||
if (!requestedName) return { ok: true };
|
||||
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||
|
||||
let rosterPath: string;
|
||||
try {
|
||||
rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `cannot inspect fleet roster.yaml: ${error instanceof Error ? error.message : String(error)}; refusing JSON fallback because fallback is allowed only when YAML is absent`,
|
||||
};
|
||||
}
|
||||
if (!existsSync(rosterPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `no fleet roster at ${join(mosaicHome, 'fleet', 'roster.yaml')} or ${join(mosaicHome, 'fleet', 'roster.json')}`,
|
||||
};
|
||||
}
|
||||
|
||||
let roster: FleetRoster;
|
||||
try {
|
||||
roster = parseFleetRosterV1(
|
||||
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid fleet roster at ${rosterPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
identity: {
|
||||
roster,
|
||||
member: getRosterAgent(roster, requestedName),
|
||||
requestedName,
|
||||
agentSendPath,
|
||||
localHost,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: missingMemberError(roster, requestedName) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Render Fleet Comms from one already-resolved canonical member identity. */
|
||||
export function buildResolvedFleetCommsBlock(identity: ResolvedFleetIdentity): string {
|
||||
return buildFleetCommsBlock({
|
||||
selfName: identity.member.name,
|
||||
roster: identity.roster,
|
||||
localHost: identity.localHost,
|
||||
agentSendPath: identity.agentSendPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the fleet roster from `mosaicHome` and build the comms block for
|
||||
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
||||
* there are no peers — onboarding is best-effort and never throws.
|
||||
* Read and resolve the installed roster for runtime composition. A requested
|
||||
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName)
|
||||
* is a quiet no-op.
|
||||
*/
|
||||
export function readFleetCommsBlock(
|
||||
mosaicHome: string,
|
||||
selfName: string | undefined,
|
||||
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
||||
): string {
|
||||
if (!selfName) return '';
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return '';
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
const agents = parseRosterAgents(text);
|
||||
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
||||
return buildFleetCommsBlock({
|
||||
selfName,
|
||||
agents,
|
||||
fleetHost,
|
||||
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
||||
export interface CommsBlockResult {
|
||||
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
||||
ok: boolean;
|
||||
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
||||
output: string;
|
||||
/** Operator-facing reason when !ok. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
||||
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
||||
* returns '' on any miss so composeContract can no-op silently during a launch —
|
||||
* this NEVER silently emits empty: an unknown role or missing roster yields
|
||||
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
||||
* rather than printing nothing. That makes it safe to preview any peer's view,
|
||||
* e.g. `mosaic fleet comms-block coder0-0`.
|
||||
*/
|
||||
export function resolveCommsBlock(
|
||||
mosaicHome: string,
|
||||
role: string | undefined,
|
||||
fleetHost?: string,
|
||||
localHost: string = shortHostname(),
|
||||
): CommsBlockResult {
|
||||
if (!role) {
|
||||
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
||||
}
|
||||
const block = fleetHost
|
||||
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
||||
: readFleetCommsBlock(mosaicHome, role);
|
||||
if (!block) {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost);
|
||||
if (!resolved.ok) return { ok: false, output: '', error: resolved.error };
|
||||
if (!resolved.identity) return { ok: true, output: '' };
|
||||
try {
|
||||
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: existsSync(rosterPath)
|
||||
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
||||
: `no fleet roster at ${rosterPath}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
return { ok: true, output: block };
|
||||
}
|
||||
|
||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||
/** Backing resolver for `mosaic agent comms-block <exact-member>`. */
|
||||
export function resolveCommsBlock(
|
||||
mosaicHome: string,
|
||||
exactMember: string | undefined,
|
||||
): CommsBlockResult {
|
||||
if (!exactMember) {
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: 'comms-block requires an exact <exact-member> argument',
|
||||
};
|
||||
}
|
||||
return readFleetCommsBlock(mosaicHome, exactMember);
|
||||
}
|
||||
|
||||
function expectedContractVersion(content: Buffer | string): boolean {
|
||||
return content.toString().includes(`<!-- ${FLEET_COMMS_TOOLS_CONTRACT} -->`);
|
||||
}
|
||||
|
||||
function boundedContractDigest(
|
||||
path: string,
|
||||
mosaicHome: string,
|
||||
): { digest?: string; versionOk: boolean } {
|
||||
try {
|
||||
const content = readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_TOOLS_CONTRACT_BYTES,
|
||||
}).content;
|
||||
return {
|
||||
digest: createHash('sha256').update(content).digest('hex'),
|
||||
versionOk: expectedContractVersion(content),
|
||||
};
|
||||
} catch {
|
||||
return { versionOk: false };
|
||||
}
|
||||
}
|
||||
|
||||
function replacementGuidance(): string {
|
||||
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
}
|
||||
|
||||
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||
export function renderToolsContractStatus(mosaicHome: string): string {
|
||||
const installedPath = join(mosaicHome, 'TOOLS.md');
|
||||
const sourcePath = join(mosaicHome, 'defaults', 'TOOLS.md');
|
||||
if (!existsSync(installedPath)) {
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is missing at \`${installedPath}\`. ${replacementGuidance()}`;
|
||||
}
|
||||
|
||||
const installed = boundedContractDigest(installedPath, mosaicHome);
|
||||
const source = boundedContractDigest(sourcePath, mosaicHome);
|
||||
if (!source.digest || !source.versionOk) {
|
||||
return `# Fleet Comms Installation Status\n\nThe bounded framework source contract at \`${sourcePath}\` is unavailable or does not declare the expected \`${FLEET_COMMS_TOOLS_CONTRACT}\` version. Run \`mosaic update\` to restore framework source data, verify again, then have an authorized operator explicitly relaunch the exact roster member. The installed file and active context were not rewritten.`;
|
||||
}
|
||||
if (installed.versionOk && installed.digest === source.digest) return '';
|
||||
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is unavailable, has the wrong contract version, or does not byte-match the bounded framework source contract \`${FLEET_COMMS_TOOLS_CONTRACT}\`. ${replacementGuidance()}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
||||
|
||||
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;
|
||||
host?: unknown;
|
||||
ssh?: unknown;
|
||||
socket?: 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;
|
||||
}>;
|
||||
connector?: {
|
||||
kind?: unknown;
|
||||
matrix?: {
|
||||
homeserver_url?: unknown;
|
||||
user_id?: unknown;
|
||||
room_id?: unknown;
|
||||
};
|
||||
discord?: {
|
||||
channel_id?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
/** Resolved host identity. Absent means the caller's authoritative local host. */
|
||||
host?: string;
|
||||
/** Explicit SSH destination for a cross-host inventory peer. */
|
||||
ssh?: string;
|
||||
/** Compatibility declaration; when set it must equal fleet-wide tmux.socketName. */
|
||||
socket?: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export type FleetConnector =
|
||||
| { kind: 'tmux' }
|
||||
| {
|
||||
kind: 'discord';
|
||||
discord: { channelId: string };
|
||||
}
|
||||
| {
|
||||
kind: 'matrix';
|
||||
matrix: { homeserverUrl: string; userId: string; roomId: string };
|
||||
};
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
connector?: FleetConnector;
|
||||
}
|
||||
|
||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||
|
||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
try {
|
||||
lstatSync(yamlPath);
|
||||
return yamlPath;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
return join(mosaicHome, 'fleet', 'roster.json');
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
/** One structural v1 resolver used by fleet commands and runtime comms composition. */
|
||||
export function parseFleetRosterV1(
|
||||
source: string,
|
||||
format: FleetRosterInputFormat = 'yaml',
|
||||
): FleetRoster {
|
||||
const trimmed = source.trim();
|
||||
const parsed =
|
||||
format === 'json'
|
||||
? (JSON.parse(trimmed) as RawFleetRoster)
|
||||
: (YAML.parse(trimmed) as RawFleetRoster);
|
||||
return normalizeFleetRosterV1(parsed);
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const source = await readFile(path, 'utf8');
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
'connector',
|
||||
]);
|
||||
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 socketName = targetingString(
|
||||
aliasValue(raw.tmux, 'socket_name', 'socketName', 'Fleet roster tmux socket'),
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
);
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
for (const agent of agents) {
|
||||
if (agent.socket !== undefined && agent.socket !== socketName) {
|
||||
throw new Error(
|
||||
`Fleet agent "${agent.name}" socket must equal the fleet-wide tmux socket_name; independent per-agent sockets are not supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
socketName,
|
||||
holderSession: stringValue(
|
||||
aliasValue(raw.tmux, 'holder_session', 'holderSession', 'Fleet roster tmux holder'),
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
aliasValue(
|
||||
raw.defaults,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'Fleet roster defaults working directory',
|
||||
),
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
connector: normalizeConnector(raw.connector as RawFleetRoster['connector']),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'host',
|
||||
'ssh',
|
||||
'socket',
|
||||
'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: canonicalizeRoleClass(
|
||||
stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
).canonicalClass,
|
||||
host: optionalTargetingString(
|
||||
raw.host,
|
||||
`Fleet roster agent "${name}" host`,
|
||||
/^[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
ssh: optionalTargetingString(
|
||||
raw.ssh,
|
||||
`Fleet roster agent "${name}" ssh`,
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
socket: optionalTargetingString(
|
||||
raw.socket,
|
||||
`Fleet roster agent "${name}" socket`,
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
),
|
||||
workingDirectory: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
`Fleet roster agent "${name}" working directory`,
|
||||
),
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
aliasValue(raw, 'model_hint', 'modelHint', `Fleet roster agent "${name}" model hint`),
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
`Fleet roster agent "${name}" reasoning level`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
aliasValue(raw, 'tool_policy', 'toolPolicy', `Fleet roster agent "${name}" tool policy`),
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
`Fleet roster agent "${name}" persistent persona`,
|
||||
),
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
`Fleet roster agent "${name}" reset between tasks`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
`Fleet roster agent "${name}" kickstart template`,
|
||||
),
|
||||
`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(
|
||||
aliasValue(
|
||||
config,
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
`Fleet roster runtime "${runtime}" reset command`,
|
||||
),
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeConnector(raw: RawFleetRoster['connector']): FleetConnector | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
assertObject(raw, 'Fleet roster connector');
|
||||
assertKnownKeys(raw, 'Fleet roster connector', ['kind', 'matrix', 'discord']);
|
||||
const kind = stringValue(raw.kind, '', 'Fleet roster connector kind');
|
||||
if (kind === 'tmux') {
|
||||
if (raw.matrix !== undefined || raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster tmux connector must not define matrix or discord settings.');
|
||||
}
|
||||
return { kind };
|
||||
}
|
||||
if (kind === 'discord') {
|
||||
if (raw.matrix !== undefined) {
|
||||
throw new Error('Fleet roster discord connector must not define matrix settings.');
|
||||
}
|
||||
assertObject(raw.discord, 'Fleet roster connector discord');
|
||||
assertKnownKeys(raw.discord, 'Fleet roster connector discord', ['channel_id']);
|
||||
return {
|
||||
kind,
|
||||
discord: {
|
||||
channelId: requiredString(
|
||||
raw.discord.channel_id,
|
||||
'Fleet roster connector discord channel_id',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (kind === 'matrix') {
|
||||
if (raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster matrix connector must not define discord settings.');
|
||||
}
|
||||
assertObject(raw.matrix, 'Fleet roster connector matrix');
|
||||
assertKnownKeys(raw.matrix, 'Fleet roster connector matrix', [
|
||||
'homeserver_url',
|
||||
'user_id',
|
||||
'room_id',
|
||||
]);
|
||||
return {
|
||||
kind,
|
||||
matrix: {
|
||||
homeserverUrl: requiredString(
|
||||
raw.matrix.homeserver_url,
|
||||
'Fleet roster connector matrix homeserver_url',
|
||||
),
|
||||
userId: requiredString(raw.matrix.user_id, 'Fleet roster connector matrix user_id'),
|
||||
roomId: requiredString(raw.matrix.room_id, 'Fleet roster connector matrix room_id'),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Fleet roster connector kind must be one of: tmux, discord, matrix.');
|
||||
}
|
||||
|
||||
function aliasValue<T extends Record<string, unknown>>(
|
||||
source: T | undefined,
|
||||
snake: keyof T,
|
||||
camel: keyof T,
|
||||
label: string,
|
||||
): unknown {
|
||||
const snakeValue = source?.[snake];
|
||||
const camelValue = source?.[camel];
|
||||
if (snakeValue !== undefined && camelValue !== undefined && snakeValue !== camelValue) {
|
||||
throw new Error(`${label} aliases ${String(snake)} and ${String(camel)} conflict.`);
|
||||
}
|
||||
return snakeValue ?? camelValue;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const resolved = stringValue(value, '', label).trim();
|
||||
if (!resolved) throw new Error(`${label} is required.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
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 targetingString(value: unknown, fallback: string, label: string, pattern: RegExp): string {
|
||||
const resolved = stringValue(value, fallback, label);
|
||||
if (resolved && !pattern.test(resolved)) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function optionalTargetingString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
pattern: RegExp,
|
||||
): string | undefined {
|
||||
const resolved = optionalString(value, label);
|
||||
if (resolved !== undefined && (!resolved || !pattern.test(resolved))) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import type * as NodeFs from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
}
|
||||
|
||||
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
lstatSync: (path: PathLike) => {
|
||||
const result = actual.lstatSync(path);
|
||||
filesystemRaceState.afterLstat?.(String(path));
|
||||
return result;
|
||||
},
|
||||
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||
const fd = actual.openSync(path, flags, mode);
|
||||
filesystemRaceState.afterOpen?.(String(path));
|
||||
return fd;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from './secure-file.js';
|
||||
|
||||
describe('secure file reads', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects canonical path escape', () => {
|
||||
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||
'path escapes managed root',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink in a file ancestor', () => {
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'external\n');
|
||||
symlinkSync(external, join(root, 'linked'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
||||
'path ancestor is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink target', () => {
|
||||
const external = join(root, 'external');
|
||||
writeFileSync(external, 'external\n');
|
||||
symlinkSync(external, join(root, 'linked-file'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
||||
'file is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||
const tools = join(root, 'tools');
|
||||
const displacedTools = join(root, 'tools.displaced');
|
||||
const external = join(root, 'external');
|
||||
const helper = join(tools, 'helper.sh');
|
||||
mkdirSync(tools);
|
||||
mkdirSync(external);
|
||||
writeFileSync(helper, 'trusted\n', { mode: 0o755 });
|
||||
writeFileSync(join(external, 'helper.sh'), 'external marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'tools') return;
|
||||
substituted = true;
|
||||
renameSync(tools, displacedTools);
|
||||
symlinkSync(external, tools);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted\n');
|
||||
});
|
||||
|
||||
it('keeps root selection bound when the opened root is substituted', () => {
|
||||
const displacedRoot = `${root}.displaced`;
|
||||
const externalRoot = `${root}.external`;
|
||||
const helper = join(root, 'helper.sh');
|
||||
mkdirSync(externalRoot);
|
||||
writeFileSync(helper, 'trusted root\n', { mode: 0o755 });
|
||||
writeFileSync(join(externalRoot, 'helper.sh'), 'external root marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
const match = openedPath.match(/\/([^/]+)$/);
|
||||
if (match?.[1] !== root.split('/').filter(Boolean).at(-1)) return;
|
||||
substituted = true;
|
||||
renameSync(root, displacedRoot);
|
||||
symlinkSync(externalRoot, root);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted root\n');
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root);
|
||||
renameSync(displacedRoot, root);
|
||||
});
|
||||
|
||||
it('keeps target read and execute validation bound to the opened file', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
const displaced = join(root, 'helper.displaced.sh');
|
||||
const external = join(root, 'external-helper.sh');
|
||||
writeFileSync(file, 'trusted target\n', { mode: 0o755 });
|
||||
writeFileSync(external, 'external target marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'helper.sh') return;
|
||||
substituted = true;
|
||||
renameSync(file, displaced);
|
||||
symlinkSync(external, file);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(file, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted target\n');
|
||||
});
|
||||
|
||||
it('uses a stable redacted executable error while retaining the error code', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
|
||||
try {
|
||||
readRegularFileSecure(file, { root, executable: true });
|
||||
throw new Error('expected executable validation to fail');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ message: 'managed file is not executable', code: 'EACCES' });
|
||||
expect(String(error)).not.toContain('/proc/self/fd/');
|
||||
expect(String(error)).not.toContain(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses effective-identity execute access after regular-file validation', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
expect(() => readRegularFileSecure(file, { root, executable: true })).toThrow();
|
||||
|
||||
chmodSync(file, 0o755);
|
||||
expect(readRegularFileSecure(file, { root, executable: true }).content.toString()).toBe(
|
||||
'#!/bin/sh\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
root: string;
|
||||
maxBytes?: number;
|
||||
executable?: boolean;
|
||||
}
|
||||
|
||||
export interface SecureFileSnapshot {
|
||||
content: Buffer;
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
left: { dev: number | bigint; ino: number | bigint },
|
||||
right: { dev: number | bigint; ino: number | bigint },
|
||||
): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function secureFilesystemError(message: string, cause: unknown): Error {
|
||||
const error = new Error(message);
|
||||
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
|
||||
Object.defineProperty(error, 'code', { value: cause.code, enumerable: true });
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function closeDescriptors(descriptors: number[]): void {
|
||||
for (const fd of descriptors.reverse()) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// Best-effort cleanup must not replace the security decision already made.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function procDescriptorPath(fd: number, component?: string): string {
|
||||
const descriptor = `/proc/self/fd/${fd}`;
|
||||
return component === undefined ? descriptor : `${descriptor}/${component}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold each directory while opening its child through Linux proc-fd. The only
|
||||
* symlink followed is the kernel-owned descriptor link; O_NOFOLLOW protects
|
||||
* every appended filesystem component from substitution.
|
||||
*/
|
||||
function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptors: number[] } {
|
||||
if (platform() !== 'linux') {
|
||||
throw new Error('secure descriptor traversal is unsupported on this platform');
|
||||
}
|
||||
|
||||
const descriptors: number[] = [];
|
||||
try {
|
||||
let fd = openSync(sep, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
descriptors.push(fd);
|
||||
for (const component of absoluteDirectory.split(sep).filter(Boolean)) {
|
||||
fd = openSync(
|
||||
procDescriptorPath(fd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
descriptors.push(fd);
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error('secure descriptor traversal encountered a non-directory component');
|
||||
}
|
||||
}
|
||||
return { fd, descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(descriptors);
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalTarget);
|
||||
const components = relative(canonicalRoot, canonicalTarget).split(sep).filter(Boolean);
|
||||
const fileName = components.pop();
|
||||
if (fileName === undefined) throw new Error('managed file path names the managed root');
|
||||
|
||||
const rootChain = openDirectoryChain(canonicalRoot);
|
||||
try {
|
||||
let parentFd = rootChain.fd;
|
||||
for (const component of components) {
|
||||
try {
|
||||
parentFd = openSync(
|
||||
procDescriptorPath(parentFd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'path ancestor is a symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
rootChain.descriptors.push(parentFd);
|
||||
if (!fstatSync(parentFd).isDirectory()) {
|
||||
throw new Error('path ancestor is a symbolic link or not a directory');
|
||||
}
|
||||
}
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(
|
||||
procDescriptorPath(parentFd, fileName),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||
}
|
||||
rootChain.descriptors.push(fd);
|
||||
return { fd, descriptors: rootChain.descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(rootChain.descriptors);
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('secure managed file open failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanonicalContainment(root: string, target: string): void {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
const rel = relative(canonicalRoot, canonicalTarget);
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new Error(`path escapes managed root ${canonicalRoot}: ${canonicalTarget}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject every symlink from the filesystem root through the target's parent. */
|
||||
export function assertNoSymlinkAncestors(target: string): void {
|
||||
const absolute = resolve(target);
|
||||
const parent = dirname(absolute);
|
||||
const pieces = parent.split(sep).filter(Boolean);
|
||||
let cursor: string = sep;
|
||||
for (const piece of pieces) {
|
||||
cursor = resolve(cursor, piece);
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureManagedDirectory(root: string, directory: string): void {
|
||||
assertCanonicalContainment(root, directory);
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalDirectory = resolve(directory);
|
||||
assertNoSymlinkAncestors(canonicalRoot);
|
||||
try {
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${canonicalRoot}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(canonicalRoot, { mode: 0o700 });
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root creation was redirected: ${canonicalRoot}`);
|
||||
}
|
||||
}
|
||||
const rel = relative(canonicalRoot, canonicalDirectory);
|
||||
let cursor = canonicalRoot;
|
||||
for (const piece of rel.split(sep).filter(Boolean)) {
|
||||
cursor = resolve(cursor, piece);
|
||||
try {
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(cursor, { mode: 0o700 });
|
||||
const created = lstatSync(cursor);
|
||||
if (!created.isDirectory() || created.isSymbolicLink()) {
|
||||
throw new Error(`managed directory creation was redirected: ${cursor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a regular file through an O_NOFOLLOW descriptor. The inode is checked
|
||||
* before and after access/read, and executable access is tested against the
|
||||
* already-open descriptor so path replacement cannot redirect the check.
|
||||
*/
|
||||
export function readRegularFileSecure(
|
||||
path: string,
|
||||
options: SecureFileReadOptions,
|
||||
): SecureFileSnapshot {
|
||||
const openedFile = openFileBeneathRoot(options.root, path);
|
||||
try {
|
||||
const opened = fstatSync(openedFile.fd);
|
||||
if (!opened.isFile()) throw new Error('managed file is not a regular file');
|
||||
if (options.maxBytes !== undefined && opened.size > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
if (options.executable) {
|
||||
try {
|
||||
accessSync(procDescriptorPath(openedFile.fd), constants.X_OK);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('managed file is not executable', error);
|
||||
}
|
||||
const afterAccess = fstatSync(openedFile.fd);
|
||||
if (!afterAccess.isFile() || !sameIdentity(opened, afterAccess)) {
|
||||
throw new Error('managed file changed during executable access check');
|
||||
}
|
||||
}
|
||||
|
||||
const content = readFileSync(openedFile.fd);
|
||||
const after = fstatSync(openedFile.fd);
|
||||
if (!after.isFile() || !sameIdentity(opened, after)) {
|
||||
throw new Error('managed file changed during secure read');
|
||||
}
|
||||
if (options.maxBytes !== undefined && content.byteLength > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
content,
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -11,8 +24,8 @@ import {
|
||||
readInstalledFrameworkVersion,
|
||||
readBundledFrameworkVersion,
|
||||
checkFrameworkDrift,
|
||||
repairFleetCommsTools,
|
||||
} from './update-checker.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
@@ -66,6 +79,7 @@ describe('readRosterAgentNames', () => {
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
@@ -77,6 +91,212 @@ describe('readRosterAgentNames', () => {
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||
});
|
||||
|
||||
it('extracts agent names from a JSON-only roster', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
|
||||
{ name: 'coder0', runtime: 'claude', class: 'worker' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairFleetCommsTools', () => {
|
||||
let root: string;
|
||||
let framework: string;
|
||||
let home: string;
|
||||
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const helperContent = '#!/bin/sh\nexit 0\n';
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
|
||||
framework = join(root, 'framework');
|
||||
home = join(root, 'home');
|
||||
mkdirSync(join(framework, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, helperContent);
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('restores a partially deleted current-version installation without package updates', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, changed: true });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const first = repairFleetCommsTools(framework, home);
|
||||
expect(first).toMatchObject({ ok: true, changed: true });
|
||||
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
|
||||
const second = repairFleetCommsTools(framework, home);
|
||||
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects an installed helper symlink without modifying its target', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
const target = join(root, 'external-helper');
|
||||
writeFileSync(target, 'do not touch\n');
|
||||
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a helper directory before replacing stale TOOLS content', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('not a regular file');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('refuses a pre-existing digest backup whose bytes do not match', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
|
||||
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('backup collision');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects a symlink in each installed destination ancestor without external writes', () => {
|
||||
const cases = [
|
||||
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
|
||||
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
|
||||
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const external = join(root, `external-${testCase.name}`);
|
||||
mkdirSync(external, { recursive: true });
|
||||
const targetHome =
|
||||
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
|
||||
if (testCase.name === 'home') {
|
||||
symlinkSync(external, targetHome);
|
||||
} else {
|
||||
mkdirSync(targetHome, { recursive: true });
|
||||
const linkPath = join(targetHome, testCase.suffix);
|
||||
mkdirSync(join(linkPath, '..'), { recursive: true });
|
||||
symlinkSync(external, linkPath);
|
||||
}
|
||||
|
||||
const result = repairFleetCommsTools(framework, targetHome);
|
||||
|
||||
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
const staleTools = '# user tools\n';
|
||||
const staleHelper = '#!/bin/sh\nexit 17\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
|
||||
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected helper commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
expect(result.reason).toContain('injected helper commit failure');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
|
||||
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
expect(
|
||||
readdirSync(home).some((name) => name.includes('.repair-')) ||
|
||||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back initially absent destinations and created directories on commit failure', () => {
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected absent helper failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('injected absent helper failure');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not persist a backup or replacement when backup commit fails', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'backup') throw new Error('injected backup commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
|
||||
const external = join(root, 'external-source');
|
||||
mkdirSync(join(external, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
|
||||
const linkedFramework = join(root, 'linked-framework');
|
||||
symlinkSync(external, linkedFramework);
|
||||
|
||||
const result = repairFleetCommsTools(linkedFramework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFrameworkReseed', () => {
|
||||
|
||||
@@ -15,16 +15,34 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
closeSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fchmodSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
assertCanonicalContainment,
|
||||
assertNoSymlinkAncestors,
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +72,10 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const NETWORK_TIMEOUT_MS = 5_000;
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||
@@ -500,6 +522,346 @@ export function buildReseedCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolsRepairResult {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolsRepairHooks {
|
||||
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||
}
|
||||
|
||||
function optionalSecureFile(
|
||||
path: string,
|
||||
root: string,
|
||||
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||
try {
|
||||
return readRegularFileSecure(path, { root });
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stageManagedFile(
|
||||
root: string,
|
||||
directory: string,
|
||||
target: string,
|
||||
content: Buffer,
|
||||
mode: number,
|
||||
): string {
|
||||
assertCanonicalContainment(root, target);
|
||||
ensureManagedDirectory(root, directory);
|
||||
assertNoSymlinkAncestors(target);
|
||||
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||
assertCanonicalContainment(root, staged);
|
||||
const fd = openSync(
|
||||
staged,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
fchmodSync(fd, mode);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
interface ManagedOriginal {
|
||||
path: string;
|
||||
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||
}
|
||||
|
||||
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||
if (!original.snapshot) {
|
||||
try {
|
||||
lstatSync(original.path);
|
||||
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = readRegularFileSecure(original.path, { root });
|
||||
if (
|
||||
current.dev !== original.snapshot.dev ||
|
||||
current.ino !== original.snapshot.ino ||
|
||||
current.mode !== original.snapshot.mode ||
|
||||
!current.content.equals(original.snapshot.content)
|
||||
) {
|
||||
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
lstatSync(target);
|
||||
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
linkSync(staged, target);
|
||||
unlinkSync(staged);
|
||||
}
|
||||
|
||||
function atomicInstall(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
const current = lstatSync(target);
|
||||
if (current.isSymbolicLink() || !current.isFile()) {
|
||||
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
renameSync(staged, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||
*/
|
||||
export function repairFleetCommsTools(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
hooks: ToolsRepairHooks = {},
|
||||
): ToolsRepairResult {
|
||||
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||
let stagedBackup: string | undefined;
|
||||
let stagedTools: string | undefined;
|
||||
let stagedHelper: string | undefined;
|
||||
let rollbackTools: string | undefined;
|
||||
let rollbackHelper: string | undefined;
|
||||
let committedBackup = false;
|
||||
let committedTools = false;
|
||||
let committedHelper = false;
|
||||
let createdHome = false;
|
||||
let createdToolsDirectory = false;
|
||||
let createdHelperDirectory = false;
|
||||
let backupPath: string | undefined;
|
||||
let toolsOriginal: ManagedOriginal | undefined;
|
||||
let helperOriginal: ManagedOriginal | undefined;
|
||||
try {
|
||||
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||
root: frameworkRoot,
|
||||
executable: true,
|
||||
});
|
||||
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||
}
|
||||
|
||||
assertCanonicalContainment(mosaicHome, installedTools);
|
||||
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||
assertNoSymlinkAncestors(mosaicHome);
|
||||
const homeExisted = existsSync(mosaicHome);
|
||||
const toolsDirectory = dirname(helperDirectory);
|
||||
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||
if (homeExisted) {
|
||||
const homeStat = lstatSync(mosaicHome);
|
||||
if (homeStat.isSymbolicLink()) {
|
||||
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||
}
|
||||
if (!homeStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||
}
|
||||
}
|
||||
|
||||
const installedToolsSnapshot = homeExisted
|
||||
? optionalSecureFile(installedTools, mosaicHome)
|
||||
: undefined;
|
||||
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||
let installedHelperExecutable = false;
|
||||
if (homeExisted) {
|
||||
try {
|
||||
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
});
|
||||
installedHelperExecutable = true;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||
if (isNodeErrorCode(error, 'EACCES')) {
|
||||
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||
const helperChanged =
|
||||
!installedHelperExecutable ||
|
||||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||
|
||||
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||
|
||||
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||
createdHome = !homeExisted;
|
||||
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||
createdToolsDirectory = !toolsDirectoryExisted;
|
||||
createdHelperDirectory = !helperDirectoryExisted;
|
||||
|
||||
if (toolsChanged) {
|
||||
stagedTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
sourceToolsSnapshot.content,
|
||||
sourceToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedToolsSnapshot) {
|
||||
const digest = createHash('sha256')
|
||||
.update(installedToolsSnapshot.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||
}
|
||||
if (!existingBackup) {
|
||||
stagedBackup = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
backupPath,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
rollbackTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (helperChanged) {
|
||||
stagedHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
sourceHelperSnapshot.content,
|
||||
sourceHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedHelperSnapshot) {
|
||||
rollbackHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
installedHelperSnapshot.content,
|
||||
installedHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
if (stagedBackup && backupPath) {
|
||||
hooks.beforeCommit?.('backup');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||
stagedBackup = undefined;
|
||||
committedBackup = true;
|
||||
}
|
||||
if (stagedTools) {
|
||||
hooks.beforeCommit?.('tools');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||
stagedTools = undefined;
|
||||
committedTools = true;
|
||||
}
|
||||
if (stagedHelper) {
|
||||
hooks.beforeCommit?.('helper');
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||
stagedHelper = undefined;
|
||||
committedHelper = true;
|
||||
}
|
||||
if (rollbackTools) unlinkSync(rollbackTools);
|
||||
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||
return { ok: true, changed: true, backupPath };
|
||||
} catch (error) {
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
if (committedHelper) {
|
||||
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||
else unlinkSync(installedHelper);
|
||||
rollbackHelper = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedTools) {
|
||||
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||
else unlinkSync(installedTools);
|
||||
rollbackTools = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedBackup && backupPath) {
|
||||
unlinkSync(backupPath);
|
||||
committedBackup = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||
if (!staged) continue;
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
failures.push(`staging cleanup failed: ${staged}`);
|
||||
}
|
||||
}
|
||||
for (const [created, directory] of [
|
||||
[createdHelperDirectory, helperDirectory],
|
||||
[createdToolsDirectory, dirname(helperDirectory)],
|
||||
[createdHome, mosaicHome],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch (cleanupError) {
|
||||
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||
failures.push(`directory cleanup failed: ${directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
changed: failures.length > 0,
|
||||
backupPath: committedBackup ? backupPath : undefined,
|
||||
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
@@ -591,25 +953,20 @@ export function checkFrameworkDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||
* considered only when roster.yaml is genuinely absent; all other failures
|
||||
* return no targets rather than guessing.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const source = readFileSync(rosterPath, 'utf8');
|
||||
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||
(agent) => agent.name,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user