chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
brainHomeIsActive,
|
||||
fleetAgentEnvDir,
|
||||
fleetProfilesDir,
|
||||
fleetRolesLocalDir,
|
||||
fleetStateDir,
|
||||
resolveBrainHome,
|
||||
type BrainHomeOptions,
|
||||
} from './brain-home.js';
|
||||
|
||||
describe('fleet brain-home resolution', (): void => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME'];
|
||||
|
||||
beforeEach((): void => {
|
||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
});
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (savedBrainEnv === undefined) {
|
||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
} else {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv;
|
||||
}
|
||||
if (cleanup !== undefined) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function makeTmp(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-'));
|
||||
cleanup = root;
|
||||
return root;
|
||||
}
|
||||
|
||||
it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain';
|
||||
expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain');
|
||||
expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents');
|
||||
expect(brainHomeIsActive('/any/mosaic-home')).toBe(true);
|
||||
});
|
||||
|
||||
it('injected envBrainHome wins identically (test seam)', (): void => {
|
||||
const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' };
|
||||
expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain');
|
||||
expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents');
|
||||
});
|
||||
|
||||
it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => {
|
||||
const mosaicHome = '/tmp/not-the-default-config-home';
|
||||
expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome);
|
||||
expect(brainHomeIsActive(mosaicHome)).toBe(false);
|
||||
expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents'));
|
||||
});
|
||||
|
||||
it('the default config home adopts the brain when it carries fleet/agents', async (): Promise<void> => {
|
||||
const root = await makeTmp();
|
||||
const brain = join(root, 'brain');
|
||||
await mkdir(join(brain, 'fleet', 'agents'), { recursive: true });
|
||||
const configHome = join(root, 'config', 'mosaic');
|
||||
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
|
||||
|
||||
expect(resolveBrainHome(configHome, opts)).toBe(brain);
|
||||
expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents'));
|
||||
expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local'));
|
||||
expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles'));
|
||||
expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet'));
|
||||
expect(brainHomeIsActive(configHome, opts)).toBe(true);
|
||||
});
|
||||
|
||||
it('the default config home stays legacy when no brain exists', async (): Promise<void> => {
|
||||
const root = await makeTmp();
|
||||
const configHome = join(root, 'config', 'mosaic');
|
||||
const opts: BrainHomeOptions = {
|
||||
homes: { brain: join(root, 'brain'), configDefault: configHome },
|
||||
};
|
||||
|
||||
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
|
||||
expect(brainHomeIsActive(configHome, opts)).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = ' ';
|
||||
expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home');
|
||||
});
|
||||
|
||||
it('adoption requires fleet/agents specifically, not any brain content', async (): Promise<void> => {
|
||||
const root = await makeTmp();
|
||||
const brain = join(root, 'brain');
|
||||
await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents
|
||||
const configHome = join(root, 'config', 'mosaic');
|
||||
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
|
||||
|
||||
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
|
||||
});
|
||||
|
||||
it('real-home control: a host brain is adopted only through the default home', (): void => {
|
||||
// Control on the un-injected path: this host carries ~/.mosaic/fleet/agents,
|
||||
// so the default config home resolves to the brain or legacy — both valid
|
||||
// canonical endpoints — while a non-default home never adopts.
|
||||
const defaultHome = join(homedir(), '.config', 'mosaic');
|
||||
const resolved = resolveBrainHome(defaultHome);
|
||||
expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved);
|
||||
expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe(
|
||||
join(homedir(), 'elsewhere', 'mosaic'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Overridable resolution inputs (tests inject tmp homes; production reads
|
||||
* the environment and the real home directory).
|
||||
*/
|
||||
export interface BrainHomeOptions {
|
||||
/** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */
|
||||
readonly envBrainHome?: string;
|
||||
/**
|
||||
* Canonical homes used for adoption. Defaults derive from the real
|
||||
* `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`.
|
||||
*/
|
||||
readonly homes?: { readonly brain: string; readonly configDefault: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Brain-home resolution — the three-tree fleet split (stack canon
|
||||
* `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain):
|
||||
*
|
||||
* config home (~/.config/mosaic) framework templates + dispatch state:
|
||||
* fleet/roles (baseline), fleet/roster.yaml,
|
||||
* fleet/run (heartbeats), fleet/services
|
||||
* brain home (~/.mosaic) user-owned fleet state, committed:
|
||||
* fleet/agents/<seat>.env.*, fleet/roles.local,
|
||||
* fleet/profiles working copies
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins)
|
||||
* 2. canonical `~/.mosaic` — adopted ONLY when mosaicHome is the real
|
||||
* default config home AND `~/.mosaic/fleet/agents` exists. Custom
|
||||
* `--mosaic-home` values (tests, sandboxes, canaries) never trigger
|
||||
* adoption, keeping them hermetic and deterministic.
|
||||
* 3. mosaicHome itself (legacy single-tree behavior).
|
||||
*/
|
||||
export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
||||
const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME'];
|
||||
if (explicit !== undefined && explicit.trim() !== '') {
|
||||
return explicit;
|
||||
}
|
||||
const homes = options.homes ?? {
|
||||
brain: join(homedir(), '.mosaic'),
|
||||
configDefault: join(homedir(), '.config', 'mosaic'),
|
||||
};
|
||||
if (resolve(mosaicHome) !== resolve(homes.configDefault)) {
|
||||
return mosaicHome;
|
||||
}
|
||||
return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome;
|
||||
}
|
||||
|
||||
/** True when fleet state resolves somewhere other than the config home. */
|
||||
export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean {
|
||||
return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome);
|
||||
}
|
||||
|
||||
/** Fleet state root (brain home when active, else the config home). */
|
||||
export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
||||
return join(resolveBrainHome(mosaicHome, options), 'fleet');
|
||||
}
|
||||
|
||||
/** Seat launch envs — `<brainHome>/fleet/agents` when a brain is active. */
|
||||
export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
||||
return join(fleetStateDir(mosaicHome, options), 'agents');
|
||||
}
|
||||
|
||||
/** PRESERVE-protected persona override layer — `<brainHome>/fleet/roles.local`. */
|
||||
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
||||
return join(fleetStateDir(mosaicHome, options), 'roles.local');
|
||||
}
|
||||
|
||||
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
|
||||
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
||||
return join(fleetStateDir(mosaicHome, options), 'profiles');
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
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 {
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
resolveCommsBlock,
|
||||
resolveFleetIdentity,
|
||||
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',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: 10.1.10.37',
|
||||
' ssh: [email protected]',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
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: '[email protected]',
|
||||
});
|
||||
});
|
||||
|
||||
// stack#1380 verification unblock: the fleet's own roster-v2 tooling writes
|
||||
// the v1 body plus a generation fence and seat lifecycle/launch envelopes.
|
||||
// The parser tolerates exactly that envelope (validated, opaque to comms).
|
||||
const V2_ROSTER = [
|
||||
'version: 2',
|
||||
'generation: 8',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'defaults:',
|
||||
' working_directory: ~/.mosaic',
|
||||
' runtime: claude',
|
||||
'agents:',
|
||||
' - name: orch-01',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' model: opus',
|
||||
' reasoning: high',
|
||||
' lifecycle:',
|
||||
' enabled: true',
|
||||
' desired_state: running',
|
||||
' launch:',
|
||||
' yolo: false',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('accepts the roster-v2 envelope (generation + lifecycle/launch) on the v1 body', () => {
|
||||
const resolved = parseFleetRosterV1(V2_ROSTER, 'yaml');
|
||||
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||
expect(resolved.agents[0]?.name).toBe('orch-01');
|
||||
});
|
||||
|
||||
it('rejects a non-integer generation', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(V2_ROSTER.replace('generation: 8', 'generation: eight'), 'yaml'),
|
||||
).toThrow(/generation must be a non-negative integer/);
|
||||
});
|
||||
|
||||
it('rejects an invalid lifecycle desired_state', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
V2_ROSTER.replace('desired_state: running', 'desired_state: paused'),
|
||||
'yaml',
|
||||
),
|
||||
).toThrow(/desired_state must be running\|stopped/);
|
||||
});
|
||||
|
||||
it('rejects unknown fields inside the lifecycle envelope', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
V2_ROSTER.replace(' enabled: true', ' enabled: true\n surprise: 1'),
|
||||
'yaml',
|
||||
),
|
||||
).toThrow(/lifecycle has unknown field/);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean launch.yolo', () => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(V2_ROSTER.replace('yolo: false', 'yolo: sometimes'), 'yaml'),
|
||||
).toThrow(/launch\.yolo must be a boolean/);
|
||||
});
|
||||
|
||||
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||
/unknown field/i,
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
),
|
||||
),
|
||||
).toThrow(/independent per-agent sockets are not supported/i);
|
||||
});
|
||||
|
||||
it('rejects unsafe operational targeting values', () => {
|
||||
expect(() =>
|
||||
roster(ROSTER.replace(' ssh: [email protected]', ' 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 — 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 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('uses only the explicit roster ssh target for a cross-host peer', () => {
|
||||
const peer: FleetAgent = {
|
||||
...base,
|
||||
name: 'coder0-0',
|
||||
host: '10.1.10.37',
|
||||
ssh: '[email protected]',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H [email protected] -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';
|
||||
|
||||
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: roster(),
|
||||
localHost: 'ignored-process-host',
|
||||
agentSendPath: send,
|
||||
});
|
||||
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
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*\|/);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -H [email protected] -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('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: [email protected]\n', ''));
|
||||
expect(() =>
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: bad,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).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('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 [email protected] -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(() => {
|
||||
// Hermetic helper fallback (stack#1380): the resolver probes
|
||||
// $HOME/.config/mosaic when mosaicHome itself carries no helper — point
|
||||
// HOME at a sandbox parent so tests never see the real host install.
|
||||
vi.stubEnv('HOME', mkdtempSync(join(tmpdir(), 'mosaic-homeless-')));
|
||||
vi.stubEnv('MOSAIC_HOME', '');
|
||||
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(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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('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 escaping the install home',
|
||||
() => {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
rmSync(helper);
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-helper-outside-'));
|
||||
writeFileSync(join(outside, 'real-send.sh'), '#!/bin/sh\n', { mode: 0o755 });
|
||||
symlinkSync(join(outside, 'real-send.sh'), helper);
|
||||
},
|
||||
],
|
||||
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||
])(
|
||||
'fails closed for a %s helper with deterministic guidance (no forbidden remedy)',
|
||||
(_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
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('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('names operator-verified recovery instead of a forbidden remedy when installed TOOLS.md is missing', () => {
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('authorized operator');
|
||||
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
expect(status).not.toContain('--reseed');
|
||||
});
|
||||
|
||||
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('authorized operator');
|
||||
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
|
||||
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('reads an installed TOOLS.md symlink whose validated target diverges (stack#1380 resolve-then-validate)', () => {
|
||||
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('does not byte-match');
|
||||
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||
});
|
||||
|
||||
it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-'));
|
||||
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(join(outside, 'external-source.md'), content);
|
||||
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(join(outside, 'external-source.md'), 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(join(outside, 'external-source.md'), '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 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 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', () => {
|
||||
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||
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 result = resolveCommsBlock(home, undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('requires');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFleetIdentity — stack#1380 split-home layouts', () => {
|
||||
// Brain-shaped mosaicHome (fleet state, NO tools/tmux) + framework config
|
||||
// home carrying the helper, roster unified by the framework-created symlink
|
||||
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. This is the
|
||||
// host layout that was down; all probes are POSITIONAL per the #1380
|
||||
// verification protocol (an object arg proves nothing — M5b). HOME is
|
||||
// stubbed so the config-default fallback stays inside the sandbox.
|
||||
let brain: string;
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'mosaic-i1380-parent-'));
|
||||
brain = join(parent, 'brain');
|
||||
// Framework home at the stubbed DEFAULT location so the fallback derives
|
||||
// exactly as in production ($HOME/.config/mosaic), not by coincidence.
|
||||
configHome = join(parent, 'home', '.config', 'mosaic');
|
||||
vi.stubEnv('HOME', join(parent, 'home'));
|
||||
vi.stubEnv('MOSAIC_HOME', '');
|
||||
mkdirSync(join(brain, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(brain, 'fleet', 'roster.yaml'), ROSTER, { mode: 0o600 });
|
||||
mkdirSync(join(configHome, 'fleet'), { recursive: true });
|
||||
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(configHome, 'fleet', 'roster.yaml'));
|
||||
mkdirSync(join(configHome, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(configHome, 'tools', 'tmux', 'agent-send.sh'), '#!/bin/sh\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(join(brain, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves a member through the roster symlink under the config home', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.identity?.member.name).toBe('orchestrator');
|
||||
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
|
||||
});
|
||||
|
||||
it('resolves a member when mosaicHome is the brain (helper found under the framework home)', () => {
|
||||
const result = resolveFleetIdentity(brain, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.identity?.member.name).toBe('enhancer');
|
||||
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
|
||||
});
|
||||
|
||||
it('no-name control stays a quiet no-op', () => {
|
||||
expect(resolveFleetIdentity(configHome, undefined, 'w-jarvis')).toEqual({ ok: true });
|
||||
expect(resolveFleetIdentity(brain, undefined, 'w-jarvis')).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('a nonce name fails naming membership, not the symlink or the helper', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'nonce-' + Date.now(), 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).not.toContain('symbolic link');
|
||||
expect(result.error).not.toContain('helper');
|
||||
expect(result.error).toContain('nonce-');
|
||||
});
|
||||
|
||||
it('a non-member failure names membership, not the symlink (protocol control)', () => {
|
||||
const result = resolveFleetIdentity(configHome, 'jarvis', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).not.toContain('symbolic link');
|
||||
expect(result.error).not.toContain('helper is unavailable');
|
||||
expect(result.error).toContain('orchestrator'); // known-member listing
|
||||
});
|
||||
|
||||
it('names every searched framework home when the helper is missing everywhere', () => {
|
||||
rmSync(join(configHome, 'tools'), { recursive: true, force: true });
|
||||
const result = resolveFleetIdentity(brain, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('agent-send.sh');
|
||||
expect(result.error).toContain(join(brain, 'tools', 'tmux', 'agent-send.sh'));
|
||||
expect(result.error).not.toContain('--repair-tools');
|
||||
});
|
||||
|
||||
it('readFleetCommsBlock composes the full contract on the split-home layout', () => {
|
||||
const result = readFleetCommsBlock(configHome, 'orchestrator', 'w-jarvis');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain(
|
||||
'Helper: `' + join(configHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Exact roster-resolved fleet communications contract (#766).
|
||||
*
|
||||
* 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 { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import { resolveBrainHome } from './brain-home.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
getRosterAgent,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from './fleet-roster-v1.js';
|
||||
|
||||
export interface FleetCommsOptions {
|
||||
/** Exact current roster member. */
|
||||
selfName: string;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export interface CommsBlockResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
/** 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 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`
|
||||
: '';
|
||||
|
||||
return `# Fleet Comms — authoritative exact targets
|
||||
|
||||
## Local identity
|
||||
|
||||
- Host: \`${selfHost}\`
|
||||
- Agent/session: \`${self.name}\`
|
||||
- Role/class: \`${self.className}\`
|
||||
- tmux socket: \`${displaySocket(selfSocket)}\`
|
||||
- Helper: \`${opts.agentSendPath}\`
|
||||
- Comms generation: \`${generation}\`
|
||||
|
||||
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
|
||||
|
||||
${peerSection}
|
||||
|
||||
${contact}
|
||||
${soloAuthority}
|
||||
## Context freshness
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
// The helper tree may live under the framework config home while this
|
||||
// caller's mosaicHome is the brain; both are framework-owned roots.
|
||||
symlinkTargetRoots: [resolveBrainHome(mosaicHome)],
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return `helper is unavailable or unsafe: ${path} (${reason})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework install homes probed for tools/tmux/agent-send.sh (stack#1380 M2).
|
||||
* The helper ships with the FRAMEWORK install, which on split-home layouts is
|
||||
* the config home — not the brain (~/.mosaic carries fleet state, no tools).
|
||||
*/
|
||||
function frameworkHelperHomes(mosaicHome: string): string[] {
|
||||
const homes = [resolve(mosaicHome)];
|
||||
const envHome = process.env['MOSAIC_HOME'];
|
||||
if (envHome && envHome.trim() !== '' && resolve(envHome) !== resolve(mosaicHome)) {
|
||||
homes.push(resolve(envHome));
|
||||
}
|
||||
const configDefault = join(homedir(), '.config', 'mosaic');
|
||||
if (resolve(configDefault) !== resolve(mosaicHome)) homes.push(configDefault);
|
||||
return homes;
|
||||
}
|
||||
|
||||
function resolveAgentSendHelper(mosaicHome: string): { path: string; error?: string } {
|
||||
const homes = frameworkHelperHomes(mosaicHome);
|
||||
for (const home of homes) {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
if (!existsSync(helper)) continue;
|
||||
const error = validateAgentSendHelper(helper, home);
|
||||
if (!error) return { path: helper };
|
||||
// Present but unsafe: surface that verdict instead of silently probing on.
|
||||
return { path: helper, error };
|
||||
}
|
||||
return {
|
||||
path: join(resolve(mosaicHome), 'tools', 'tmux', 'agent-send.sh'),
|
||||
error:
|
||||
`fleet helper agent-send.sh was not found under any framework install home ` +
|
||||
`(${homes.map((h) => join(h, 'tools', 'tmux', 'agent-send.sh')).join('; ')}). ` +
|
||||
`Verify the framework install for this host (the helper ships with the framework ` +
|
||||
`config home; the brain home carries fleet state, not tools) and have an authorized ` +
|
||||
`operator restore it if missing.`,
|
||||
};
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
// stack#1380 M5a: `mosaic update --repair-tools` is a forbidden remedy on the
|
||||
// affected estate (and wrong for a layout/missing-helper failure). Name the
|
||||
// actual recovery shape instead.
|
||||
return `${reason}. Verify the framework install provides tools/tmux/agent-send.sh under the framework config home and that the roster resolves (split-home layouts symlink the roster into the brain); contact the operator if it persists; 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 helper = resolveAgentSendHelper(mosaicHome);
|
||||
if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) };
|
||||
const agentSendPath = helper.path;
|
||||
|
||||
// Split-home layouts unify the roster by symlinking
|
||||
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. The secure
|
||||
// read resolves that framework-created symlink when the brain is a
|
||||
// sanctioned target root (stack#1380 M1).
|
||||
const rosterSymlinkRoots = [resolveBrainHome(mosaicHome)];
|
||||
|
||||
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,
|
||||
symlinkTargetRoots: rosterSymlinkRoots,
|
||||
}).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 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,
|
||||
localHost: string = shortHostname(),
|
||||
): CommsBlockResult {
|
||||
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: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
// stack#1380 M5a: never recommend the forbidden --repair-tools remedy from
|
||||
// error text; name the operator-verified recovery shape instead.
|
||||
return `Verify the installed TOOLS contract against the framework source with an authorized operator (the installed file must byte-match the supported current version) and have the 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');
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
MatrixConnector,
|
||||
buildMessageBody,
|
||||
parseSyncResponse,
|
||||
registerMatrixConnector,
|
||||
type FetchLike,
|
||||
} from './matrix.js';
|
||||
import { createConnector, _resetConnectorRegistry } from './registry.js';
|
||||
import type { MatrixConnectorConfig } from './types.js';
|
||||
|
||||
const CONFIG: MatrixConnectorConfig = {
|
||||
homeserverUrl: 'https://matrix.internal/',
|
||||
userId: '@mos:internal',
|
||||
roomId: '!room:internal',
|
||||
};
|
||||
|
||||
/** A fetch mock that returns queued responses and records calls. */
|
||||
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body?: unknown }>): {
|
||||
fetchImpl: FetchLike;
|
||||
calls: Array<{ url: string; method?: string; body?: string }>;
|
||||
} {
|
||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||
let i = 0;
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, method: init?.method, body: init?.body });
|
||||
const r = responses[Math.min(i, responses.length - 1)] ?? {};
|
||||
i += 1;
|
||||
return {
|
||||
ok: r.ok ?? true,
|
||||
status: r.status ?? 200,
|
||||
json: async () => r.body ?? {},
|
||||
text: async () => JSON.stringify(r.body ?? {}),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe('buildMessageBody', () => {
|
||||
it('builds an m.text event', () => {
|
||||
expect(buildMessageBody({ text: 'hi' })).toEqual({ msgtype: 'm.text', body: 'hi' });
|
||||
});
|
||||
it('adds an m.thread relation when threadId is set', () => {
|
||||
expect(buildMessageBody({ text: 'hi', threadId: '$evt' })).toEqual({
|
||||
msgtype: 'm.text',
|
||||
body: 'hi',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$evt' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSyncResponse', () => {
|
||||
it('extracts operator messages and skips the orchestrator’s own echoes', () => {
|
||||
const data = {
|
||||
next_batch: 's2',
|
||||
rooms: {
|
||||
join: {
|
||||
'!room:internal': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@jason:internal',
|
||||
origin_server_ts: 1_700_000_000_000,
|
||||
content: { body: 'status?' },
|
||||
},
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@mos:internal', // self — skipped
|
||||
origin_server_ts: 1_700_000_001_000,
|
||||
content: { body: 'working on it' },
|
||||
},
|
||||
{ type: 'm.reaction', sender: '@jason:internal', content: {} }, // non-message
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const msgs = parseSyncResponse(data, '!room:internal', '@mos:internal');
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]).toMatchObject({ text: 'status?', sender: '@jason:internal' });
|
||||
expect(msgs[0]!.ts).toBe(new Date(1_700_000_000_000).toISOString());
|
||||
});
|
||||
|
||||
it('carries threadId through thread-relments', () => {
|
||||
const data = {
|
||||
rooms: {
|
||||
join: {
|
||||
'!room:internal': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@jason:internal',
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
body: 'in thread',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseSyncResponse(data, '!room:internal', '@mos:internal')[0]!.threadId).toBe('$root');
|
||||
});
|
||||
|
||||
it('returns [] for an empty/foreign sync', () => {
|
||||
expect(parseSyncResponse({}, '!room:internal', '@mos:internal')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixConnector', () => {
|
||||
it('throws without an access token', () => {
|
||||
expect(() => new MatrixConnector(CONFIG, { accessToken: '' })).toThrow(/access token/i);
|
||||
});
|
||||
|
||||
it('send PUTs an m.text event and returns the event id', async () => {
|
||||
const { fetchImpl, calls } = mockFetch([{ body: { event_id: '$abc' } }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const res = await c.send({ text: 'pong' }, 1234);
|
||||
expect(res).toEqual({ delivered: true, messageId: '$abc' });
|
||||
expect(calls[0]!.method).toBe('PUT');
|
||||
expect(calls[0]!.url).toContain(
|
||||
'/_matrix/client/v3/rooms/!room%3Ainternal/send/m.room.message/mosaic-1234-1',
|
||||
);
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ msgtype: 'm.text', body: 'pong' });
|
||||
});
|
||||
|
||||
it('send reports not-delivered on a non-2xx', async () => {
|
||||
const { fetchImpl } = mockFetch([{ ok: false, status: 403 }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const res = await c.send({ text: 'x' });
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(res.error).toContain('403');
|
||||
});
|
||||
|
||||
it('health reports reachable + authenticated when whoami matches', async () => {
|
||||
const { fetchImpl } = mockFetch([
|
||||
{ body: { versions: ['v1.11'] } }, // /versions
|
||||
{ body: { user_id: '@mos:internal' } }, // /whoami
|
||||
]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(true);
|
||||
expect(h.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('health flags auth mismatch', async () => {
|
||||
const { fetchImpl } = mockFetch([
|
||||
{ body: {} },
|
||||
{ body: { user_id: '@someone-else:internal' } },
|
||||
]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(true);
|
||||
expect(h.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('health reports unreachable when /versions fails', async () => {
|
||||
const { fetchImpl } = mockFetch([{ ok: false, status: 502 }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerMatrixConnector', () => {
|
||||
beforeEach(() => _resetConnectorRegistry());
|
||||
|
||||
it('registers a matrix factory createConnector can build', () => {
|
||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
||||
const c = createConnector({ kind: 'matrix', matrix: CONFIG });
|
||||
expect(c.kind).toBe('matrix');
|
||||
});
|
||||
|
||||
it('the factory rejects config missing the matrix block', () => {
|
||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
||||
expect(() => createConnector({ kind: 'matrix' })).toThrow(/missing the .matrix. block/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Matrix connector (F4 Phase 2) — speaks the Matrix client-server API directly
|
||||
* over HTTPS so it is homeserver-agnostic (Conduit default, Synapse alt). No
|
||||
* SDK: a small injectable fetch keeps it dependency-light and unit-testable.
|
||||
*
|
||||
* The access token is supplied by the caller (from the environment —
|
||||
* MATRIX_ACCESS_TOKEN — per the gateway secret pattern), never the roster.
|
||||
*/
|
||||
|
||||
import {
|
||||
type OrchestratorConnector,
|
||||
type OutboundMessage,
|
||||
type InboundMessage,
|
||||
type SendResult,
|
||||
type ConnectorHealth,
|
||||
type MatrixConnectorConfig,
|
||||
type Unsubscribe,
|
||||
} from './types.js';
|
||||
import { registerConnector } from './registry.js';
|
||||
|
||||
/** Minimal fetch surface — avoids a lib.dom dependency and is trivial to mock. */
|
||||
export interface FetchLike {
|
||||
(
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MatrixConnectorOptions {
|
||||
accessToken: string;
|
||||
/** Injectable fetch (defaults to global fetch). */
|
||||
fetchImpl?: FetchLike;
|
||||
/** Long-poll timeout for /sync, ms. */
|
||||
syncTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Build the `m.room.message` event content, threading when a threadId is set. */
|
||||
export function buildMessageBody(message: OutboundMessage): Record<string, unknown> {
|
||||
const content: Record<string, unknown> = {
|
||||
msgtype: 'm.text',
|
||||
body: message.text,
|
||||
};
|
||||
if (message.threadId) {
|
||||
content['m.relates_to'] = { rel_type: 'm.thread', event_id: message.threadId };
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Shape of the bits of a /sync response we consume. */
|
||||
interface SyncResponse {
|
||||
next_batch?: string;
|
||||
rooms?: {
|
||||
join?: Record<
|
||||
string,
|
||||
{
|
||||
timeline?: {
|
||||
events?: Array<{
|
||||
type?: string;
|
||||
sender?: string;
|
||||
origin_server_ts?: number;
|
||||
content?: {
|
||||
body?: string;
|
||||
['m.relates_to']?: { rel_type?: string; event_id?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inbound operator messages from a /sync response for one room,
|
||||
* skipping the orchestrator's own echoes. Pure — the testable core of receive.
|
||||
*/
|
||||
export function parseSyncResponse(
|
||||
data: unknown,
|
||||
roomId: string,
|
||||
selfUserId: string,
|
||||
): InboundMessage[] {
|
||||
const sync = data as SyncResponse;
|
||||
const events = sync.rooms?.join?.[roomId]?.timeline?.events ?? [];
|
||||
const out: InboundMessage[] = [];
|
||||
for (const ev of events) {
|
||||
if (ev.type !== 'm.room.message') continue;
|
||||
if (!ev.sender || ev.sender === selfUserId) continue; // skip our own messages
|
||||
const body = ev.content?.body;
|
||||
if (typeof body !== 'string') continue;
|
||||
const rel = ev.content?.['m.relates_to'];
|
||||
out.push({
|
||||
text: body,
|
||||
sender: ev.sender,
|
||||
ts: new Date(ev.origin_server_ts ?? 0).toISOString(),
|
||||
...(rel?.rel_type === 'm.thread' && rel.event_id ? { threadId: rel.event_id } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class MatrixConnector implements OrchestratorConnector {
|
||||
readonly kind = 'matrix' as const;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly token: string;
|
||||
private readonly syncTimeoutMs: number;
|
||||
private txnCounter = 0;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: MatrixConnectorConfig,
|
||||
opts: MatrixConnectorOptions,
|
||||
) {
|
||||
this.token = opts.accessToken;
|
||||
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
||||
this.syncTimeoutMs = opts.syncTimeoutMs ?? 30_000;
|
||||
if (!this.token) {
|
||||
throw new Error('MatrixConnector requires an access token (set MATRIX_ACCESS_TOKEN).');
|
||||
}
|
||||
}
|
||||
|
||||
private url(path: string): string {
|
||||
return `${this.config.homeserverUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** Monotonic, unique-per-instance transaction id for idempotent sends. */
|
||||
private nextTxnId(nowMs: number): string {
|
||||
this.txnCounter += 1;
|
||||
return `mosaic-${nowMs}-${this.txnCounter}`;
|
||||
}
|
||||
|
||||
async send(message: OutboundMessage, nowMs = Date.now()): Promise<SendResult> {
|
||||
const txnId = this.nextTxnId(nowMs);
|
||||
const path = `/_matrix/client/v3/rooms/${encodeURIComponent(
|
||||
this.config.roomId,
|
||||
)}/send/m.room.message/${encodeURIComponent(txnId)}`;
|
||||
try {
|
||||
const res = await this.fetchImpl(this.url(path), {
|
||||
method: 'PUT',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(buildMessageBody(message)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { delivered: false, error: `Matrix send failed: HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { event_id?: string };
|
||||
return { delivered: true, ...(json.event_id ? { messageId: json.event_id } : {}) };
|
||||
} catch (err) {
|
||||
return { delivered: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe {
|
||||
this.stopped = false;
|
||||
let since: string | undefined;
|
||||
const loop = async (): Promise<void> => {
|
||||
while (!this.stopped) {
|
||||
try {
|
||||
const q = new URLSearchParams({ timeout: String(this.syncTimeoutMs) });
|
||||
if (since) q.set('since', since);
|
||||
const res = await this.fetchImpl(this.url(`/_matrix/client/v3/sync?${q.toString()}`), {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await this.backoff();
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
since = (data as SyncResponse).next_batch ?? since;
|
||||
for (const msg of parseSyncResponse(data, this.config.roomId, this.config.userId)) {
|
||||
handler(msg);
|
||||
}
|
||||
} catch {
|
||||
await this.backoff();
|
||||
}
|
||||
}
|
||||
};
|
||||
void loop();
|
||||
return () => {
|
||||
this.stopped = true;
|
||||
};
|
||||
}
|
||||
|
||||
private backoff(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
|
||||
async health(): Promise<ConnectorHealth> {
|
||||
try {
|
||||
const versions = await this.fetchImpl(this.url('/_matrix/client/versions'), {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!versions.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
authenticated: false,
|
||||
detail: `versions HTTP ${versions.status}`,
|
||||
};
|
||||
}
|
||||
const who = await this.fetchImpl(this.url('/_matrix/client/v3/account/whoami'), {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!who.ok) {
|
||||
return { reachable: true, authenticated: false, detail: `whoami HTTP ${who.status}` };
|
||||
}
|
||||
const json = (await who.json()) as { user_id?: string };
|
||||
const authenticated = json.user_id === this.config.userId;
|
||||
return {
|
||||
reachable: true,
|
||||
authenticated,
|
||||
lastSeen: new Date().toISOString(),
|
||||
...(authenticated
|
||||
? {}
|
||||
: { detail: `whoami user ${json.user_id} != ${this.config.userId}` }),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
reachable: false,
|
||||
authenticated: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Matrix connector factory. The token is read from the environment
|
||||
* (MATRIX_ACCESS_TOKEN) at build time, never the roster.
|
||||
*/
|
||||
export function registerMatrixConnector(env: NodeJS.ProcessEnv = process.env): void {
|
||||
registerConnector('matrix', (config) => {
|
||||
if (!config.matrix) {
|
||||
throw new Error('Matrix connector config missing the `matrix` block (homeserver/user/room).');
|
||||
}
|
||||
return new MatrixConnector(config.matrix, { accessToken: env['MATRIX_ACCESS_TOKEN'] ?? '' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
KNOWN_CONNECTOR_KINDS,
|
||||
isKnownConnectorKind,
|
||||
resolveConnectorKind,
|
||||
registerConnector,
|
||||
hasConnector,
|
||||
createConnector,
|
||||
ConnectorNotImplementedError,
|
||||
_resetConnectorRegistry,
|
||||
} from './registry.js';
|
||||
import type { ConnectorConfig, OrchestratorConnector } from './types.js';
|
||||
|
||||
function fakeConnector(kind: 'tmux' | 'discord' | 'matrix'): OrchestratorConnector {
|
||||
return {
|
||||
kind,
|
||||
send: async () => ({ delivered: true, messageId: 'x' }),
|
||||
subscribe: () => () => {},
|
||||
health: async () => ({ reachable: true, authenticated: true }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('connector registry (F4 Phase 1)', () => {
|
||||
beforeEach(() => {
|
||||
_resetConnectorRegistry();
|
||||
});
|
||||
|
||||
it('knows the three peer connector kinds', () => {
|
||||
expect(KNOWN_CONNECTOR_KINDS).toEqual(['tmux', 'discord', 'matrix']);
|
||||
});
|
||||
|
||||
it('isKnownConnectorKind guards correctly', () => {
|
||||
expect(isKnownConnectorKind('matrix')).toBe(true);
|
||||
expect(isKnownConnectorKind('irc')).toBe(false);
|
||||
expect(isKnownConnectorKind(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolveConnectorKind defaults to tmux when config is absent (back-compat)', () => {
|
||||
expect(resolveConnectorKind(undefined)).toBe('tmux');
|
||||
expect(resolveConnectorKind({ kind: 'matrix' })).toBe('matrix');
|
||||
});
|
||||
|
||||
it('createConnector throws ConnectorNotImplementedError for an unregistered kind', () => {
|
||||
const cfg: ConnectorConfig = { kind: 'matrix' };
|
||||
expect(() => createConnector(cfg)).toThrow(ConnectorNotImplementedError);
|
||||
expect(() => createConnector(cfg)).toThrow(/not implemented yet/i);
|
||||
});
|
||||
|
||||
it('createConnector with no config resolves the default kind (tmux) and reports it unimplemented in Phase 1', () => {
|
||||
try {
|
||||
createConnector();
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConnectorNotImplementedError);
|
||||
expect((err as ConnectorNotImplementedError).kind).toBe('tmux');
|
||||
}
|
||||
});
|
||||
|
||||
it('register → has → create resolves a registered factory', () => {
|
||||
expect(hasConnector('matrix')).toBe(false);
|
||||
registerConnector('matrix', (cfg) => fakeConnector(cfg.kind));
|
||||
expect(hasConnector('matrix')).toBe(true);
|
||||
|
||||
const connector = createConnector({ kind: 'matrix' });
|
||||
expect(connector.kind).toBe('matrix');
|
||||
});
|
||||
|
||||
it('passes the config through to the factory', () => {
|
||||
let received: ConnectorConfig | null = null;
|
||||
registerConnector('matrix', (cfg) => {
|
||||
received = cfg;
|
||||
return fakeConnector(cfg.kind);
|
||||
});
|
||||
const cfg: ConnectorConfig = {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.internal',
|
||||
userId: '@mos:internal',
|
||||
roomId: '!room:internal',
|
||||
},
|
||||
};
|
||||
createConnector(cfg);
|
||||
expect(received).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Connector registry (F4 Phase 1).
|
||||
*
|
||||
* A tiny extensible registry so connector implementations (Phase 2: tmux,
|
||||
* Discord, Matrix) register a factory by kind and fleet core resolves one from
|
||||
* roster config without branching on kind. Phase 1 ships the registry + the
|
||||
* config→kind resolution; the connector factories land in Phase 2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConnectorConfig,
|
||||
type ConnectorKind,
|
||||
type OrchestratorConnector,
|
||||
DEFAULT_CONNECTOR_KIND,
|
||||
} from './types.js';
|
||||
|
||||
/** The set of connector kinds the framework recognizes. */
|
||||
export const KNOWN_CONNECTOR_KINDS: readonly ConnectorKind[] = ['tmux', 'discord', 'matrix'];
|
||||
|
||||
/** Type guard: is `value` a known connector kind? */
|
||||
export function isKnownConnectorKind(value: unknown): value is ConnectorKind {
|
||||
return typeof value === 'string' && (KNOWN_CONNECTOR_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the connector kind from roster config. Absent config ⇒ the default
|
||||
* (tmux) so existing rosters keep working unchanged (back-compat).
|
||||
*/
|
||||
export function resolveConnectorKind(config?: ConnectorConfig): ConnectorKind {
|
||||
return config?.kind ?? DEFAULT_CONNECTOR_KIND;
|
||||
}
|
||||
|
||||
/** A factory builds a live connector from its validated config. */
|
||||
export type ConnectorFactory = (config: ConnectorConfig) => OrchestratorConnector;
|
||||
|
||||
/** Thrown when no factory is registered for a requested kind. */
|
||||
export class ConnectorNotImplementedError extends Error {
|
||||
constructor(public readonly kind: ConnectorKind) {
|
||||
super(
|
||||
`Connector "${kind}" is not implemented yet. ` +
|
||||
`Register a factory via registerConnector('${kind}', …) (F4 Phase 2).`,
|
||||
);
|
||||
this.name = 'ConnectorNotImplementedError';
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map<ConnectorKind, ConnectorFactory>();
|
||||
|
||||
/** Register a connector factory for a kind (idempotent — last registration wins). */
|
||||
export function registerConnector(kind: ConnectorKind, factory: ConnectorFactory): void {
|
||||
registry.set(kind, factory);
|
||||
}
|
||||
|
||||
/** True when a factory is registered for `kind`. */
|
||||
export function hasConnector(kind: ConnectorKind): boolean {
|
||||
return registry.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connector from roster config. Throws `ConnectorNotImplementedError`
|
||||
* when no factory is registered for the resolved kind (the Phase-1 default for
|
||||
* every kind until Phase 2 registers them).
|
||||
*/
|
||||
export function createConnector(config?: ConnectorConfig): OrchestratorConnector {
|
||||
const kind = resolveConnectorKind(config);
|
||||
const factory = registry.get(kind);
|
||||
if (!factory) {
|
||||
throw new ConnectorNotImplementedError(kind);
|
||||
}
|
||||
return factory(config ?? { kind });
|
||||
}
|
||||
|
||||
/** Test/runtime helper: drop all registrations. */
|
||||
export function _resetConnectorRegistry(): void {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Orchestrator chat connectors (F4).
|
||||
*
|
||||
* A connector mediates the chat channel between the fleet **orchestrator** and
|
||||
* its human operator. Connectors are PEERS — tmux (default), Discord, Matrix,
|
||||
* and future first-party plugins — selected per fleet, never hardwired. Fleet
|
||||
* core depends only on the small uniform interface below, so a new connector
|
||||
* drops in without touching the fleet.
|
||||
*
|
||||
* The interface is deliberately minimal: send (orchestrator → human),
|
||||
* subscribe (human → orchestrator), health (reachable/authed liveness). Thread
|
||||
* support is optional metadata (`threadId`) so thread-capable connectors
|
||||
* (Matrix rooms/threads, the future Mosaic Discord plugin) fit without an
|
||||
* interface change.
|
||||
*/
|
||||
|
||||
/** The connector kinds shipped/known to the framework. */
|
||||
export type ConnectorKind = 'tmux' | 'discord' | 'matrix';
|
||||
|
||||
/** A message the orchestrator sends out to the human operator. */
|
||||
export interface OutboundMessage {
|
||||
/** Message body (markdown where the connector supports it). */
|
||||
text: string;
|
||||
/** Optional thread/topic id for thread-capable connectors. */
|
||||
threadId?: string;
|
||||
/** Optional attachment references (paths or URLs); connector-dependent. */
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
/** A message received from the human operator. */
|
||||
export interface InboundMessage {
|
||||
/** Message body. */
|
||||
text: string;
|
||||
/** Thread/topic id if the connector carries one. */
|
||||
threadId?: string;
|
||||
/** Opaque sender identifier (connector-scoped). */
|
||||
sender: string;
|
||||
/** ISO-8601 timestamp the connector assigns/observes. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** Result of a send — the "ack" half of ack/health. */
|
||||
export interface SendResult {
|
||||
/** True when the connector accepted/delivered the message. */
|
||||
delivered: boolean;
|
||||
/** Connector-assigned message id when available. */
|
||||
messageId?: string;
|
||||
/** Reason when `delivered` is false. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Liveness of a connector — the "health" half of ack/health. */
|
||||
export interface ConnectorHealth {
|
||||
/** The transport endpoint is reachable. */
|
||||
reachable: boolean;
|
||||
/** Credentials are valid / the connector is authenticated. */
|
||||
authenticated: boolean;
|
||||
/** ISO-8601 of the last successful interaction, if any. */
|
||||
lastSeen?: string;
|
||||
/** Human-readable detail (e.g. failure reason). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Unsubscribe handle returned by `subscribe`. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* The uniform contract every orchestrator chat connector implements. Small by
|
||||
* design — send / subscribe / health — so connectors are interchangeable and
|
||||
* fleet core never branches on connector kind.
|
||||
*/
|
||||
export interface OrchestratorConnector {
|
||||
/** Which kind of connector this is. */
|
||||
readonly kind: ConnectorKind;
|
||||
/** Send a message from the orchestrator to the operator. */
|
||||
send(message: OutboundMessage): Promise<SendResult>;
|
||||
/** Subscribe to inbound operator messages; returns an unsubscribe handle. */
|
||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe;
|
||||
/** Report connector liveness (reachable + authenticated). */
|
||||
health(): Promise<ConnectorHealth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector configuration carried by the roster (the `connector` block).
|
||||
* Secrets (access tokens, bot tokens) are NEVER stored here — they come from
|
||||
* the environment (the gateway env-config pattern). Absent config ⇒ tmux.
|
||||
*/
|
||||
export interface ConnectorConfig {
|
||||
kind: ConnectorKind;
|
||||
/** Matrix connector settings (homeserver + room); token via env. */
|
||||
matrix?: MatrixConnectorConfig;
|
||||
/** Discord connector settings (channel); token via env. */
|
||||
discord?: DiscordConnectorConfig;
|
||||
}
|
||||
|
||||
export interface MatrixConnectorConfig {
|
||||
/** Local homeserver base URL, e.g. https://matrix.example.internal */
|
||||
homeserverUrl: string;
|
||||
/** Full Matrix user id of the orchestrator, e.g. @mos:example.internal */
|
||||
userId: string;
|
||||
/** Room id/alias the orchestrator converses in. */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface DiscordConnectorConfig {
|
||||
/** Channel id the orchestrator converses in. */
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
/** The default connector when a roster declares none (back-compat). */
|
||||
export const DEFAULT_CONNECTOR_KIND: ConnectorKind = 'tmux';
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */
|
||||
export function compareCodePoints(left: string, right: string): number {
|
||||
const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0);
|
||||
const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0);
|
||||
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
|
||||
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
const leftPoint = leftPoints[index];
|
||||
const rightPoint = rightPoints[index];
|
||||
if (leftPoint === undefined || rightPoint === undefined) continue;
|
||||
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
|
||||
}
|
||||
return leftPoints.length < rightPoints.length
|
||||
? -1
|
||||
: leftPoints.length > rightPoints.length
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
|
||||
validateShippedFleetArtifactDispositions,
|
||||
} from './example-profile-dispositions.js';
|
||||
|
||||
const frameworkFleet = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'framework',
|
||||
'fleet',
|
||||
);
|
||||
|
||||
const EXPECTED_DISPOSITIONS = [
|
||||
'examples/coding.yaml:v1-fixture',
|
||||
'examples/general.yaml:v1-fixture',
|
||||
'examples/hybrid.yaml:v1-fixture',
|
||||
'examples/local-canary.yaml:v1-fixture',
|
||||
'examples/minimal.yaml:v1-fixture',
|
||||
'examples/operator-interaction.yaml:v1-fixture',
|
||||
'examples/research.yaml:v1-fixture',
|
||||
'profiles/business.yaml:canonical-profile',
|
||||
'profiles/marketing.yaml:canonical-profile',
|
||||
'profiles/personal-assistant.yaml:canonical-profile',
|
||||
'profiles/research.yaml:canonical-profile',
|
||||
'profiles/software-delivery.yaml:canonical-profile',
|
||||
'services/operator-interaction.yaml:canonical-service-policy',
|
||||
];
|
||||
|
||||
const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(
|
||||
({ path, disposition }): string => `${path}:${disposition}`,
|
||||
);
|
||||
|
||||
describe('shipped fleet example/profile/service disposition validation', (): void => {
|
||||
it('enumerates every inventory artifact with an explicit executable disposition', (): void => {
|
||||
expect(declared).toEqual(EXPECTED_DISPOSITIONS);
|
||||
});
|
||||
|
||||
it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise<void> => {
|
||||
const results = await validateShippedFleetArtifactDispositions({ frameworkFleet });
|
||||
|
||||
expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual(
|
||||
EXPECTED_DISPOSITIONS,
|
||||
);
|
||||
expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength(
|
||||
7,
|
||||
);
|
||||
expect(
|
||||
results.filter(({ disposition }): boolean => disposition === 'canonical-profile'),
|
||||
).toHaveLength(5);
|
||||
expect(
|
||||
results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'),
|
||||
).toMatchObject({
|
||||
disposition: 'canonical-service-policy',
|
||||
});
|
||||
});
|
||||
|
||||
let temporaryFleet: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true });
|
||||
temporaryFleet = undefined;
|
||||
});
|
||||
|
||||
it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise<void> => {
|
||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
||||
await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n');
|
||||
|
||||
await expect(
|
||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
||||
).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i);
|
||||
});
|
||||
|
||||
it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise<void> => {
|
||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
||||
const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml');
|
||||
const fixture = await readFile(fixturePath, 'utf8');
|
||||
await writeFile(fixturePath, fixture.replace(/^version: 1\n/, ''));
|
||||
|
||||
await expect(
|
||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
||||
).rejects.toThrow(/Fleet roster version must be 1/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { loadFleetRoster } from '../commands/fleet.js';
|
||||
import { loadProfiles } from '../commands/fleet-profiles.js';
|
||||
import {
|
||||
provisionInteractionService,
|
||||
readInteractionServiceProfile,
|
||||
} from './interaction-service-profile.js';
|
||||
|
||||
export type FleetArtifactDisposition =
|
||||
| 'v1-fixture'
|
||||
| 'canonical-profile'
|
||||
| 'canonical-service-policy';
|
||||
|
||||
export interface ShippedFleetArtifactDisposition {
|
||||
readonly path: string;
|
||||
readonly disposition: FleetArtifactDisposition;
|
||||
}
|
||||
|
||||
export interface ValidateShippedFleetArtifactDispositionsOptions {
|
||||
readonly frameworkFleet: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The M0 inventory in executable form. Every shipped fleet YAML asset is either
|
||||
* a deliberately retained v1 fixture or validated through its canonical loader.
|
||||
*/
|
||||
export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [
|
||||
{ path: 'examples/coding.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/general.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/hybrid.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/local-canary.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/minimal.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/research.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'profiles/business.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/marketing.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/research.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fail closed when a fleet YAML asset is added or removed without a disposition.
|
||||
* This keeps legacy v1 compatibility explicit instead of silently accepting new
|
||||
* unresolved classes outside the shared resolver.
|
||||
*/
|
||||
export async function validateShippedFleetArtifactDispositions(
|
||||
options: ValidateShippedFleetArtifactDispositionsOptions,
|
||||
): Promise<readonly ShippedFleetArtifactDisposition[]> {
|
||||
await assertEveryShippedArtifactIsDeclared(options.frameworkFleet);
|
||||
|
||||
const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'v1-fixture',
|
||||
);
|
||||
for (const artifact of examples) {
|
||||
const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path));
|
||||
if (roster.version !== 1) {
|
||||
throw new Error(`v1 fixture ${artifact.path} must declare version: 1`);
|
||||
}
|
||||
}
|
||||
|
||||
const profilesDir = join(options.frameworkFleet, 'profiles');
|
||||
const profiles = await loadProfiles({
|
||||
profilesDir,
|
||||
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
|
||||
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
|
||||
});
|
||||
const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'canonical-profile',
|
||||
).map(({ path }): string => basename(path, '.yaml'));
|
||||
const resolvedProfiles = new Set(profiles.map(({ id }): string => id));
|
||||
for (const profileId of declaredProfiles) {
|
||||
if (!resolvedProfiles.has(profileId)) {
|
||||
throw new Error(`declared canonical profile ${profileId} did not resolve`);
|
||||
}
|
||||
}
|
||||
|
||||
const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'canonical-service-policy',
|
||||
);
|
||||
for (const artifact of servicePolicies) {
|
||||
const serviceProfile = await readInteractionServiceProfile(
|
||||
join(options.frameworkFleet, artifact.path),
|
||||
);
|
||||
provisionInteractionService(serviceProfile, { agentName: 'interaction-example' });
|
||||
}
|
||||
|
||||
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS;
|
||||
}
|
||||
|
||||
async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise<void> {
|
||||
const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path));
|
||||
const shipped = await listShippedFleetArtifactPaths(frameworkFleet);
|
||||
|
||||
for (const path of shipped) {
|
||||
if (!declared.has(path)) {
|
||||
throw new Error(`undeclared shipped fleet artifact: ${path}`);
|
||||
}
|
||||
}
|
||||
for (const path of declared) {
|
||||
if (!shipped.has(path)) {
|
||||
throw new Error(`declared shipped fleet artifact is missing: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise<Set<string>> {
|
||||
const directories = ['examples', 'profiles', 'services'];
|
||||
const paths = new Set<string>();
|
||||
for (const directory of directories) {
|
||||
const files = await readdir(join(frameworkFleet, directory));
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FleetAgentMutationError,
|
||||
executeFleetAgentMutation,
|
||||
planFleetAgentMutation,
|
||||
type FleetAgentMutationRequest,
|
||||
} from './fleet-agent-crud.js';
|
||||
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';
|
||||
|
||||
const roster = parseRosterV2(`
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
alias: Orchestrator
|
||||
class: orchestrator
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: orchestrator
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: true
|
||||
reset_between_tasks: false
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`);
|
||||
|
||||
function createRequest(): FleetAgentMutationRequest {
|
||||
return {
|
||||
operation: 'create',
|
||||
expectedGeneration: 7,
|
||||
agent: {
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
launch: { yolo: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
|
||||
const rolesDir = join(cleanup, 'roles');
|
||||
const overrideDir = join(cleanup, 'roles.local');
|
||||
await mkdir(rolesDir, { recursive: true });
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
for (const klass of ['orchestrator', 'code']) {
|
||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
|
||||
}
|
||||
return { rolesDir, overrideDir };
|
||||
}
|
||||
|
||||
describe('fleet agent CRUD plan', (): void => {
|
||||
it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
|
||||
const plan = planFleetAgentMutation(roster, createRequest());
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
operation: 'create',
|
||||
currentGeneration: 7,
|
||||
nextGeneration: 8,
|
||||
changed: true,
|
||||
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a stale generation before proposing effects', (): void => {
|
||||
expect((): void => {
|
||||
planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
|
||||
}).toThrow(FleetAgentMutationError);
|
||||
});
|
||||
|
||||
it('treats an equivalent retry as an idempotent no-op', (): void => {
|
||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
||||
const retried = planFleetAgentMutation(created, {
|
||||
...createRequest(),
|
||||
expectedGeneration: created.generation,
|
||||
});
|
||||
|
||||
expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet agent CRUD execution', (): void => {
|
||||
it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, 'before\n', { mode: 0o600 });
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
|
||||
await expect(
|
||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });
|
||||
|
||||
await expect(
|
||||
executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
|
||||
});
|
||||
|
||||
it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
|
||||
await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster: created,
|
||||
request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir,
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
|
||||
'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
projectionApplier: async (): Promise<void> => {
|
||||
throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('must-not-leak');
|
||||
expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { open, readFile, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
prepareAgentGeneratedProjectionDeletion,
|
||||
prepareAgentEnvironmentProjection,
|
||||
type PreparedAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from './generated-env-boundary.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
type FleetRosterV2Launch,
|
||||
type FleetRosterV2Lifecycle,
|
||||
type RosterV2ReasoningLevel,
|
||||
type RosterV2RuntimeName,
|
||||
} from './roster-v2.js';
|
||||
|
||||
export type FleetAgentMutationOperation = 'create' | 'update' | 'delete';
|
||||
|
||||
export interface FleetAgentMutationAgent {
|
||||
readonly name: string;
|
||||
readonly alias: string;
|
||||
readonly className: string;
|
||||
readonly runtime: RosterV2RuntimeName;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly reasoning: RosterV2ReasoningLevel;
|
||||
readonly toolPolicy: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly persistentPersona: boolean;
|
||||
readonly resetBetweenTasks: boolean;
|
||||
readonly launch: FleetRosterV2Launch;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationRequest {
|
||||
readonly operation: FleetAgentMutationOperation;
|
||||
readonly expectedGeneration: number;
|
||||
readonly name?: string;
|
||||
readonly agent?: FleetAgentMutationAgent;
|
||||
readonly persistedStart?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationPlan {
|
||||
readonly operation: FleetAgentMutationOperation;
|
||||
readonly currentGeneration: number;
|
||||
readonly nextGeneration: number;
|
||||
readonly changed: boolean;
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly agent?: FleetRosterV2Agent;
|
||||
}
|
||||
|
||||
export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed';
|
||||
export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||
|
||||
export interface FleetAgentMutationResult {
|
||||
/** True only when every requested roster and projection write completed. */
|
||||
readonly applied: boolean;
|
||||
/** Whether the authoritative roster changed on disk. */
|
||||
readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState;
|
||||
/** Whether derived projections were skipped, complete, or require recovery. */
|
||||
readonly projections: FleetAgentMutationProjectionState;
|
||||
readonly plan: FleetAgentMutationPlan;
|
||||
readonly recovery?: FleetAgentMutationRecovery;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationRecovery {
|
||||
readonly code: 'projection-apply-failed';
|
||||
readonly rosterPath: string;
|
||||
readonly action: 'regenerate-projections-from-roster';
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationOptions {
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly request: FleetAgentMutationRequest;
|
||||
readonly mosaicHome: string;
|
||||
readonly rosterPath: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly rolesDir: string;
|
||||
readonly overrideDir: string;
|
||||
readonly dryRun?: boolean;
|
||||
readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class FleetAgentMutationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'stale-generation'
|
||||
| 'agent-conflict'
|
||||
| 'agent-not-found'
|
||||
| 'invalid-request'
|
||||
| 'concurrent-mutation'
|
||||
| 'projection-apply-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetAgentMutationError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plans a generation-guarded desired-state mutation without writing any file. */
|
||||
export function planFleetAgentMutation(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (request.expectedGeneration !== roster.generation) {
|
||||
throw new FleetAgentMutationError(
|
||||
'stale-generation',
|
||||
`Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.operation === 'create') return planCreate(roster, request);
|
||||
if (request.operation === 'update') return planUpdate(roster, request);
|
||||
return planDelete(roster, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the complete proposed roster and its deterministic projections before
|
||||
* changing the roster authority. The only post-roster write is a derived projection.
|
||||
*/
|
||||
export async function executeFleetAgentMutation(
|
||||
options: FleetAgentMutationOptions,
|
||||
): Promise<FleetAgentMutationResult> {
|
||||
const plan = planFleetAgentMutation(options.roster, options.request);
|
||||
await validateRosterV2Semantics(plan.roster, {
|
||||
rolesDir: options.rolesDir,
|
||||
overrideDir: options.overrideDir,
|
||||
});
|
||||
const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir);
|
||||
if (plan.operation === 'delete' && plan.agent) {
|
||||
// Delete validates only the exact generated projection. Local overrides,
|
||||
// legacy input, and quarantine records are retained operator state.
|
||||
await prepareAgentGeneratedProjectionDeletion({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: plan.agent.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dryRun === true || !plan.changed) {
|
||||
return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan };
|
||||
}
|
||||
|
||||
const lockPath = `${options.rosterPath}.mutation.lock`;
|
||||
const release = await acquireMutationLock(lockPath);
|
||||
try {
|
||||
const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml');
|
||||
if (persisted.generation !== options.roster.generation) {
|
||||
throw new FleetAgentMutationError(
|
||||
'stale-generation',
|
||||
`Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await writeManagedFleetRoster(
|
||||
options.mosaicHome,
|
||||
options.rosterPath,
|
||||
renderRosterV2Yaml(plan.roster),
|
||||
);
|
||||
const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection;
|
||||
try {
|
||||
for (const projection of prepared) await apply(projection);
|
||||
if (plan.operation === 'delete' && plan.agent) {
|
||||
await prepareAgentGeneratedProjectionDeletion({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: plan.agent.name,
|
||||
});
|
||||
await removeGeneratedProjection(options.agentEnvDir, plan.agent.name);
|
||||
}
|
||||
} catch {
|
||||
throw new FleetAgentMutationError(
|
||||
'projection-apply-failed',
|
||||
`Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`,
|
||||
);
|
||||
}
|
||||
return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
rosterPath: options.rosterPath,
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function planCreate(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.agent)
|
||||
throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.');
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name,
|
||||
);
|
||||
const created = toRosterAgent(request.agent, request.persistedStart === true);
|
||||
if (existing) {
|
||||
if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing);
|
||||
throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`);
|
||||
}
|
||||
return changedPlan(roster, request.operation, [...roster.agents, created], created);
|
||||
}
|
||||
|
||||
function planUpdate(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.name || !request.agent) {
|
||||
throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.');
|
||||
}
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
||||
);
|
||||
if (!existing)
|
||||
throw new FleetAgentMutationError(
|
||||
'agent-not-found',
|
||||
`Agent "${request.name}" is not in roster.`,
|
||||
);
|
||||
if (request.agent.name !== request.name) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'Agent names are immutable during update.',
|
||||
);
|
||||
}
|
||||
const updated = {
|
||||
...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'),
|
||||
lifecycle: existing.lifecycle,
|
||||
};
|
||||
if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing);
|
||||
return changedPlan(
|
||||
roster,
|
||||
request.operation,
|
||||
roster.agents.map(
|
||||
(agent: FleetRosterV2Agent): FleetRosterV2Agent =>
|
||||
agent.name === request.name ? updated : agent,
|
||||
),
|
||||
updated,
|
||||
);
|
||||
}
|
||||
|
||||
function planDelete(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.');
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
||||
);
|
||||
if (!existing) return unchangedPlan(roster, request.operation);
|
||||
return changedPlan(
|
||||
roster,
|
||||
request.operation,
|
||||
roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name),
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
||||
function toRosterAgent(
|
||||
input: FleetAgentMutationAgent,
|
||||
persistedStart: boolean,
|
||||
): FleetRosterV2Agent {
|
||||
const lifecycle: FleetRosterV2Lifecycle = {
|
||||
enabled: true,
|
||||
desiredState: persistedStart ? 'running' : 'stopped',
|
||||
};
|
||||
return { ...input, lifecycle };
|
||||
}
|
||||
|
||||
function changedPlan(
|
||||
roster: FleetRosterV2,
|
||||
operation: FleetAgentMutationOperation,
|
||||
agents: readonly FleetRosterV2Agent[],
|
||||
agent?: FleetRosterV2Agent,
|
||||
): FleetAgentMutationPlan {
|
||||
const proposed = { ...roster, generation: roster.generation + 1, agents };
|
||||
const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml');
|
||||
return {
|
||||
operation,
|
||||
currentGeneration: roster.generation,
|
||||
nextGeneration: normalized.generation,
|
||||
changed: true,
|
||||
roster: normalized,
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unchangedPlan(
|
||||
roster: FleetRosterV2,
|
||||
operation: FleetAgentMutationOperation,
|
||||
agent?: FleetRosterV2Agent,
|
||||
): FleetAgentMutationPlan {
|
||||
return {
|
||||
operation,
|
||||
currentGeneration: roster.generation,
|
||||
nextGeneration: roster.generation,
|
||||
changed: false,
|
||||
roster,
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean {
|
||||
return (
|
||||
left.name === right.name &&
|
||||
left.alias === right.alias &&
|
||||
left.className === right.className &&
|
||||
left.runtime === right.runtime &&
|
||||
left.provider === right.provider &&
|
||||
left.model === right.model &&
|
||||
left.reasoning === right.reasoning &&
|
||||
left.toolPolicy === right.toolPolicy &&
|
||||
left.workingDirectory === right.workingDirectory &&
|
||||
left.persistentPersona === right.persistentPersona &&
|
||||
left.resetBetweenTasks === right.resetBetweenTasks &&
|
||||
left.launch.yolo === right.launch.yolo &&
|
||||
left.lifecycle.enabled === right.lifecycle.enabled &&
|
||||
left.lifecycle.desiredState === right.lifecycle.desiredState
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareProjections(
|
||||
roster: FleetRosterV2,
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
const projections: PreparedAgentEnvironmentProjection[] = [];
|
||||
for (const agent of roster.agents) {
|
||||
projections.push(
|
||||
await prepareAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generatedValues(roster, agent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return projections;
|
||||
}
|
||||
|
||||
function generatedValues(
|
||||
roster: FleetRosterV2,
|
||||
agent: FleetRosterV2Agent,
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_GIT_IDENTITY: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(agentEnvDir, `${agentName}.env.generated`));
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking acquire of the roster mutation lock: an exclusive `wx` create that
|
||||
* throws `concurrent-mutation` (never waits) if the lock is already held. The
|
||||
* on-disk format is an empty private file. CRUD releases in a plain `finally` with
|
||||
* no result-preservation logic, so a release fault here must not override the
|
||||
* mutation result — the release swallows unlink failures by design.
|
||||
*
|
||||
* The recovery-framed `fleet regen` contends on this exact same lock file, but it
|
||||
* does so through the hardened, ownership-proving acquirer in the reconciler
|
||||
* (`acquirePrivateRosterMutationLock`), not this one: whoever wins the `wx` create
|
||||
* owns the file (the loser always gets `concurrent-mutation`), so the reconciler's
|
||||
* ownership token is only ever written and read back by the same regen invocation,
|
||||
* never by this empty-file writer. The two acquirers stay mutually compatible at
|
||||
* the `wx`-contention layer while regen additionally proves ownership before it
|
||||
* unlinks — a guarantee CRUD does not need because it releases unconditionally.
|
||||
*/
|
||||
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx', 0o600);
|
||||
await handle.close();
|
||||
} catch {
|
||||
throw new FleetAgentMutationError(
|
||||
'concurrent-mutation',
|
||||
`Another roster mutation is in progress for ${dirname(lockPath)}.`,
|
||||
);
|
||||
}
|
||||
return async (): Promise<void> => {
|
||||
await unlink(lockPath).catch((): void => {});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { dirname, extname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseRosterV2, validateRosterV2Semantics } from './roster-v2.js';
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const repositoryRoot = resolve(packageRoot, '..', '..');
|
||||
const fleetDocs = join(repositoryRoot, 'docs', 'fleet');
|
||||
const frameworkFleet = join(packageRoot, 'framework', 'fleet');
|
||||
|
||||
const REQUIRED_FLEET_PAGES = [
|
||||
'README.md',
|
||||
'concepts/desired-vs-observed-state.md',
|
||||
'concepts/identity-class-runtime.md',
|
||||
'concepts/role-authority-and-leases.md',
|
||||
'concepts/generated-env-launch-chain.md',
|
||||
'reference/roster-v2.schema.json',
|
||||
'reference/roster-v2-fields.md',
|
||||
'reference/cli.md',
|
||||
'reference/role-classes.md',
|
||||
'reference/lifecycle-transitions.md',
|
||||
'reference/status-and-drift.md',
|
||||
'how-to/create-update-delete-agent.md',
|
||||
'how-to/start-stop-restart.md',
|
||||
'how-to/configure-tess-interaction.md',
|
||||
'how-to/configure-ultron-validator.md',
|
||||
'how-to/customize-roles.md',
|
||||
'operations/reconcile-and-recover.md',
|
||||
'operations/env-quarantine.md',
|
||||
'operations/systemd-tmux-troubleshooting.md',
|
||||
'operations/backup-restore.md',
|
||||
'operations/upgrade-assets.md',
|
||||
'migration/v1-to-v2.md',
|
||||
'migration/example-profile-disposition.md',
|
||||
'migration/legacy-class-aliases.md',
|
||||
] as const;
|
||||
|
||||
async function markdownFiles(root: string): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
const paths = await Promise.all(
|
||||
entries.map(async (entry): Promise<string[]> => {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) return markdownFiles(path);
|
||||
return extname(entry.name) === '.md' ? [path] : [];
|
||||
}),
|
||||
);
|
||||
return paths.flat().sort();
|
||||
}
|
||||
|
||||
function localMarkdownTargets(source: string): string[] {
|
||||
const link =
|
||||
/\[[^\]]*\]\(\s*(?:<([^>]+)>|((?:\\.|[^()\s]|\([^()]*\))+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
||||
return [...source.matchAll(link)]
|
||||
.map((match): string => match[1] ?? match[2] ?? '')
|
||||
.filter(
|
||||
(target): boolean =>
|
||||
target !== '' &&
|
||||
!target.startsWith('http://') &&
|
||||
!target.startsWith('https://') &&
|
||||
!target.startsWith('mailto:'),
|
||||
);
|
||||
}
|
||||
|
||||
function markdownHeadingAnchors(source: string): Set<string> {
|
||||
const anchors = new Set<string>();
|
||||
let fence: { readonly marker: string; readonly length: number } | undefined;
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (fence === undefined && fenceMatch !== null) {
|
||||
const run = fenceMatch[1] ?? '';
|
||||
fence = { marker: run[0] ?? '', length: run.length };
|
||||
continue;
|
||||
}
|
||||
if (fence !== undefined) {
|
||||
const closingRun = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)?.[1];
|
||||
if (
|
||||
closingRun !== undefined &&
|
||||
closingRun[0] === fence.marker &&
|
||||
closingRun.length >= fence.length
|
||||
) {
|
||||
fence = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)?.[1];
|
||||
if (heading === undefined) continue;
|
||||
const base = heading
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/[`*_~]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.replace(/\s+/g, '-');
|
||||
let anchor = base;
|
||||
let duplicate = 0;
|
||||
while (anchors.has(anchor)) {
|
||||
duplicate += 1;
|
||||
anchor = `${base}-${duplicate}`;
|
||||
}
|
||||
anchors.add(anchor);
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function markdownLinkViolations(
|
||||
sourcePath: string,
|
||||
source: string,
|
||||
documents: Readonly<Record<string, string>>,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const target of localMarkdownTargets(source)) {
|
||||
const [encodedPath = '', encodedFragment] = target.split('#', 2);
|
||||
const targetPath = decodeURIComponent(encodedPath);
|
||||
const normalizedTarget = resolve('/', dirname(sourcePath), targetPath).slice(1);
|
||||
const targetSource = documents[normalizedTarget];
|
||||
if (targetSource === undefined) {
|
||||
violations.push(`${sourcePath} -> ${target}: missing file`);
|
||||
continue;
|
||||
}
|
||||
if (encodedFragment !== undefined) {
|
||||
const fragment = decodeURIComponent(encodedFragment);
|
||||
if (fragment === '' || !markdownHeadingAnchors(targetSource).has(fragment)) {
|
||||
violations.push(`${sourcePath} -> ${target}: missing heading`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
type CodeSurfaceCategory = 'ConcreteCommand' | 'Synopsis' | 'DataProfile' | 'InlineLiteral';
|
||||
|
||||
interface CodeSurface {
|
||||
readonly category: CodeSurfaceCategory;
|
||||
readonly path: string;
|
||||
readonly line: number;
|
||||
readonly block?: number;
|
||||
readonly info?: string;
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
interface SurfaceDiagnostic {
|
||||
readonly path: string;
|
||||
readonly line: number;
|
||||
readonly block?: number;
|
||||
readonly category?: CodeSurfaceCategory;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
interface FenceProfile {
|
||||
readonly path: string;
|
||||
readonly block: number;
|
||||
readonly info: string;
|
||||
readonly category: Exclude<CodeSurfaceCategory, 'InlineLiteral'>;
|
||||
readonly recordSchemas?: readonly string[];
|
||||
}
|
||||
|
||||
const FENCE_PROFILES = [
|
||||
{
|
||||
path: 'FLEET-LAUNCH.md',
|
||||
block: 1,
|
||||
info: 'dotenv',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.DOTENV.FLEET_LAUNCH'],
|
||||
},
|
||||
{
|
||||
path: 'TASKS.md',
|
||||
block: 1,
|
||||
info: 'text-table',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.TEXT_TABLE.FLEET_TASKS'],
|
||||
},
|
||||
{
|
||||
path: 'backlog-conventions.md',
|
||||
block: 1,
|
||||
info: 'text-diagram',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.TEXT_DIAGRAM.BACKLOG_FLOW'],
|
||||
},
|
||||
{
|
||||
path: 'backlog-conventions.md',
|
||||
block: 2,
|
||||
info: 'fleet-command',
|
||||
category: 'ConcreteCommand',
|
||||
recordSchemas: [
|
||||
'CMD.BACKLOG.CREATE.1',
|
||||
'CMD.BACKLOG.CREATE.2',
|
||||
'CMD.BACKLOG.CLAIM',
|
||||
'CMD.BACKLOG.COMPLETE',
|
||||
'CMD.BACKLOG.LIST_READY',
|
||||
'CMD.BACKLOG.RECLAIM',
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'f4-matrix-connector.md',
|
||||
block: 1,
|
||||
info: 'typescript',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.TYPESCRIPT.MATRIX_CONNECTOR_TYPE'],
|
||||
},
|
||||
{
|
||||
path: 'f4-matrix-connector.md',
|
||||
block: 2,
|
||||
info: 'yaml',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.YAML.MATRIX_CONNECTOR_CONFIG'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/configure-tess-interaction.md',
|
||||
block: 1,
|
||||
info: 'yaml',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.YAML.INTERACTION_AGENT'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/configure-ultron-validator.md',
|
||||
block: 1,
|
||||
info: 'yaml',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.YAML.VALIDATOR_AGENT'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/create-update-delete-agent.md',
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
category: 'Synopsis',
|
||||
recordSchemas: ['SYN.AGENT.GET', 'SYN.PLAN.CREATE', 'SYN.PLAN.UPDATE', 'SYN.PLAN.DELETE'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/create-update-delete-agent.md',
|
||||
block: 2,
|
||||
info: 'fleet-command',
|
||||
category: 'ConcreteCommand',
|
||||
recordSchemas: ['CMD.AGENT.CREATE_JSON'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/create-update-delete-agent.md',
|
||||
block: 3,
|
||||
info: 'fleet-synopsis',
|
||||
category: 'Synopsis',
|
||||
recordSchemas: ['SYN.AGENT.UPDATE_COMPLETE', 'SYN.AGENT.DELETE'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/create-update-delete-agent.md',
|
||||
block: 4,
|
||||
info: 'json',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.JSON.PARTIAL_FAILURE'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/customize-roles.md',
|
||||
block: 1,
|
||||
info: 'markdown',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.MARKDOWN.ROLE_TEMPLATE'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/customize-roles.md',
|
||||
block: 2,
|
||||
info: 'markdown',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.MARKDOWN.ROLE_EXAMPLE'],
|
||||
},
|
||||
{
|
||||
path: 'how-to/start-stop-restart.md',
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
category: 'Synopsis',
|
||||
recordSchemas: [
|
||||
'SYN.APPLY.DRY',
|
||||
'SYN.APPLY',
|
||||
'SYN.RECONCILE',
|
||||
'SYN.START.REQUIRED',
|
||||
'SYN.STOP.REQUIRED',
|
||||
'SYN.RESTART.REQUIRED',
|
||||
'SYN.STATUS.OPTIONAL',
|
||||
'SYN.VERIFY',
|
||||
'SYN.DOCTOR',
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'migration/example-profile-disposition.md',
|
||||
block: 1,
|
||||
info: 'fleet-command',
|
||||
category: 'ConcreteCommand',
|
||||
recordSchemas: ['CMD.PNPM.MIGRATION_TEST'],
|
||||
},
|
||||
{
|
||||
path: 'migration/v1-to-v2.md',
|
||||
block: 1,
|
||||
info: 'fleet-command',
|
||||
category: 'ConcreteCommand',
|
||||
recordSchemas: ['CMD.MIGRATE.PREVIEW'],
|
||||
},
|
||||
{
|
||||
path: 'migration/v1-to-v2.md',
|
||||
block: 2,
|
||||
info: 'json',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.JSON.LIFECYCLE_OBSERVATIONS'],
|
||||
},
|
||||
{
|
||||
path: 'operations/reconcile-and-recover.md',
|
||||
block: 1,
|
||||
info: 'json',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.JSON.RECOVERY_RESULT'],
|
||||
},
|
||||
{
|
||||
path: 'reference/agent-mutations.md',
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
category: 'Synopsis',
|
||||
recordSchemas: [
|
||||
'SYN.AGENT.GET',
|
||||
'SYN.PLAN.GENERIC',
|
||||
'SYN.AGENT.CREATE',
|
||||
'SYN.AGENT.UPDATE',
|
||||
'SYN.AGENT.DELETE_DRY',
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'reference/agent-mutations.md',
|
||||
block: 2,
|
||||
info: 'json',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.JSON.MUTATION_RESULT'],
|
||||
},
|
||||
{
|
||||
path: 'reference/cli.md',
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
category: 'Synopsis',
|
||||
recordSchemas: [
|
||||
'SYN.APPLY',
|
||||
'SYN.RECONCILE',
|
||||
'SYN.START.OPTIONAL',
|
||||
'SYN.STOP.OPTIONAL',
|
||||
'SYN.RESTART.OPTIONAL',
|
||||
'SYN.STATUS.OPTIONAL',
|
||||
'SYN.VERIFY',
|
||||
'SYN.DOCTOR',
|
||||
'SYN.MIGRATE.PREVIEW',
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'reference/generated-env-boundary.md',
|
||||
block: 1,
|
||||
info: 'dotenv',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.DOTENV.GENERATED_ENV'],
|
||||
},
|
||||
{
|
||||
path: 'reference/roster-v2-fields.md',
|
||||
block: 1,
|
||||
info: 'yaml',
|
||||
category: 'DataProfile',
|
||||
recordSchemas: ['DATA.YAML.ROSTER_FIELDS'],
|
||||
},
|
||||
] as const satisfies readonly FenceProfile[];
|
||||
|
||||
const COMMAND_RECORDS: Readonly<Record<string, RegExp>> = {
|
||||
'CMD.BACKLOG.CREATE.1': /^mosaic fleet backlog create --id A1 --title "schema" --priority 5$/,
|
||||
'CMD.BACKLOG.CREATE.2':
|
||||
/^mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9$/,
|
||||
'CMD.BACKLOG.CLAIM': /^mosaic fleet backlog claim --owner worker-1 --ttl 600 --json$/,
|
||||
'CMD.BACKLOG.COMPLETE': /^mosaic fleet backlog complete --id A1$/,
|
||||
'CMD.BACKLOG.LIST_READY': /^mosaic fleet backlog list --ready-only --json$/,
|
||||
'CMD.BACKLOG.RECLAIM': /^mosaic fleet backlog reclaim --json$/,
|
||||
'CMD.AGENT.CREATE_JSON':
|
||||
/^mosaic fleet create --expected-generation [1-9][0-9]* --agent '\{\n(?:[ -~]*\n)*\}'$/,
|
||||
'CMD.MIGRATE.PREVIEW':
|
||||
/^mosaic fleet migrate-v1 preview \\\n --source [A-Za-z0-9_./@:+,=-]+ \\\n --decisions [A-Za-z0-9_./@:+,=-]+ \\\n --observations [A-Za-z0-9_./@:+,=-]+$/,
|
||||
'CMD.PNPM.MIGRATION_TEST':
|
||||
/^pnpm --filter @mosaicstack\/mosaic test -- v1-v2-migration\.spec\.ts \\\n -t "[A-Za-z0-9 _./@:+,=-]+"$/,
|
||||
};
|
||||
|
||||
const DATA_PROFILE_BODIES: Readonly<Record<string, string>> = {
|
||||
'DATA.DOTENV.FLEET_LAUNCH':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.TEXT_TABLE.FLEET_TASKS':
|
||||
'| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |',
|
||||
'DATA.TEXT_DIAGRAM.BACKLOG_FLOW':
|
||||
' create\n \u2502\n \u25bc\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u25ba ready \u2500\u2500\u2500\u2500\u2500 claim \u2500\u2500\u2500\u2500\u2500\u25ba claimed \u2500\u2500\u2500\u2500\u2500 complete \u2500\u2500\u2500\u2500\u2500\u25ba done\n \u2502 \u2502 \u2502\n \u2502 block reclaim (TTL expiry or --id)\n \u2502 \u25bc \u2502\n \u2502 blocked \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 (back to ready)\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 (reclaim / re-create can return a card to ready)',
|
||||
'DATA.TYPESCRIPT.MATRIX_CONNECTOR_TYPE':
|
||||
"interface OrchestratorConnector {\n readonly kind: 'tmux' | 'discord' | 'matrix';\n send(message: OutboundMessage): Promise<SendResult>; // orchestrator \u2192 human\n subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human \u2192 orchestrator\n health(): Promise<ConnectorHealth>; // reachable + authenticated\n}",
|
||||
'DATA.YAML.MATRIX_CONNECTOR_CONFIG':
|
||||
"connector:\n kind: matrix\n matrix:\n homeserver_url: https://matrix.example.internal\n user_id: '@mos:example.internal'\n room_id: '!abc:example.internal'",
|
||||
'DATA.YAML.INTERACTION_AGENT':
|
||||
'name: interaction-example\nalias: Interaction Example\nclass: interaction\ntool_policy: interaction\nlifecycle:\n enabled: true\n desired_state: stopped',
|
||||
'DATA.YAML.VALIDATOR_AGENT':
|
||||
'name: validator-example\nalias: Validator Example\nclass: validator\ntool_policy: validator\nlifecycle:\n enabled: true\n desired_state: stopped',
|
||||
'DATA.JSON.PARTIAL_FAILURE':
|
||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||
'DATA.MARKDOWN.ROLE_TEMPLATE':
|
||||
"# Code \u2014 local role definition\n\nThe local code role (`class: code`) follows the operator's repository conventions.",
|
||||
'DATA.MARKDOWN.ROLE_EXAMPLE':
|
||||
'# Release notes \u2014 local role definition\n\nThe release-notes role (`class: release-notes`) prepares operator-reviewed release copy.',
|
||||
'DATA.JSON.LIFECYCLE_OBSERVATIONS':
|
||||
'{\n "coder0": { "systemd": "inactive", "tmux": "missing" }\n}',
|
||||
'DATA.JSON.RECOVERY_RESULT':
|
||||
'{\n "applied": false,\n "authoritativeRoster": "unchanged",\n "projections": "incomplete",\n "lifecycle": "not-applied",\n "recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }\n}',
|
||||
'DATA.JSON.MUTATION_RESULT':
|
||||
'{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}',
|
||||
'DATA.DOTENV.GENERATED_ENV':
|
||||
'MOSAIC_AGENT_NAME=<roster name>\nMOSAIC_GIT_IDENTITY=<roster name>\nMOSAIC_AGENT_CLASS=<roster class>\nMOSAIC_AGENT_RUNTIME=<roster runtime>\nMOSAIC_AGENT_MODEL=<roster model hint>\nMOSAIC_AGENT_REASONING=<roster reasoning>\nMOSAIC_AGENT_TOOL_POLICY=<roster tool policy>\nMOSAIC_AGENT_WORKDIR=<absolute roster work directory>\nMOSAIC_TMUX_SOCKET=<roster socket or empty>',
|
||||
'DATA.YAML.ROSTER_FIELDS':
|
||||
'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true',
|
||||
};
|
||||
|
||||
const SYNOPSIS_RECORDS: Readonly<Record<string, string>> = {
|
||||
'SYN.AGENT.GET': 'mosaic fleet get <name>',
|
||||
'SYN.PLAN.CREATE': "mosaic fleet plan create --expected-generation <n> --agent '<json>'",
|
||||
'SYN.PLAN.UPDATE': "mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'",
|
||||
'SYN.PLAN.DELETE': 'mosaic fleet plan delete <name> --expected-generation <n>',
|
||||
'SYN.AGENT.UPDATE_COMPLETE':
|
||||
"mosaic fleet update <name> --expected-generation <n> --agent '<complete JSON agent payload>'",
|
||||
'SYN.AGENT.DELETE': 'mosaic fleet delete <name> --expected-generation <n>',
|
||||
'SYN.PLAN.GENERIC':
|
||||
"mosaic fleet plan <create|update|delete> [<name>] --expected-generation <n> [--agent '<json>'] [--persisted-start]",
|
||||
'SYN.AGENT.CREATE':
|
||||
"mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]",
|
||||
'SYN.AGENT.UPDATE':
|
||||
"mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]",
|
||||
'SYN.AGENT.DELETE_DRY': 'mosaic fleet delete <name> --expected-generation <n> [--dry-run]',
|
||||
'SYN.APPLY.DRY': 'mosaic fleet apply --expected-generation <n> --dry-run',
|
||||
'SYN.APPLY': 'mosaic fleet apply --expected-generation <n>',
|
||||
'SYN.RECONCILE': 'mosaic fleet reconcile --expected-generation <n>',
|
||||
'SYN.START.REQUIRED': 'mosaic fleet start <name> --expected-generation <n>',
|
||||
'SYN.STOP.REQUIRED': 'mosaic fleet stop <name> --expected-generation <n>',
|
||||
'SYN.RESTART.REQUIRED': 'mosaic fleet restart <name> --expected-generation <n>',
|
||||
'SYN.START.OPTIONAL': 'mosaic fleet start [<name>] --expected-generation <n> [--dry-run]',
|
||||
'SYN.STOP.OPTIONAL': 'mosaic fleet stop [<name>] --expected-generation <n> [--dry-run]',
|
||||
'SYN.RESTART.OPTIONAL': 'mosaic fleet restart [<name>] --expected-generation <n> [--dry-run]',
|
||||
'SYN.STATUS.OPTIONAL': 'mosaic fleet status [<name>]',
|
||||
'SYN.VERIFY': 'mosaic fleet verify',
|
||||
'SYN.DOCTOR': 'mosaic fleet doctor',
|
||||
'SYN.MIGRATE.PREVIEW':
|
||||
'mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>',
|
||||
};
|
||||
|
||||
function profileFor(path: string, block: number): FenceProfile | undefined {
|
||||
return FENCE_PROFILES.find(
|
||||
(profile): boolean => profile.path === path && profile.block === block,
|
||||
);
|
||||
}
|
||||
|
||||
function publicDiagnostic(
|
||||
surface: Pick<CodeSurface, 'path' | 'line' | 'block' | 'category'>,
|
||||
code: string,
|
||||
): SurfaceDiagnostic {
|
||||
return {
|
||||
path: surface.path,
|
||||
line: surface.line,
|
||||
...(surface.block === undefined ? {} : { block: surface.block }),
|
||||
...(surface.category === undefined ? {} : { category: surface.category }),
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
function codeSurfaceDiagnostics(path: string, source: string): SurfaceDiagnostic[] {
|
||||
const diagnostics: SurfaceDiagnostic[] = [];
|
||||
let inFence = false;
|
||||
let block = 0;
|
||||
|
||||
for (const [index, line] of source.split('\n').entries()) {
|
||||
const lineNumber = index + 1;
|
||||
if (inFence) {
|
||||
if (line === '```') inFence = false;
|
||||
else if (/^(?:`{3,}|~{3,})/.test(line)) {
|
||||
diagnostics.push({ path, line: lineNumber, block, code: 'fence-conflict' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/^(?: {0,3}(?:(?:> ?)|(?:(?:[-+*]|[0-9]{1,9}[.)]) +)))+(`{3,}|~{3,})/.test(line)) {
|
||||
diagnostics.push({ path, line: lineNumber, code: 'fence-context' });
|
||||
continue;
|
||||
}
|
||||
if (/^(?: {4,}|\t).*\S/.test(line)) {
|
||||
diagnostics.push({ path, line: lineNumber, code: 'indented-code' });
|
||||
continue;
|
||||
}
|
||||
const marker = line.match(/^(`{3,}|~{3,})(.*)$/);
|
||||
if (marker !== null) {
|
||||
block += 1;
|
||||
const info = marker[2] ?? '';
|
||||
if (marker[1] !== '```' || /[`~]/.test(info)) {
|
||||
diagnostics.push({ path, line: lineNumber, block, code: 'fence-marker' });
|
||||
continue;
|
||||
}
|
||||
if (info === '' || !FENCE_PROFILES.some((profile): boolean => profile.info === info)) {
|
||||
diagnostics.push({ path, line: lineNumber, block, code: 'fence-info' });
|
||||
continue;
|
||||
}
|
||||
const profile = profileFor(path, block);
|
||||
if (profile === undefined || profile.info !== info) {
|
||||
diagnostics.push({ path, line: lineNumber, block, code: 'profile-unknown' });
|
||||
continue;
|
||||
}
|
||||
inFence = true;
|
||||
continue;
|
||||
}
|
||||
if (/`{2,}/.test(line)) {
|
||||
diagnostics.push({ path, line: lineNumber, code: 'inline-delimiter' });
|
||||
continue;
|
||||
}
|
||||
const withoutClosedInlineSpans = line.replace(/`[^`\n]+`/g, '');
|
||||
if (withoutClosedInlineSpans.includes('`')) {
|
||||
diagnostics.push({ path, line: lineNumber, code: 'inline-delimiter' });
|
||||
continue;
|
||||
}
|
||||
if (/<\/?(?:pre|code|script|style|xmp|listing)(?=$|\s|[>/])/i.test(line)) {
|
||||
diagnostics.push({ path, line: lineNumber, code: 'raw-code-container' });
|
||||
}
|
||||
for (const match of line.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
|
||||
if (!/^[A-Za-z0-9_./@:+,=-]{1,256}$/.test(match[1] ?? '')) {
|
||||
diagnostics.push({
|
||||
path,
|
||||
line: lineNumber,
|
||||
category: 'InlineLiteral',
|
||||
code: 'inline-literal',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inFence)
|
||||
diagnostics.push({ path, line: source.split('\n').length, block, code: 'fence-unclosed' });
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function codeSurfaces(path: string, source: string): CodeSurface[] {
|
||||
const diagnostics = codeSurfaceDiagnostics(path, source);
|
||||
if (diagnostics.length > 0) return [];
|
||||
|
||||
const surfaces: CodeSurface[] = [];
|
||||
const lines = source.split('\n');
|
||||
let fence:
|
||||
| {
|
||||
readonly block: number;
|
||||
readonly line: number;
|
||||
readonly info: string;
|
||||
readonly body: string[];
|
||||
}
|
||||
| undefined;
|
||||
let block = 0;
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
if (fence !== undefined) {
|
||||
if (line === '```') {
|
||||
const profile = profileFor(path, fence.block);
|
||||
if (profile !== undefined && profile.info === fence.info) {
|
||||
surfaces.push({
|
||||
category: profile.category,
|
||||
path,
|
||||
line: fence.line,
|
||||
block: fence.block,
|
||||
info: fence.info,
|
||||
source: fence.body.join('\n'),
|
||||
});
|
||||
}
|
||||
fence = undefined;
|
||||
} else fence.body.push(line);
|
||||
continue;
|
||||
}
|
||||
const opener = line.match(/^```([a-z-]+)$/);
|
||||
if (opener !== null) {
|
||||
block += 1;
|
||||
fence = { block, line: lineNumber, info: opener[1] ?? '', body: [] };
|
||||
continue;
|
||||
}
|
||||
for (const match of line.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
|
||||
surfaces.push({
|
||||
category: 'InlineLiteral',
|
||||
path,
|
||||
line: lineNumber,
|
||||
source: match[1] ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
function closedGrammarViolationKinds(surface: CodeSurface): string[] {
|
||||
const kinds = new Set<string>();
|
||||
const credentialFormat =
|
||||
/(?:\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{35}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b|\bglpat-[A-Za-z0-9_-]{20,}\b|\bnpm_[A-Za-z0-9]{20,}\b|\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}\b|\bsk-proj-[A-Za-z0-9_-]{20,}\b|\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bxox[baprs]-[A-Za-z0-9-]{10,}\b|\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:]+:[^\s/@]+@)/;
|
||||
|
||||
if (credentialFormat.test(surface.source)) kinds.add('credential-format');
|
||||
if (/\b(?:Tess|Ultron)\b/.test(surface.source)) kinds.add('identity');
|
||||
|
||||
if (surface.category === 'InlineLiteral') {
|
||||
if (!/^[A-Za-z0-9_./@:+,=-]{1,256}$/.test(surface.source)) kinds.add('inline-literal');
|
||||
return [...kinds].sort();
|
||||
}
|
||||
|
||||
const profile = profileFor(surface.path, surface.block ?? 0);
|
||||
if (
|
||||
profile === undefined ||
|
||||
profile.info !== surface.info ||
|
||||
profile.category !== surface.category
|
||||
) {
|
||||
kinds.add('profile-unknown');
|
||||
return [...kinds].sort();
|
||||
}
|
||||
|
||||
if (surface.category === 'DataProfile') {
|
||||
const schema = profile.recordSchemas?.[0] ?? '';
|
||||
if (profile.recordSchemas?.length !== 1 || surface.source !== DATA_PROFILE_BODIES[schema]) {
|
||||
kinds.add('data-profile');
|
||||
}
|
||||
return [...kinds].sort();
|
||||
}
|
||||
if (/^(?:# |\$ |> )/m.test(surface.source)) kinds.add('comment-or-prompt');
|
||||
if (/(?:^|\s)[A-Za-z_][A-Za-z0-9_]*\+?=[^\s]*/.test(surface.source)) kinds.add('assignment');
|
||||
|
||||
const schemas = profile.recordSchemas ?? [];
|
||||
if (surface.category === 'Synopsis') {
|
||||
const records = surface.source.split('\n').filter((record): boolean => record !== '');
|
||||
if (
|
||||
records.length !== schemas.length ||
|
||||
records.some((record, index): boolean => record !== SYNOPSIS_RECORDS[schemas[index] ?? ''])
|
||||
) {
|
||||
kinds.add('synopsis-schema');
|
||||
}
|
||||
} else if (schemas.length === 1) {
|
||||
if (!COMMAND_RECORDS[schemas[0] ?? '']?.test(surface.source)) kinds.add('command-schema');
|
||||
} else {
|
||||
const records = surface.source.split('\n').filter((record): boolean => record !== '');
|
||||
if (
|
||||
records.length !== schemas.length ||
|
||||
records.some((record, index): boolean => !COMMAND_RECORDS[schemas[index] ?? '']?.test(record))
|
||||
) {
|
||||
kinds.add('command-schema');
|
||||
}
|
||||
}
|
||||
return [...kinds].sort();
|
||||
}
|
||||
|
||||
function surfaceDiagnostics(surfaces: readonly CodeSurface[]): SurfaceDiagnostic[] {
|
||||
return surfaces.flatMap((surface): SurfaceDiagnostic[] =>
|
||||
closedGrammarViolationKinds(surface).map(
|
||||
(kind): SurfaceDiagnostic => publicDiagnostic(surface, kind),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const CLOSED_GRAMMAR_REJECTION_FIXTURES = [
|
||||
'sudo systemctl restart example',
|
||||
'env -S apt-get install example',
|
||||
"sh -c 'apt-get --version'",
|
||||
'su root',
|
||||
'command -- apt-get install example',
|
||||
'mosaic fleet verify; reboot',
|
||||
'# mosaic fleet verify',
|
||||
'$ mosaic fleet verify',
|
||||
'FOO=value mosaic fleet verify',
|
||||
] as const;
|
||||
|
||||
describe('closed documentation publication grammar', (): void => {
|
||||
it('classifies only the four approved code-surface categories', (): void => {
|
||||
const categories: readonly CodeSurfaceCategory[] = [
|
||||
'ConcreteCommand',
|
||||
'Synopsis',
|
||||
'DataProfile',
|
||||
'InlineLiteral',
|
||||
];
|
||||
expect(new Set(categories)).toEqual(
|
||||
new Set(['ConcreteCommand', 'Synopsis', 'DataProfile', 'InlineLiteral']),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(CLOSED_GRAMMAR_REJECTION_FIXTURES)(
|
||||
'rejects shell-shaped input without parsing shell grammar',
|
||||
(source): void => {
|
||||
const surface: CodeSurface = {
|
||||
category: 'ConcreteCommand',
|
||||
path: 'migration/v1-to-v2.md',
|
||||
line: 1,
|
||||
block: 1,
|
||||
info: 'fleet-command',
|
||||
source,
|
||||
};
|
||||
expect(closedGrammarViolationKinds(surface)).toContain('command-schema');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects DSL comments, prompt prefixes, assignments, and root-prompt ambiguity', (): void => {
|
||||
for (const source of [
|
||||
'# mosaic fleet verify',
|
||||
'$ mosaic fleet verify',
|
||||
'> mosaic fleet verify',
|
||||
'ROOT=1 mosaic fleet verify',
|
||||
]) {
|
||||
const surface: CodeSurface = {
|
||||
category: 'Synopsis',
|
||||
path: 'reference/cli.md',
|
||||
line: 1,
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
source,
|
||||
};
|
||||
expect(closedGrammarViolationKinds(surface)).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unmatched single-backtick delimiters on either side', (): void => {
|
||||
for (const source of ['`literal', 'literal`', 'text `literal', 'literal` text']) {
|
||||
expect(codeSurfaceDiagnostics('fixture.md', source)).toContainEqual({
|
||||
path: 'fixture.md',
|
||||
line: 1,
|
||||
code: 'inline-delimiter',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('counts every invalid column-one fence candidate before later profile selection', (): void => {
|
||||
for (const firstCandidate of [
|
||||
'````fleet-synopsis',
|
||||
'```fleet-synopsis```',
|
||||
'~~~fleet-synopsis~~~',
|
||||
]) {
|
||||
expect(
|
||||
codeSurfaceDiagnostics(
|
||||
'reference/cli.md',
|
||||
`${firstCandidate}\n\`\`\`unknown\n\`\`\`fleet-synopsis\nmosaic fleet verify\n\`\`\``,
|
||||
),
|
||||
).toEqual([
|
||||
{ path: 'reference/cli.md', line: 1, block: 1, code: 'fence-marker' },
|
||||
{ path: 'reference/cli.md', line: 2, block: 2, code: 'fence-info' },
|
||||
{ path: 'reference/cli.md', line: 3, block: 3, code: 'profile-unknown' },
|
||||
{ path: 'reference/cli.md', line: 5, block: 4, code: 'fence-info' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects inline delimiter runs instead of silently omitting them', (): void => {
|
||||
for (const source of ['``literal``', 'text ```literal```', 'text ``literal`` text']) {
|
||||
expect(codeSurfaceDiagnostics('fixture.md', source)).toContainEqual({
|
||||
path: 'fixture.md',
|
||||
line: 1,
|
||||
code: 'inline-delimiter',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects every nonblank line with four leading spaces or a leading tab', (): void => {
|
||||
for (const source of [
|
||||
' mosaic fleet verify',
|
||||
' mosaic fleet verify',
|
||||
' \tmosaic fleet verify',
|
||||
'\t mosaic fleet verify',
|
||||
]) {
|
||||
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
|
||||
{ path: 'fixture.md', line: 1, code: 'indented-code' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects raw HTML code-container tag prefixes at every boundary', (): void => {
|
||||
for (const source of [
|
||||
'<pre',
|
||||
'<pre ',
|
||||
'<pre>',
|
||||
'<pre/',
|
||||
'<code',
|
||||
'<code ',
|
||||
'<code>',
|
||||
'<code/',
|
||||
]) {
|
||||
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
|
||||
{ path: 'fixture.md', line: 1, code: 'raw-code-container' },
|
||||
]);
|
||||
}
|
||||
expect(codeSurfaceDiagnostics('fixture.md', '<prelude>prose</prelude>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects nested blockquote and list fence contexts', (): void => {
|
||||
for (const source of [
|
||||
'>> ```fleet-command',
|
||||
'> > ```fleet-command',
|
||||
'> - ```fleet-command',
|
||||
'- > ```fleet-command',
|
||||
]) {
|
||||
expect(codeSurfaceDiagnostics('fixture.md', source)).toEqual([
|
||||
{ path: 'fixture.md', line: 1, code: 'fence-context' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires optional metavariables to use bracketed angle notation', (): void => {
|
||||
const accepted: CodeSurface = {
|
||||
category: 'Synopsis',
|
||||
path: 'reference/cli.md',
|
||||
line: 1,
|
||||
block: 1,
|
||||
info: 'fleet-synopsis',
|
||||
source: Object.values(SYNOPSIS_RECORDS)
|
||||
.filter((record): boolean =>
|
||||
[
|
||||
'SYN.APPLY',
|
||||
'SYN.RECONCILE',
|
||||
'SYN.START.OPTIONAL',
|
||||
'SYN.STOP.OPTIONAL',
|
||||
'SYN.RESTART.OPTIONAL',
|
||||
'SYN.STATUS.OPTIONAL',
|
||||
'SYN.VERIFY',
|
||||
'SYN.DOCTOR',
|
||||
'SYN.MIGRATE.PREVIEW',
|
||||
].includes(
|
||||
Object.entries(SYNOPSIS_RECORDS).find(([, value]): boolean => value === record)?.[0] ??
|
||||
'',
|
||||
),
|
||||
)
|
||||
.join('\n'),
|
||||
};
|
||||
expect(closedGrammarViolationKinds(accepted)).toEqual([]);
|
||||
expect(
|
||||
closedGrammarViolationKinds({
|
||||
...accepted,
|
||||
source: accepted.source.replace('[<name>]', '[name]'),
|
||||
}),
|
||||
).toContain('synopsis-schema');
|
||||
});
|
||||
|
||||
it('emits only location, category, and closed diagnostic codes', (): void => {
|
||||
const sensitiveFixture = ['sk-ant-api03-', 'a'.repeat(80)].join('');
|
||||
const diagnostic = surfaceDiagnostics([
|
||||
{
|
||||
category: 'InlineLiteral',
|
||||
path: 'fixture.md',
|
||||
line: 7,
|
||||
source: sensitiveFixture,
|
||||
},
|
||||
]);
|
||||
expect(diagnostic).toEqual([
|
||||
{
|
||||
path: 'fixture.md',
|
||||
line: 7,
|
||||
category: 'InlineLiteral',
|
||||
code: 'credential-format',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(diagnostic)).not.toContain(sensitiveFixture);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet operator documentation', (): void => {
|
||||
it('ships every accepted information-architecture page', async (): Promise<void> => {
|
||||
await expect(
|
||||
Promise.all(REQUIRED_FLEET_PAGES.map((path) => readFile(join(fleetDocs, path), 'utf8'))),
|
||||
).resolves.toHaveLength(REQUIRED_FLEET_PAGES.length);
|
||||
});
|
||||
|
||||
it('resolves every local Markdown link and heading fragment in the fleet book and sitemap', async (): Promise<void> => {
|
||||
const files = [...(await markdownFiles(fleetDocs)), join(repositoryRoot, 'docs', 'SITEMAP.md')];
|
||||
const documents: Record<string, string> = {};
|
||||
for (const file of files) {
|
||||
const relative = file.slice(repositoryRoot.length + 1);
|
||||
documents[relative] = await readFile(file, 'utf8');
|
||||
}
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const [sourcePath, source] of Object.entries(documents)) {
|
||||
for (const target of localMarkdownTargets(source)) {
|
||||
const encodedPath = target.split('#', 1)[0] ?? '';
|
||||
const targetPath = resolve(
|
||||
dirname(join(repositoryRoot, sourcePath)),
|
||||
decodeURIComponent(encodedPath),
|
||||
);
|
||||
const relativeTarget = targetPath.slice(repositoryRoot.length + 1);
|
||||
if (documents[relativeTarget] === undefined) {
|
||||
try {
|
||||
documents[relativeTarget] = await readFile(targetPath, 'utf8');
|
||||
} catch {
|
||||
// The deterministic validator below records the missing target without exposing content.
|
||||
}
|
||||
}
|
||||
}
|
||||
violations.push(...markdownLinkViolations(sourcePath, source, documents));
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('validates the canonical documentation example through the production compiler and resolver', async (): Promise<void> => {
|
||||
const source = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||
const roster = parseRosterV2(source, 'yaml');
|
||||
const validated = await validateRosterV2Semantics(roster, {
|
||||
rolesDir: join(frameworkFleet, 'roles'),
|
||||
overrideDir: join(fleetDocs, 'examples', 'roles.local'),
|
||||
});
|
||||
|
||||
expect(validated.generation).toBe(1);
|
||||
expect(validated.agents.map((agent) => agent.canonicalClass)).toEqual([
|
||||
'code',
|
||||
'interaction',
|
||||
'validator',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every rendered fleet code surface in one closed category without exposing sensitive values', async (): Promise<void> => {
|
||||
const diagnostics: SurfaceDiagnostic[] = [];
|
||||
const surfaces: CodeSurface[] = [];
|
||||
for (const file of await markdownFiles(fleetDocs)) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
const relative = file.slice(fleetDocs.length + 1);
|
||||
diagnostics.push(...codeSurfaceDiagnostics(relative, source));
|
||||
surfaces.push(...codeSurfaces(relative, source));
|
||||
}
|
||||
diagnostics.push(...surfaceDiagnostics(surfaces));
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(
|
||||
surfaces.filter((surface): boolean => surface.category === 'ConcreteCommand'),
|
||||
).toHaveLength(4);
|
||||
expect(surfaces.filter((surface): boolean => surface.category === 'Synopsis')).toHaveLength(5);
|
||||
expect(surfaces.filter((surface): boolean => surface.category === 'DataProfile')).toHaveLength(
|
||||
15,
|
||||
);
|
||||
expect(
|
||||
surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'),
|
||||
).toHaveLength(882);
|
||||
expect(surfaces).toHaveLength(906);
|
||||
|
||||
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||
const auxiliary: CodeSurface = {
|
||||
category: 'DataProfile',
|
||||
path: 'examples/roster-v2.yaml',
|
||||
line: 1,
|
||||
source: rosterSource,
|
||||
};
|
||||
expect(
|
||||
closedGrammarViolationKinds(auxiliary).filter((kind): boolean => kind !== 'profile-unknown'),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommandResult,
|
||||
type FleetReconcileDeps,
|
||||
type FleetReconcileResult,
|
||||
} from './fleet-reconciler.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
} from './roster-v2.js';
|
||||
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
|
||||
|
||||
const holderIdentity = '11111111-1111-4111-8111-111111111111';
|
||||
const stoppedAgent: FleetRosterV2Agent = {
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
};
|
||||
|
||||
const baseRoster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [stoppedAgent],
|
||||
};
|
||||
|
||||
interface InjectedLifecycleFailure {
|
||||
readonly action: 'start' | 'stop' | 'restart';
|
||||
readonly service: string;
|
||||
readonly diagnostic: string;
|
||||
}
|
||||
|
||||
class FakeLifecycleHost {
|
||||
readonly calls: string[][] = [];
|
||||
readonly sessions = new Set<string>();
|
||||
readonly activeServices = new Set<string>();
|
||||
private failure: InjectedLifecycleFailure | undefined;
|
||||
|
||||
constructor(readonly roster: FleetRosterV2) {
|
||||
this.sessions.add(roster.tmux.holderSession);
|
||||
}
|
||||
|
||||
injectFailure(failure: InjectedLifecycleFailure): void {
|
||||
this.failure = failure;
|
||||
}
|
||||
|
||||
readonly run = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetReconcileCommandResult> => {
|
||||
this.calls.push([command, ...args]);
|
||||
if (command === 'tmux') return this.runTmux(args);
|
||||
if (command === 'systemctl') return this.runSystemctl(args);
|
||||
return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 };
|
||||
};
|
||||
|
||||
private runTmux(args: readonly string[]): FleetReconcileCommandResult {
|
||||
if (args.includes('list-sessions')) {
|
||||
return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (args.includes('has-session')) {
|
||||
const targetArgument = args[args.indexOf('-t') + 1];
|
||||
const sessionName = targetArgument?.replace(/^=/, '').split(':')[0];
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1,
|
||||
};
|
||||
}
|
||||
if (args.includes('show-environment')) {
|
||||
return {
|
||||
stdout: [
|
||||
'HOME=/home/mosaic',
|
||||
`MOSAIC_FLEET_OWNER=${holderIdentity}`,
|
||||
`MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`,
|
||||
`MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
'PWD=/home/mosaic',
|
||||
'',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 };
|
||||
}
|
||||
|
||||
private runSystemctl(args: readonly string[]): FleetReconcileCommandResult {
|
||||
const action = args[1];
|
||||
const service = args[2];
|
||||
if (action === 'show' && service !== undefined) {
|
||||
return {
|
||||
stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (
|
||||
(action === 'start' || action === 'stop' || action === 'restart') &&
|
||||
service !== undefined
|
||||
) {
|
||||
this.applyLifecycleEffect(action, service);
|
||||
if (this.failure?.action === action && this.failure.service === service) {
|
||||
const diagnostic = this.failure.diagnostic;
|
||||
this.failure = undefined;
|
||||
return { stdout: '', stderr: diagnostic, exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 };
|
||||
}
|
||||
|
||||
private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void {
|
||||
if (service === 'mosaic-tmux-holder.service') return;
|
||||
const match = /^mosaic-agent@(.+)\.service$/.exec(service);
|
||||
if (!match) return;
|
||||
const agentName = match[1];
|
||||
if (agentName === undefined) return;
|
||||
if (action === 'stop') {
|
||||
this.activeServices.delete(service);
|
||||
this.sessions.delete(agentName);
|
||||
return;
|
||||
}
|
||||
this.activeServices.add(service);
|
||||
this.sessions.add(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupDirectories: string[] = [];
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
await Promise.all(
|
||||
cleanupDirectories.splice(0).map(async (directory: string): Promise<void> => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
|
||||
return {
|
||||
runner: host.run,
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => holderIdentity,
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => host.roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
// Hermetic broker observation (#1297 F3): without this, the plan probes
|
||||
// the REAL host filesystem, so the "stable JSON" fixtures answered true
|
||||
// on any machine with a live lease broker and false elsewhere. Pointing
|
||||
// both paths at fixtures that do not exist pins socketPresent:false and
|
||||
// unitInstalled:false on every host, which is what these fixtures assert.
|
||||
brokerSocketEnv: {
|
||||
MOSAIC_LEASE_BROKER_SOCKET: '/nonexistent/mosaic-lease/broker.sock',
|
||||
XDG_CONFIG_HOME: '/nonexistent/mosaic-config',
|
||||
XDG_RUNTIME_DIR: '/nonexistent/run',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function execute(
|
||||
host: FakeLifecycleHost,
|
||||
command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop',
|
||||
agentName?: string,
|
||||
): Promise<FleetReconcileResult> {
|
||||
return executeFleetReconcile({
|
||||
roster: host.roster,
|
||||
command,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }),
|
||||
deps: reconcileDeps(host),
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureHome(roster: FleetRosterV2): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-'));
|
||||
cleanupDirectories.push(home);
|
||||
const fleetDirectory = join(home, 'fleet');
|
||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chmod(fleetDirectory, 0o700);
|
||||
await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
return home;
|
||||
}
|
||||
|
||||
async function fixtureLegacyRoster(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-'));
|
||||
cleanupDirectories.push(home);
|
||||
const fleetDirectory = join(home, 'fleet');
|
||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chmod(fleetDirectory, 0o700);
|
||||
const rosterPath = join(fleetDirectory, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' holder_session: _holder',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: code',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return rosterPath;
|
||||
}
|
||||
|
||||
function cliProgram(home: string, host: FakeLifecycleHost): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
mosaicHome: home,
|
||||
runner: async (command: string, args: string[]): Promise<CommandResult> =>
|
||||
host.run(command, args),
|
||||
reconcileDeps: reconcileDeps(host),
|
||||
});
|
||||
return program;
|
||||
}
|
||||
|
||||
function captureJson(): string[] {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string): void => {
|
||||
output.push(line);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
|
||||
it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise<void> => {
|
||||
const roster: FleetRosterV2 = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
stoppedAgent,
|
||||
{
|
||||
...stoppedAgent,
|
||||
name: 'reviewer0',
|
||||
alias: 'Reviewer 0',
|
||||
lifecycle: { enabled: true, desiredState: 'running' },
|
||||
},
|
||||
{
|
||||
...stoppedAgent,
|
||||
name: 'validator0',
|
||||
alias: 'Validator 0',
|
||||
lifecycle: { enabled: false, desiredState: 'stopped' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const home = await fixtureHome(roster);
|
||||
const host = new FakeLifecycleHost(roster);
|
||||
host.sessions.add('coder0');
|
||||
host.sessions.add('validator0');
|
||||
host.sessions.add('coder0-shadow');
|
||||
const output = captureJson();
|
||||
|
||||
await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(output[0] ?? '{}')).toMatchObject({
|
||||
plan: {
|
||||
agents: [
|
||||
{ name: 'coder0', drift: ['unexpected-session'] },
|
||||
{ name: 'reviewer0', drift: ['missing-session'] },
|
||||
{ name: 'validator0', drift: ['unexpected-session', 'disabled-running'] },
|
||||
],
|
||||
unmanagedSessions: ['coder0-shadow'],
|
||||
},
|
||||
});
|
||||
expect(host.calls[0]).toEqual([
|
||||
'tmux',
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}',
|
||||
]);
|
||||
expect(
|
||||
host.calls.every(
|
||||
(call: string[]): boolean =>
|
||||
call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
|
||||
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
|
||||
expect(() => parseRosterV2(source, 'yaml')).toThrow(
|
||||
'Roster v2 tmux socket_name is required and must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => {
|
||||
const source = renderRosterV2Yaml(baseRoster).replace(
|
||||
/^ socket_name:.*$/m,
|
||||
' socket_name: ""',
|
||||
);
|
||||
expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe('');
|
||||
});
|
||||
|
||||
it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise<void> => {
|
||||
const rosterPath = await fixtureLegacyRoster();
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (): Promise<CommandResult> => ({
|
||||
stdout: '111 pi 0 0 0 0\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
mosaicHome: '/unused',
|
||||
rosterPath,
|
||||
runner,
|
||||
});
|
||||
|
||||
await expect(transport.verifySession('coder0')).resolves.toEqual({
|
||||
id: 'coder0',
|
||||
runtimeId: 'pi',
|
||||
socketName: '',
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(runner).toHaveBeenCalledWith('tmux', [
|
||||
'list-panes',
|
||||
'-t',
|
||||
'=coder0:0.0',
|
||||
'-F',
|
||||
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
|
||||
]);
|
||||
expect(runner.mock.calls[0]?.[1]).not.toContain('-L');
|
||||
});
|
||||
|
||||
it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise<void> => {
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.sessions.add('coder0');
|
||||
host.sessions.add('coder0-shadow');
|
||||
host.sessions.add('unmanaged');
|
||||
host.activeServices.add('[email protected]');
|
||||
host.activeServices.add('[email protected]');
|
||||
|
||||
const observed = await execute(host, 'status');
|
||||
const stopped = await execute(host, 'stop', 'coder0');
|
||||
|
||||
expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']);
|
||||
expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.activeServices.has('[email protected]')).toBe(false);
|
||||
expect(host.activeServices.has('[email protected]')).toBe(true);
|
||||
expect(host.sessions.has('coder0-shadow')).toBe(true);
|
||||
expect(host.sessions.has('unmanaged')).toBe(true);
|
||||
expect(host.calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'stop',
|
||||
'[email protected]',
|
||||
]);
|
||||
expect(
|
||||
host.calls.some(
|
||||
(call: string[]): boolean =>
|
||||
call.includes('kill-session') ||
|
||||
call.includes('coder0-shadow.service') ||
|
||||
call.includes('unmanaged.service'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise<void> => {
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.sessions.add('coder0');
|
||||
host.activeServices.add('[email protected]');
|
||||
|
||||
const applied = await execute(host, 'apply');
|
||||
const reconciled = await execute(host, 'reconcile');
|
||||
host.injectFailure({
|
||||
action: 'restart',
|
||||
service: '[email protected]',
|
||||
diagnostic: 'crash after effect: TOKEN=acceptance-secret',
|
||||
});
|
||||
const partialRestart = await execute(host, 'restart', 'coder0');
|
||||
|
||||
expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
||||
expect(partialRestart).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
});
|
||||
expect(host.activeServices.has('[email protected]')).toBe(true);
|
||||
|
||||
const recovered = await execute(host, 'reconcile');
|
||||
|
||||
expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
||||
expect(host.activeServices.has('[email protected]')).toBe(false);
|
||||
expect(host.sessions.has('coder0')).toBe(false);
|
||||
const destructiveCalls = host.calls.filter(
|
||||
(call: string[]): boolean =>
|
||||
call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''),
|
||||
);
|
||||
expect(
|
||||
destructiveCalls.every(
|
||||
(call: string[]): boolean => call[3] === '[email protected]',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise<void> => {
|
||||
const home = await fixtureHome(baseRoster);
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.injectFailure({
|
||||
action: 'restart',
|
||||
service: '[email protected]',
|
||||
diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret',
|
||||
});
|
||||
const output = captureJson();
|
||||
|
||||
await cliProgram(home, host).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'restart',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
const line = output.at(-1) ?? '';
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(line)).toEqual({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
plan: {
|
||||
generation: 7,
|
||||
holder: 'owned',
|
||||
broker: { unitInstalled: false, socketPresent: false },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
desiredState: 'stopped',
|
||||
enabled: true,
|
||||
systemd: 'inactive',
|
||||
tmux: 'missing',
|
||||
drift: [],
|
||||
},
|
||||
],
|
||||
unmanagedSessions: [],
|
||||
},
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(line).not.toContain('PASSWORD');
|
||||
expect(line).not.toContain('acceptance-secret');
|
||||
expect(line).not.toContain('simulated runner stderr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
acquirePrivateReconcileLock,
|
||||
FleetReconcileError,
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from './fleet-reconciler.js';
|
||||
import type { FleetRosterV2 } from './roster-v2.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function lockHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
|
||||
const fleet = join(cleanup, 'fleet');
|
||||
await mkdir(fleet, { mode: 0o700 });
|
||||
await chmod(cleanup, 0o700);
|
||||
await chmod(fleet, 0o700);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
const roster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
|
||||
return {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
|
||||
return executeFleetReconcile({
|
||||
roster,
|
||||
command,
|
||||
...(command === 'status' || command === 'verify' || command === 'doctor'
|
||||
? {}
|
||||
: { expectedGeneration: 7 }),
|
||||
deps: deps({ readRoster: async () => roster, ...overrides }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('fleet roster-owned reconciler', (): void => {
|
||||
// ── #1292: broker as first-class plan member + broker-first start ordering ──
|
||||
|
||||
it('reports broker unit and socket state in the plan (socket is the signal, not unit state)', async (): Promise<void> => {
|
||||
const result = await run('status', {
|
||||
statPath: async () => true,
|
||||
checkBrokerSocket: async () => true,
|
||||
});
|
||||
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
|
||||
});
|
||||
|
||||
it('reports a dead broker as socketPresent=false even when the unit is installed (enabled-but-dead is the #1292 shape)', async (): Promise<void> => {
|
||||
const result = await run('status', {
|
||||
statPath: async () => true,
|
||||
checkBrokerSocket: async () => false,
|
||||
});
|
||||
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: false });
|
||||
});
|
||||
|
||||
it('probes the REAL filesystem when no seam is injected — live socket and unit report healthy, absent paths report absent (#1297 F3)', async (): Promise<void> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-broker-probe-'));
|
||||
cleanup = dir;
|
||||
const configHome = join(dir, 'config');
|
||||
const unitDir = join(configHome, 'systemd', 'user');
|
||||
await mkdir(unitDir, { recursive: true });
|
||||
await writeFile(join(unitDir, 'mosaic-lease-broker.service'), '[Unit]\n');
|
||||
const sockPath = join(dir, 'broker.sock');
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(sockPath, resolve);
|
||||
});
|
||||
try {
|
||||
const result = await run('status', {
|
||||
brokerSocketEnv: {
|
||||
MOSAIC_LEASE_BROKER_SOCKET: sockPath,
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
XDG_RUNTIME_DIR: dir,
|
||||
},
|
||||
});
|
||||
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
|
||||
// Absent paths through the SAME seam-less path answer false — this is
|
||||
// the half the old default got right; healthy is the half it got wrong.
|
||||
const absent = await run('status', {
|
||||
brokerSocketEnv: {
|
||||
MOSAIC_LEASE_BROKER_SOCKET: join(dir, 'gone.sock'),
|
||||
XDG_CONFIG_HOME: join(dir, 'gone-config'),
|
||||
},
|
||||
});
|
||||
expect(absent.plan.broker).toEqual({ unitInstalled: false, socketPresent: false });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('command start refuses with a named error when the broker socket does not appear after enable+start (#1297 F3)', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
await expect(
|
||||
run('start', {
|
||||
checkBrokerSocket: async () => false,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/broker-absent/);
|
||||
// Refused: broker enable+start attempted, no holder/agent unit touched.
|
||||
const agentStarts = calls.filter(
|
||||
(c) => c.join(' ') === 'systemctl --user start [email protected]',
|
||||
);
|
||||
expect(agentStarts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('command start enables and starts the broker BEFORE the holder and any agent unit', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const result = await run('start', {
|
||||
// Deterministic broker presence: without the seam this test answers the
|
||||
// HOST's broker state (passes on a machine with a live broker, refuses
|
||||
// on CI), not the ordering property it exists for (#1297 follow-up).
|
||||
checkBrokerSocket: async () => true,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
const brokerEnable = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user enable mosaic-lease-broker.service',
|
||||
);
|
||||
const brokerStart = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
|
||||
);
|
||||
const holderStart = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user start mosaic-tmux-holder.service',
|
||||
);
|
||||
const agentStart = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user start [email protected]',
|
||||
);
|
||||
expect(brokerEnable).toBeGreaterThanOrEqual(0);
|
||||
expect(brokerStart).toBeGreaterThan(brokerEnable);
|
||||
// Holder start may be absent (holder 'owned' in this fixture); if present it must follow the broker.
|
||||
if (holderStart >= 0) expect(holderStart).toBeGreaterThan(brokerStart);
|
||||
expect(agentStart).toBeGreaterThan(brokerStart);
|
||||
});
|
||||
|
||||
it('apply with a running desired agent also enables and starts the broker first', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const runningRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
agents: roster.agents.map((agent) =>
|
||||
agent.name === 'coder0'
|
||||
? { ...agent, lifecycle: { enabled: true, desiredState: 'running' as const } }
|
||||
: agent,
|
||||
),
|
||||
};
|
||||
const result = await executeFleetReconcile({
|
||||
roster: runningRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => runningRoster,
|
||||
// Deterministic broker presence (see start-ordering test note).
|
||||
checkBrokerSocket: async () => true,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(result.applied).toBe(true);
|
||||
const brokerStart = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
|
||||
);
|
||||
const agentStart = calls.findIndex(
|
||||
(c) => c.join(' ') === 'systemctl --user start [email protected]',
|
||||
);
|
||||
expect(brokerStart).toBeGreaterThanOrEqual(0);
|
||||
expect(agentStart).toBeGreaterThan(brokerStart);
|
||||
});
|
||||
|
||||
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
|
||||
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
|
||||
try {
|
||||
await rm(fleet, { recursive: true });
|
||||
await symlink(attacker, fleet, 'dir');
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
|
||||
} finally {
|
||||
await rm(attacker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
|
||||
|
||||
await chmod(fleet, 0o770);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
await chmod(fleet, 0o700);
|
||||
|
||||
const target = join(home, 'target');
|
||||
await writeFile(target, 'target\n', { mode: 0o600 });
|
||||
await symlink(target, lockPath);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-lock',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
await writeFile(lockPath, 'other\n', { mode: 0o600 });
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'concurrent-mutation',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
|
||||
await expect(
|
||||
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
|
||||
).rejects.toMatchObject({ code: 'lock-io-failed' });
|
||||
});
|
||||
|
||||
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
|
||||
const release = await acquirePrivateReconcileLock(home)();
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
|
||||
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
|
||||
|
||||
await rm(lockPath);
|
||||
const normalRelease = await acquirePrivateReconcileLock(home)();
|
||||
await normalRelease();
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'missing-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => ({ ...roster, generation: 8 }),
|
||||
runner: async () => {
|
||||
effects += 1;
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 6,
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
const busy = async (): Promise<() => Promise<void>> => {
|
||||
throw new FleetReconcileError('concurrent-mutation', 'busy');
|
||||
};
|
||||
await expect(
|
||||
run('apply', {
|
||||
acquireMutationLock: busy,
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(effects).toBe(0);
|
||||
|
||||
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
});
|
||||
|
||||
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
|
||||
let releases = 0;
|
||||
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
releases += 1;
|
||||
};
|
||||
await run('apply', { acquireMutationLock: lock });
|
||||
await run('apply', {
|
||||
acquireMutationLock: lock,
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
expect(releases).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves stopped desired state during apply', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const result = await run('apply', {
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['plan', 'status', 'doctor', 'verify'] as const)(
|
||||
'keeps default-server %s observational and free of lifecycle effects',
|
||||
async (command) => {
|
||||
const calls: string[][] = [];
|
||||
const defaultServerRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
tmux: { ...roster.tmux, socketName: '' },
|
||||
};
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: defaultServerRoster,
|
||||
command,
|
||||
deps: deps({
|
||||
runner: async (executable, args) => {
|
||||
calls.push([executable, ...args]);
|
||||
if (executable === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (executable === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
|
||||
expect(
|
||||
calls.some(
|
||||
([executable, , action]): boolean =>
|
||||
executable === 'systemctl' &&
|
||||
(action === 'start' || action === 'stop' || action === 'restart'),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
|
||||
'fails closed before %s can route fixed named-socket services for a default-server roster',
|
||||
async (command) => {
|
||||
const calls: string[][] = [];
|
||||
let projectionPrepares = 0;
|
||||
let projectionApplies = 0;
|
||||
const defaultServerRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
tmux: { ...roster.tmux, socketName: '' },
|
||||
agents: [
|
||||
{
|
||||
...roster.agents[0]!,
|
||||
lifecycle: {
|
||||
enabled: true,
|
||||
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: defaultServerRoster,
|
||||
command,
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => defaultServerRoster,
|
||||
prepareProjections: async () => {
|
||||
projectionPrepares += 1;
|
||||
return [{ agentName: 'coder0' }];
|
||||
},
|
||||
applyProjection: async () => {
|
||||
projectionApplies += 1;
|
||||
},
|
||||
runner: async (executable, args) => {
|
||||
calls.push([executable, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||
expect(projectionPrepares).toBe(0);
|
||||
expect(projectionApplies).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const runningRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
|
||||
};
|
||||
const result = await executeFleetReconcile({
|
||||
roster: runningRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => runningRoster,
|
||||
// Deterministic broker presence (see start-ordering test note).
|
||||
checkBrokerSocket: async () => true,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', '[email protected]']);
|
||||
});
|
||||
|
||||
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
|
||||
let projectionApplied = false;
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
projectionApplied = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ownership-mismatch' });
|
||||
expect(projectionApplied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
|
||||
});
|
||||
|
||||
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const remoteRoster = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
|
||||
} as unknown as FleetRosterV2;
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: remoteRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => remoteRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
let applied = false;
|
||||
const result = await run('plan', {
|
||||
applyProjection: async () => {
|
||||
applied = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.lifecycle).toBe('not-applied');
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
|
||||
const failingRelease = async (): Promise<never> => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
};
|
||||
const projectionPartial = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(projectionPartial).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
|
||||
const lifecyclePartial = await run('apply', {
|
||||
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(lifecyclePartial).toMatchObject({
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: { code: 'lifecycle-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,644 @@
|
||||
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;
|
||||
generation?: 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;
|
||||
model?: unknown;
|
||||
reasoning?: unknown;
|
||||
lifecycle?: { enabled?: unknown; desired_state?: unknown };
|
||||
launch?: { yolo?: 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 class FleetRosterConfigurationError extends Error {
|
||||
override name = 'FleetRosterConfigurationError';
|
||||
}
|
||||
|
||||
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 readFleetRosterText(path);
|
||||
try {
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
} catch (error) {
|
||||
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an operator-owned roster with errors that say how to recover. */
|
||||
export async function readFleetRosterText(path: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(path, 'utf8');
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||
throw new FleetRosterConfigurationError(
|
||||
`No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`,
|
||||
);
|
||||
}
|
||||
throw new FleetRosterConfigurationError(
|
||||
`Could not read fleet roster at ${path}. Check the file exists and is readable.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a roster document needed only to select the v1/v2 command path. */
|
||||
export function parseFleetRosterDocument(source: string, path: string): unknown {
|
||||
try {
|
||||
return YAML.parse(source);
|
||||
} catch (error) {
|
||||
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function invalidFleetRosterError(path: string): FleetRosterConfigurationError {
|
||||
return new FleetRosterConfigurationError(
|
||||
`Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
function isRosterParserError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof SyntaxError ||
|
||||
(error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning'))
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
return normalizeFleetRosterV1Unchecked(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof FleetRosterConfigurationError) throw error;
|
||||
if (error instanceof Error) throw new FleetRosterConfigurationError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
'connector',
|
||||
// stack#1380 verification unblock: the fleet's own roster-v2 mutation
|
||||
// tooling writes a `generation` fence on the same v1 body. Tolerated here
|
||||
// as an opaque non-negative integer; comms semantics are unchanged.
|
||||
'generation',
|
||||
]);
|
||||
if (
|
||||
raw.generation !== undefined &&
|
||||
(typeof raw.generation !== 'number' || !Number.isInteger(raw.generation) || raw.generation < 0)
|
||||
) {
|
||||
throw new Error('Fleet roster generation must be a non-negative integer.');
|
||||
}
|
||||
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',
|
||||
// stack#1380 verification unblock: roster-v2 default runtime hint.
|
||||
'runtime',
|
||||
]);
|
||||
}
|
||||
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 && raw.version !== 2) {
|
||||
throw new Error('Fleet roster version must be 1 or 2.');
|
||||
}
|
||||
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',
|
||||
// stack#1380 verification unblock: roster-v2 envelope fields written by
|
||||
// the fleet's own mutation tooling. Validated, then opaque to comms.
|
||||
'model',
|
||||
'reasoning',
|
||||
'lifecycle',
|
||||
'launch',
|
||||
]);
|
||||
if (raw.model !== undefined && typeof raw.model !== 'string') {
|
||||
throw new Error('Fleet roster agent model must be a string.');
|
||||
}
|
||||
if (raw.reasoning !== undefined && typeof raw.reasoning !== 'string') {
|
||||
throw new Error('Fleet roster agent reasoning must be a string.');
|
||||
}
|
||||
const lifecycle = raw.lifecycle as { enabled?: unknown; desired_state?: unknown } | undefined;
|
||||
if (lifecycle !== undefined) {
|
||||
if (typeof lifecycle !== 'object' || lifecycle === null) {
|
||||
throw new Error('Fleet roster agent lifecycle must be an object.');
|
||||
}
|
||||
const lifecycleKeys = Object.keys(lifecycle);
|
||||
if (!lifecycleKeys.every((key) => key === 'enabled' || key === 'desired_state')) {
|
||||
throw new Error('Fleet roster agent lifecycle has unknown field(s).');
|
||||
}
|
||||
if (lifecycle.enabled !== undefined && typeof lifecycle.enabled !== 'boolean') {
|
||||
throw new Error('Fleet roster agent lifecycle.enabled must be a boolean.');
|
||||
}
|
||||
if (
|
||||
lifecycle.desired_state !== undefined &&
|
||||
(typeof lifecycle.desired_state !== 'string' ||
|
||||
!['running', 'stopped'].includes(lifecycle.desired_state))
|
||||
) {
|
||||
throw new Error('Fleet roster agent lifecycle.desired_state must be running|stopped.');
|
||||
}
|
||||
}
|
||||
const launch = raw.launch as { yolo?: unknown } | undefined;
|
||||
if (launch !== undefined) {
|
||||
if (typeof launch !== 'object' || launch === null) {
|
||||
throw new Error('Fleet roster agent launch must be an object.');
|
||||
}
|
||||
const launchKeys = Object.keys(launch);
|
||||
if (!launchKeys.every((key) => key === 'yolo')) {
|
||||
throw new Error('Fleet roster agent launch has unknown field(s).');
|
||||
}
|
||||
if (launch.yolo !== undefined && typeof launch.yolo !== 'boolean') {
|
||||
throw new Error('Fleet roster agent launch.yolo must be a boolean.');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentEnvBoundaryError,
|
||||
parseAgentEnvironment,
|
||||
previewAgentEnvironmentProjection,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
|
||||
const generatedValues = {
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_GIT_IDENTITY: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||
};
|
||||
|
||||
describe('generated fleet agent environment boundary', (): void => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a deterministic complete generated projection', (): void => {
|
||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_GIT_IDENTITY=coder0',
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=pi',
|
||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||
'MOSAIC_AGENT_REASONING=high',
|
||||
'MOSAIC_AGENT_TOOL_POLICY=code',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['generated-key shadowing', 'MOSAIC_AGENT_RUNTIME=codex\n'],
|
||||
['unknown key', 'UNRELATED_SETTING=value\n'],
|
||||
['arbitrary command', 'MOSAIC_AGENT_COMMAND=mosaic yolo codex\n'],
|
||||
['sensitive key', 'MOSAIC_AGENT_TOKEN=do-not-log-me\n'],
|
||||
['malformed syntax', 'export MOSAIC_RUNTIME_BIN=/opt/bin\n'],
|
||||
['duplicate keys', 'MOSAIC_RUNTIME_BIN=/opt/bin\nMOSAIC_RUNTIME_BIN=/other/bin\n'],
|
||||
])('rejects local %s without echoing values', (_name: string, source: string): void => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseAgentEnvironment(source, 'local');
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(AgentEnvBoundaryError);
|
||||
expect(String(error)).not.toContain('mosaic yolo codex');
|
||||
expect(String(error)).not.toContain('do-not-log-me');
|
||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unsafe-git-identity', 'other/identity'],
|
||||
['git-identity-mismatch', 'reviewer0'],
|
||||
])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_GIT_IDENTITY: identity,
|
||||
});
|
||||
}).toThrow(
|
||||
expect.objectContaining({
|
||||
diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: '../outside',
|
||||
});
|
||||
}).toThrow(AgentEnvBoundaryError);
|
||||
});
|
||||
|
||||
it('rejects traversal in home-relative workdirs before expansion', (): void => {
|
||||
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
});
|
||||
}).toThrow(
|
||||
expect.objectContaining({
|
||||
diagnostic: expect.objectContaining({
|
||||
code: 'unsafe-path',
|
||||
key: 'MOSAIC_AGENT_WORKDIR',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('expands home-relative workdirs before preserving absolute-path validation', (): void => {
|
||||
expect(
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: '~/src',
|
||||
}),
|
||||
).toContain(`MOSAIC_AGENT_WORKDIR=${join(homedir(), 'src')}\n`);
|
||||
|
||||
for (const workingDirectory of ['relative/path', '../outside']) {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
});
|
||||
}).toThrow(AgentEnvBoundaryError);
|
||||
}
|
||||
});
|
||||
|
||||
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
||||
const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n';
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(legacyPath, legacy, { mode: 0o600 });
|
||||
|
||||
const preview = await previewAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
expect(preview).toMatchObject({
|
||||
agentName: 'coder0',
|
||||
generated: 'rebuild',
|
||||
legacy: 'quarantine',
|
||||
relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
|
||||
diagnostics: [
|
||||
expect.objectContaining({
|
||||
code: 'unknown-key',
|
||||
key: 'MOSAIC_AGENT_COMMAND',
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(preview)).not.toContain('never-print-command');
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy);
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||
expect((await stat(directory)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise<void> => {
|
||||
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
|
||||
try {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'config-home');
|
||||
const brainHome = join(cleanup, 'brain');
|
||||
const agentEnvDir = join(brainHome, 'fleet', 'agents');
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brainHome;
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
// Projection landed in the brain tree, not under the config home.
|
||||
expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated'));
|
||||
expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700);
|
||||
expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700);
|
||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
||||
await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow();
|
||||
|
||||
// A config-home agentEnvDir is now REJECTED while the brain is active —
|
||||
// the boundary must not silently split state across two trees.
|
||||
let rejected: unknown;
|
||||
try {
|
||||
await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
agentName: 'coder1',
|
||||
generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' },
|
||||
});
|
||||
} catch (caught: unknown) {
|
||||
rejected = caught;
|
||||
}
|
||||
expect(rejected).toBeInstanceOf(AgentEnvBoundaryError);
|
||||
} finally {
|
||||
if (savedBrainHome === undefined) {
|
||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
} else {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(
|
||||
join(agentEnvDir, 'coder0.env'),
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=stale-name',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin',
|
||||
'MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
expect(await readFile(result.generatedPath, 'utf8')).toBe(
|
||||
renderGeneratedAgentEnvironment(generatedValues),
|
||||
);
|
||||
expect(await readFile(result.localPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
const quarantine = await readFile(result.quarantinePath!, 'utf8');
|
||||
expect(quarantine).toContain('MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous');
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||
expect((await stat(result.generatedPath)).mode & 0o077).toBe(0);
|
||||
expect((await stat(result.localPath)).mode & 0o077).toBe(0);
|
||||
expect(result.diagnostics).toEqual([expect.objectContaining({ key: 'MOSAIC_AGENT_COMMAND' })]);
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain('mosaic yolo codex --dangerous');
|
||||
});
|
||||
|
||||
it('fails closed on an existing local override with unsafe permissions', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(localPath, 0o644);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/permissions/i);
|
||||
});
|
||||
|
||||
it('rejects an existing group/world-accessible projection directory before reading it', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(agentEnvDir, 0o777);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-permissions/i);
|
||||
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
await expect(readFile(generatedPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MOSAIC_HOME', 'symlink'],
|
||||
['MOSAIC_HOME/fleet', 'symlink'],
|
||||
['MOSAIC_HOME/fleet/agents', 'symlink'],
|
||||
['MOSAIC_HOME', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet/agents', 'group/world-writable'],
|
||||
])(
|
||||
'rejects a %s %s managed ancestor before any projection mutation',
|
||||
async (ancestor: string, hazard: string): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const agentEnvDir = join(fleetDir, 'agents');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
||||
const quarantinePath = join(agentEnvDir, 'coder0.env.quarantine');
|
||||
const managedPaths: Record<string, string> = {
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
'MOSAIC_HOME/fleet': fleetDir,
|
||||
'MOSAIC_HOME/fleet/agents': agentEnvDir,
|
||||
};
|
||||
const unsafePath = managedPaths[ancestor];
|
||||
if (unsafePath === undefined) throw new Error(`Unknown managed ancestor: ${ancestor}`);
|
||||
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(mosaicHome, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentEnvDir, 0o700);
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
|
||||
if (hazard === 'symlink') {
|
||||
const targetPath = `${unsafePath}-target`;
|
||||
await rename(unsafePath, targetPath);
|
||||
await symlink(targetPath, unsafePath, 'dir');
|
||||
} else {
|
||||
await chmod(unsafePath, 0o777);
|
||||
}
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-(directory|permissions)/i);
|
||||
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(quarantinePath, 'utf8')).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a symlinked projection directory without mutating its target', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const targetDirectory = join(cleanup, 'target');
|
||||
const linkedDirectory = join(mosaicHome, 'fleet', 'agents');
|
||||
const legacyPath = join(targetDirectory, 'coder0.env');
|
||||
const generatedPath = join(targetDirectory, 'coder0.env.generated');
|
||||
const localPath = join(targetDirectory, 'coder0.env.local');
|
||||
const quarantinePath = join(targetDirectory, 'coder0.env.quarantine');
|
||||
await mkdir(join(mosaicHome, 'fleet'), { recursive: true, mode: 0o700 });
|
||||
await mkdir(targetDirectory, { mode: 0o700 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'local-before\n', { mode: 0o600 });
|
||||
await writeFile(quarantinePath, 'quarantine-before\n', { mode: 0o600 });
|
||||
await symlink(targetDirectory, linkedDirectory, 'dir');
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: linkedDirectory,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-directory/i);
|
||||
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('local-before\n');
|
||||
await expect(readFile(quarantinePath, 'utf8')).resolves.toBe('quarantine-before\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js';
|
||||
import { compareCodePoints } from './deterministic-order.js';
|
||||
|
||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||
|
||||
export interface AgentEnvironmentDiagnostic {
|
||||
readonly code: string;
|
||||
readonly key: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
readonly generated: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface AgentGeneratedProjectionDeletionOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionResult {
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
/** Sanitized, non-mutating projection evidence safe for migration output. */
|
||||
export interface AgentEnvironmentProjectionPreview {
|
||||
readonly agentName: string;
|
||||
readonly generated: 'rebuild';
|
||||
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
|
||||
readonly relocatedKeys: readonly string[];
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
/** A projection fully validated without changing managed files. */
|
||||
export interface PreparedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly legacyPath: string;
|
||||
readonly generated: string;
|
||||
readonly local: string;
|
||||
readonly legacy?: string;
|
||||
readonly legacyRelocatedKeys: readonly string[];
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
export class AgentEnvBoundaryError extends Error {
|
||||
readonly diagnostic: AgentEnvironmentDiagnostic;
|
||||
|
||||
constructor(code: string, key: string, value: string) {
|
||||
const diagnostic: AgentEnvironmentDiagnostic = {
|
||||
code,
|
||||
key,
|
||||
sha256: hashValue(value),
|
||||
};
|
||||
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
|
||||
this.name = 'AgentEnvBoundaryError';
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export const GENERATED_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_GIT_IDENTITY',
|
||||
'MOSAIC_AGENT_CLASS',
|
||||
'MOSAIC_AGENT_RUNTIME',
|
||||
'MOSAIC_AGENT_MODEL',
|
||||
'MOSAIC_AGENT_REASONING',
|
||||
'MOSAIC_AGENT_TOOL_POLICY',
|
||||
'MOSAIC_AGENT_WORKDIR',
|
||||
'MOSAIC_TMUX_SOCKET',
|
||||
] as const;
|
||||
|
||||
export const LOCAL_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_RUNTIME_BIN',
|
||||
'MOSAIC_HEARTBEAT_RUN_DIR',
|
||||
'MOSAIC_HEARTBEAT_INTERVAL',
|
||||
'MOSAIC_CLAUDE_JSON',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
] as const;
|
||||
|
||||
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
|
||||
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
|
||||
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
|
||||
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
|
||||
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
|
||||
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
|
||||
/** Runtime launchers supported by the generated environment contract. */
|
||||
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'pi',
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||
const REASONING = new Set(['', 'low', 'medium', 'high']);
|
||||
|
||||
/** Parses a strict data-only generated or local environment file without shell evaluation. */
|
||||
export function parseAgentEnvironment(
|
||||
source: string,
|
||||
kind: AgentEnvironmentKind,
|
||||
): Readonly<Record<string, string>> {
|
||||
const values: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
|
||||
const [, key, value] = match;
|
||||
if (key === undefined || value === undefined) {
|
||||
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
}
|
||||
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
|
||||
seen.add(key);
|
||||
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
|
||||
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
|
||||
|
||||
if (kind === 'generated') {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
} else {
|
||||
if (GENERATED_KEY_SET.has(key))
|
||||
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
|
||||
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
if (kind === 'generated') assertGeneratedValues(values);
|
||||
else assertLocalValues(values);
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
const normalized = normalizeGeneratedValues(values);
|
||||
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** Validates all deterministic projection inputs and target state without mutation. */
|
||||
export async function prepareAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<PreparedAgentEnvironmentProjection> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
|
||||
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
|
||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
const currentLocal = await readOptionalPrivateFile(localPath);
|
||||
const parsedLocal =
|
||||
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
|
||||
const legacy = await readOptionalPrivateFile(legacyPath);
|
||||
const legacyDisposition =
|
||||
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
|
||||
|
||||
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
|
||||
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
|
||||
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
|
||||
const quarantinePath = legacyDisposition.diagnostics.length
|
||||
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
|
||||
: undefined;
|
||||
if (quarantinePath !== undefined) {
|
||||
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
|
||||
if (existingQuarantine !== undefined) {
|
||||
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
generatedPath,
|
||||
localPath,
|
||||
legacyPath,
|
||||
generated,
|
||||
local,
|
||||
...(legacy === undefined ? {} : { legacy }),
|
||||
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
|
||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
||||
diagnostics: legacyDisposition.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** A generated-only projection validated without touching any legacy/local/quarantine file. */
|
||||
export interface PreparedGeneratedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly generatedPath: string;
|
||||
readonly generated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates ONLY the roster-derived generated projection for a recovery rebuild.
|
||||
* Unlike {@link prepareAgentEnvironmentProjection}, this never reads, classifies,
|
||||
* relocates, or quarantines the legacy `.env` / `.env.local` operator surface — it
|
||||
* exists so `mosaic fleet regen` has no code path that can mutate anything except
|
||||
* `<name>.env.generated`. The existing generated file, if present, must already be
|
||||
* a private regular file.
|
||||
*/
|
||||
export async function prepareGeneratedAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<PreparedGeneratedAgentEnvironmentProjection> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
return {
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
generatedPath,
|
||||
generated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a generated-only projection: writes ONLY `<name>.env.generated` atomically
|
||||
* and touches nothing else. It never writes `.env.local`/`.env.quarantine` and never
|
||||
* unlinks the legacy `.env` — the projection-only recovery guarantee is structural.
|
||||
*/
|
||||
export async function applyPreparedGeneratedAgentEnvironmentProjection(
|
||||
prepared: PreparedGeneratedAgentEnvironmentProjection,
|
||||
): Promise<string> {
|
||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||
return prepared.generatedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates only the exact generated projection eligible for a roster delete.
|
||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
||||
* intentionally excluded from delete validation and cleanup.
|
||||
*/
|
||||
export async function prepareAgentGeneratedProjectionDeletion(
|
||||
options: AgentGeneratedProjectionDeletionOptions,
|
||||
): Promise<string> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
return generatedPath;
|
||||
}
|
||||
|
||||
export async function previewAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<AgentEnvironmentProjectionPreview> {
|
||||
const prepared = await prepareAgentEnvironmentProjection(options);
|
||||
const relocatedKeys = prepared.legacyRelocatedKeys;
|
||||
const legacy =
|
||||
prepared.legacy === undefined
|
||||
? 'absent'
|
||||
: prepared.diagnostics.length > 0
|
||||
? 'quarantine'
|
||||
: relocatedKeys.length > 0
|
||||
? 'relocate-local'
|
||||
: 'regenerate-only';
|
||||
return {
|
||||
agentName: options.agentName,
|
||||
generated: 'rebuild',
|
||||
legacy,
|
||||
relocatedKeys,
|
||||
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
|
||||
compareCodePoints(
|
||||
`${left.key}:${left.code}:${left.sha256}`,
|
||||
`${right.key}:${right.code}:${right.sha256}`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies a previously prepared deterministic projection. */
|
||||
export async function applyPreparedAgentEnvironmentProjection(
|
||||
prepared: PreparedAgentEnvironmentProjection,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
|
||||
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
|
||||
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
|
||||
}
|
||||
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
|
||||
|
||||
return {
|
||||
generatedPath: prepared.generatedPath,
|
||||
localPath: prepared.localPath,
|
||||
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
|
||||
diagnostics: prepared.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
|
||||
export async function writeAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
|
||||
}
|
||||
|
||||
/** Creates the canonical managed roster path with private fresh-chain permissions. */
|
||||
export async function writeManagedFleetRoster(
|
||||
mosaicHome: string,
|
||||
rosterPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const expectedRosterPath = join(fleetDir, 'roster.yaml');
|
||||
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
|
||||
}
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await writePrivateAtomically(rosterPath, content);
|
||||
}
|
||||
|
||||
/** Ensures the private install-derived identity used to own a named tmux server. */
|
||||
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(runDir, true);
|
||||
const existing = await readOptionalPrivateFile(identityPath);
|
||||
if (existing !== undefined) {
|
||||
const identity = existing.trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(identity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
const identity = randomUUID();
|
||||
await writePrivateAtomically(identityPath, `${identity}\n`);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function normalizeGeneratedValues(
|
||||
values: Readonly<Record<string, string>>,
|
||||
): Readonly<Record<string, string>> {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
assertGeneratedValues(normalized);
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function expandHomeDirectory(path: string): string {
|
||||
if (path === '~') return homedir();
|
||||
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
|
||||
return join(homedir(), path.slice(2));
|
||||
}
|
||||
|
||||
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
if (Object.keys(values).length === 0) return '';
|
||||
const parsed = parseAgentEnvironment(
|
||||
Object.entries(values)
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
'local',
|
||||
);
|
||||
return `${Object.entries(parsed)
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
}
|
||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||
const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY');
|
||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
|
||||
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
|
||||
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
|
||||
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
|
||||
|
||||
if (!AGENT_NAME.test(name))
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||
if (!AGENT_NAME.test(gitIdentity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (gitIdentity !== name) {
|
||||
throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity);
|
||||
}
|
||||
if (!POLICY_NAME.test(className)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||
}
|
||||
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
|
||||
}
|
||||
if (!MODEL.test(model))
|
||||
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
|
||||
if (!REASONING.has(reasoning)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
|
||||
}
|
||||
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
|
||||
}
|
||||
if (!isSafeAbsolutePath(workingDirectory)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
|
||||
}
|
||||
if (!TMUX_SOCKET.test(socket)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertLocalValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
|
||||
if (!/^[1-9][0-9]*$/.test(value)) {
|
||||
throw new AgentEnvBoundaryError('invalid-interval', key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function containsShellSyntax(value: string): boolean {
|
||||
return /["'`$\\;&|<>(){}]/.test(value);
|
||||
}
|
||||
|
||||
interface LegacyDisposition {
|
||||
readonly localValues: Readonly<Record<string, string>>;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
function emptyLegacyDisposition(): LegacyDisposition {
|
||||
return { localValues: {}, diagnostics: [] };
|
||||
}
|
||||
|
||||
function classifyLegacyEnvironment(source: string): LegacyDisposition {
|
||||
const localValues: Record<string, string> = {};
|
||||
const diagnostics: AgentEnvironmentDiagnostic[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match || match[1] === undefined || match[2] === undefined) {
|
||||
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
|
||||
continue;
|
||||
}
|
||||
const [, key, value] = match;
|
||||
if (seen.has(key)) {
|
||||
diagnostics.push(diagnostic('duplicate-key', key, value));
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
if (GENERATED_KEY_SET.has(key)) continue;
|
||||
try {
|
||||
parseAgentEnvironment(`${key}=${value}\n`, 'local');
|
||||
localValues[key] = value;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
|
||||
}
|
||||
|
||||
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
|
||||
return { code, key, sha256: hashValue(value) };
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
return await readFile(path, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function validatePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
// Brain-home split (canon §2): seat envs live under the brain home's
|
||||
// fleet/agents when a brain is active; roster + templates stay config-home.
|
||||
const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome);
|
||||
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
||||
}
|
||||
const stateHome = resolveBrainHome(mosaicHome);
|
||||
const fleetDir = join(stateHome, 'fleet');
|
||||
await assertManagedDirectoryIfPresent(stateHome, false);
|
||||
await assertManagedDirectoryIfPresent(fleetDir, false);
|
||||
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensurePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
||||
const stateHome = resolveBrainHome(mosaicHome);
|
||||
const fleetDir = join(stateHome, 'fleet');
|
||||
await ensureManagedDirectory(stateHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
let created = false;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
try {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
created = true;
|
||||
} catch (mkdirError: unknown) {
|
||||
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
||||
}
|
||||
}
|
||||
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
if (created) {
|
||||
await chmod(path, 0o700);
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
function isAlreadyExists(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
||||
}
|
||||
|
||||
async function assertManagedDirectoryIfPresent(
|
||||
path: string,
|
||||
privateDirectory: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
|
||||
}
|
||||
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFile(path: string): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
|
||||
if ((metadata.mode & 0o077) !== 0) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function writePrivateAtomically(path: string, content: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
||||
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
await rename(temporaryPath, path);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import YAML from 'yaml';
|
||||
import type { FleetAgent } from '../commands/fleet.js';
|
||||
|
||||
const REQUIRED_RUNTIME = 'pi';
|
||||
const REQUIRED_MODEL = 'openai/gpt-5.6-sol';
|
||||
const REQUIRED_REASONING = 'high';
|
||||
const REQUIRED_TOOL_POLICY = 'operator-interaction';
|
||||
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export interface InteractionServiceProfile {
|
||||
runtime: string;
|
||||
model: string;
|
||||
reasoning: string;
|
||||
toolPolicy: string;
|
||||
}
|
||||
|
||||
export interface EffectiveInteractionPolicy {
|
||||
agentName: string;
|
||||
runtime: string;
|
||||
model: string;
|
||||
reasoning: string;
|
||||
toolPolicy: string;
|
||||
}
|
||||
|
||||
export class InteractionServiceProfileError extends Error {
|
||||
constructor(
|
||||
readonly code: 'invalid_profile' | 'invalid_request',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InteractionServiceProfileError.name;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInteractionServiceProfile(
|
||||
path: string,
|
||||
overrides: Partial<InteractionServiceProfile> = {},
|
||||
): Promise<InteractionServiceProfile> {
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
if (!isRecord(parsed)) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_profile',
|
||||
'Service profile must be an object',
|
||||
);
|
||||
}
|
||||
const profile: InteractionServiceProfile = {
|
||||
runtime: valueOrUndefined(overrides.runtime, parsed['runtime']),
|
||||
model: valueOrUndefined(overrides.model, parsed['model']),
|
||||
reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']),
|
||||
toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']),
|
||||
};
|
||||
assertPinnedPolicy(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function provisionInteractionService(
|
||||
profile: InteractionServiceProfile,
|
||||
input: { agentName: string },
|
||||
): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } {
|
||||
assertPinnedPolicy(profile);
|
||||
if (!AGENT_NAME_PATTERN.test(input.agentName)) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_request',
|
||||
'Agent name must contain only letters, numbers, dots, underscores, or hyphens',
|
||||
);
|
||||
}
|
||||
const effectivePolicy: EffectiveInteractionPolicy = {
|
||||
agentName: input.agentName,
|
||||
runtime: profile.runtime,
|
||||
model: profile.model,
|
||||
reasoning: profile.reasoning,
|
||||
toolPolicy: profile.toolPolicy,
|
||||
};
|
||||
return {
|
||||
rosterAgent: {
|
||||
name: input.agentName,
|
||||
runtime: profile.runtime,
|
||||
className: profile.toolPolicy,
|
||||
modelHint: profile.model,
|
||||
reasoningLevel: profile.reasoning,
|
||||
toolPolicy: profile.toolPolicy,
|
||||
persistentPersona: true,
|
||||
},
|
||||
effectivePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function assertPinnedPolicy(profile: InteractionServiceProfile): void {
|
||||
const invalid =
|
||||
profile.runtime !== REQUIRED_RUNTIME ||
|
||||
profile.model !== REQUIRED_MODEL ||
|
||||
profile.reasoning !== REQUIRED_REASONING ||
|
||||
profile.toolPolicy !== REQUIRED_TOOL_POLICY;
|
||||
if (invalid) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_profile',
|
||||
`Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function valueOrUndefined(override: string | undefined, value: unknown): string {
|
||||
if (override !== undefined) return override;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeScope } from '@mosaicstack/types';
|
||||
import {
|
||||
MatrixNativeRuntimeTransport,
|
||||
type MatrixFetchLike,
|
||||
} from './matrix-native-runtime-transport.js';
|
||||
|
||||
const scope: RuntimeScope = {
|
||||
actorId: 'operator-1',
|
||||
tenantId: 'tenant-a',
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const bindings = [
|
||||
{
|
||||
id: 'native-1',
|
||||
runtimeId: '@native-worker:example.test',
|
||||
roomId: '!room:example.test',
|
||||
remoteUserId: '@native-worker:example.test',
|
||||
state: 'active' as const,
|
||||
createdAt: '2026-07-13T00:00:00.000Z',
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function response(
|
||||
status: number,
|
||||
body: unknown = {},
|
||||
): ReturnType<MatrixFetchLike> extends Promise<infer T> ? T : never {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as never;
|
||||
}
|
||||
|
||||
function transport(fetchImpl: MatrixFetchLike): MatrixNativeRuntimeTransport {
|
||||
return new MatrixNativeRuntimeTransport({
|
||||
homeserverUrl: 'https://matrix.example.test/base',
|
||||
accessToken: 'test-token',
|
||||
userId: '@mosaic:example.test',
|
||||
sessions: bindings,
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
describe('MatrixNativeRuntimeTransport', (): void => {
|
||||
it('uses only configured session-to-room bindings and idempotency-derived Matrix transactions', async (): Promise<void> => {
|
||||
const fetchImpl = vi
|
||||
.fn<MatrixFetchLike>()
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
||||
.mockResolvedValueOnce(response(200, { event_id: '$sent' }));
|
||||
const matrix = transport(fetchImpl);
|
||||
|
||||
await matrix.send('native-1', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://matrix.example.test/base/_matrix/client/v3/account/whoami',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
||||
}),
|
||||
);
|
||||
const [url, init] = fetchImpl.mock.calls[1]!;
|
||||
expect(url).toMatch(
|
||||
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/m\.room\.message\/mosaic-send-[A-Za-z0-9_-]+$/,
|
||||
);
|
||||
expect(init?.method).toBe('PUT');
|
||||
expect(JSON.parse(init?.body ?? '')).toEqual({
|
||||
msgtype: 'm.text',
|
||||
body: 'hello',
|
||||
'mosaic.runtime.v1': {
|
||||
session_id: 'native-1',
|
||||
runtime_id: '@native-worker:example.test',
|
||||
actor_id: 'operator-1',
|
||||
tenant_id: 'tenant-a',
|
||||
channel_id: 'cli',
|
||||
correlation_id: 'corr-1',
|
||||
idempotency_key: 'message-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a termination only to the bound room with the exact approval reference', async (): Promise<void> => {
|
||||
const fetchImpl = vi
|
||||
.fn<MatrixFetchLike>()
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
||||
.mockResolvedValueOnce(response(200, { event_id: '$terminated' }));
|
||||
const matrix = transport(fetchImpl);
|
||||
|
||||
await matrix.terminate('native-1', 'approval-1', scope);
|
||||
|
||||
const [url, init] = fetchImpl.mock.calls[1]!;
|
||||
expect(url).toMatch(
|
||||
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/mosaic\.runtime\.terminate\/mosaic-terminate-[A-Za-z0-9_-]+$/,
|
||||
);
|
||||
expect(JSON.parse(init?.body ?? '')).toEqual({
|
||||
session_id: 'native-1',
|
||||
runtime_id: '@native-worker:example.test',
|
||||
actor_id: 'operator-1',
|
||||
tenant_id: 'tenant-a',
|
||||
channel_id: 'cli',
|
||||
correlation_id: 'corr-1',
|
||||
approval_ref: 'approval-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when Matrix whoami does not match the configured native identity', async (): Promise<void> => {
|
||||
const fetchImpl = vi
|
||||
.fn<MatrixFetchLike>()
|
||||
.mockResolvedValue(response(200, { user_id: '@other:example.test' }));
|
||||
const matrix = transport(fetchImpl);
|
||||
|
||||
await expect(matrix.listSessions(scope)).rejects.toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('rejects unbound session IDs without using caller-supplied Matrix room data', async (): Promise<void> => {
|
||||
const fetchImpl = vi
|
||||
.fn<MatrixFetchLike>()
|
||||
.mockResolvedValue(response(200, { user_id: '@mosaic:example.test' }));
|
||||
const matrix = transport(fetchImpl);
|
||||
|
||||
await expect(matrix.verifySession('!attacker-room:example.test', scope)).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps replay-cursor events from only the configured remote identity', async (): Promise<void> => {
|
||||
const fetchImpl = vi
|
||||
.fn<MatrixFetchLike>()
|
||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
||||
.mockResolvedValueOnce(
|
||||
response(200, {
|
||||
next_batch: 'next-cursor',
|
||||
rooms: {
|
||||
join: {
|
||||
'!room:example.test': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'mosaic.runtime.event',
|
||||
sender: '@intruder:example.test',
|
||||
content: { 'mosaic.runtime.v1': { type: 'message.delta', content: 'nope' } },
|
||||
},
|
||||
{
|
||||
type: 'mosaic.runtime.event',
|
||||
sender: '@native-worker:example.test',
|
||||
origin_server_ts: 1_784_246_400_000,
|
||||
content: {
|
||||
'mosaic.runtime.v1': {
|
||||
session_id: 'native-1',
|
||||
type: 'message.delta',
|
||||
content: 'accepted',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const matrix = transport(fetchImpl);
|
||||
|
||||
const events = [];
|
||||
for await (const event of matrix.stream('native-1', 'prior-cursor', scope)) events.push(event);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'message.delta',
|
||||
sessionId: 'native-1',
|
||||
cursor: 'next-cursor',
|
||||
occurredAt: '2026-07-17T00:00:00.000Z',
|
||||
content: 'accepted',
|
||||
},
|
||||
]);
|
||||
expect(fetchImpl.mock.calls[1]?.[0]).toContain('since=prior-cursor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type {
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSessionState,
|
||||
RuntimeStreamEvent,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export type MatrixRuntimeTransportErrorCode =
|
||||
| 'forbidden'
|
||||
| 'invalid_request'
|
||||
| 'not_found'
|
||||
| 'unavailable';
|
||||
|
||||
export class MatrixRuntimeTransportError extends Error {
|
||||
constructor(
|
||||
readonly code: MatrixRuntimeTransportErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MatrixRuntimeTransportError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal injectable Matrix fetch surface; it avoids coupling this transport to an SDK. */
|
||||
export interface MatrixFetchLike {
|
||||
(
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A server-configured runtime session binding. Callers never select a Matrix room. */
|
||||
export interface MatrixNativeRuntimeSessionBinding {
|
||||
id: string;
|
||||
runtimeId: string;
|
||||
roomId: string;
|
||||
remoteUserId: string;
|
||||
parentSessionId?: string;
|
||||
state: RuntimeSessionState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MatrixNativeRuntimeTransportOptions {
|
||||
homeserverUrl: string;
|
||||
accessToken: string;
|
||||
/** Matrix user authenticated by the service token. */
|
||||
userId: string;
|
||||
sessions: readonly MatrixNativeRuntimeSessionBinding[];
|
||||
fetchImpl?: MatrixFetchLike;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface MatrixSyncEvent {
|
||||
type?: string;
|
||||
sender?: string;
|
||||
origin_server_ts?: number;
|
||||
content?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MatrixSyncResponse {
|
||||
next_batch?: string;
|
||||
rooms?: { join?: Record<string, { timeline?: { events?: MatrixSyncEvent[] } }> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete Matrix CS-API transport for the native provider. It authenticates
|
||||
* the configured sender before each operation and resolves rooms exclusively
|
||||
* from configured bindings, preventing client-selected room/identity routing.
|
||||
*/
|
||||
export class MatrixNativeRuntimeTransport {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: MatrixFetchLike;
|
||||
private readonly now: () => Date;
|
||||
private readonly bindings: ReadonlyMap<string, MatrixNativeRuntimeSessionBinding>;
|
||||
|
||||
constructor(private readonly options: MatrixNativeRuntimeTransportOptions) {
|
||||
this.baseUrl = validatedHomeserverUrl(options.homeserverUrl);
|
||||
if (!options.accessToken.trim()) {
|
||||
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix access token is required');
|
||||
}
|
||||
if (!options.userId.trim()) {
|
||||
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix user identity is required');
|
||||
}
|
||||
this.bindings = new Map(
|
||||
options.sessions.map((binding) => [binding.id, Object.freeze({ ...binding })]),
|
||||
);
|
||||
if (this.bindings.size !== options.sessions.length) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'invalid_request',
|
||||
'Matrix runtime session IDs must be unique',
|
||||
);
|
||||
}
|
||||
this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as MatrixFetchLike);
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
try {
|
||||
const versions = await this.request('/_matrix/client/versions', { method: 'GET' });
|
||||
if (!versions.ok) {
|
||||
return this.healthResult('down', `versions HTTP ${versions.status}`);
|
||||
}
|
||||
await this.assertIdentity();
|
||||
return this.healthResult('healthy');
|
||||
} catch (error: unknown) {
|
||||
return this.healthResult('down', message(error));
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(scope: RuntimeScope): Promise<MatrixNativeRuntimeSessionBinding[]> {
|
||||
await this.assertIdentity();
|
||||
void scope;
|
||||
return [...this.bindings.values()].map((binding) => ({ ...binding }));
|
||||
}
|
||||
|
||||
async verifySession(
|
||||
sessionId: string,
|
||||
scope: RuntimeScope,
|
||||
): Promise<MatrixNativeRuntimeSessionBinding> {
|
||||
await this.assertIdentity();
|
||||
void scope;
|
||||
const binding = this.bindings.get(sessionId);
|
||||
if (!binding) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'not_found',
|
||||
'Matrix runtime session is not configured',
|
||||
);
|
||||
}
|
||||
return { ...binding };
|
||||
}
|
||||
|
||||
async *stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
const binding = await this.verifySession(sessionId, scope);
|
||||
const query = new URLSearchParams({ timeout: '0' });
|
||||
if (cursor?.trim()) query.set('since', cursor);
|
||||
const response = await this.request(`/_matrix/client/v3/sync?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Matrix sync failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as MatrixSyncResponse;
|
||||
const nextCursor = payload.next_batch?.trim() || cursor?.trim() || 'initial';
|
||||
const events = payload.rooms?.join?.[binding.roomId]?.timeline?.events ?? [];
|
||||
for (const event of events) {
|
||||
const normalized = normalizeEvent(event, binding, nextCursor);
|
||||
if (normalized) yield normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
||||
const binding = await this.verifySession(sessionId, scope);
|
||||
const txnId = transactionId('send', binding.id, message.idempotencyKey);
|
||||
const response = await this.request(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({
|
||||
msgtype: 'm.text',
|
||||
body: message.content,
|
||||
'mosaic.runtime.v1': metadata(binding, scope, message.idempotencyKey),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Matrix message delivery failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
const binding = await this.verifySession(sessionId, scope);
|
||||
const txnId = transactionId('terminate', binding.id, `${approvalRef}:${scope.correlationId}`);
|
||||
const response = await this.request(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/mosaic.runtime.terminate/${encodeURIComponent(txnId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify({
|
||||
...metadata(binding, scope),
|
||||
approval_ref: approvalRef,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Matrix termination delivery failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertIdentity(): Promise<void> {
|
||||
let response: Awaited<ReturnType<MatrixFetchLike>>;
|
||||
try {
|
||||
response = await this.request('/_matrix/client/v3/account/whoami', {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw new MatrixRuntimeTransportError('unavailable', message(error));
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'forbidden',
|
||||
`Matrix whoami failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as { user_id?: unknown };
|
||||
if (body.user_id !== this.options.userId) {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'forbidden',
|
||||
'Matrix whoami identity does not match configuration',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private request(
|
||||
path: string,
|
||||
init: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
) {
|
||||
return this.fetchImpl(`${this.baseUrl}${path}`, init);
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.options.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private healthResult(status: RuntimeHealth['status'], detail?: string): RuntimeHealth {
|
||||
return { status, checkedAt: this.now().toISOString(), ...(detail ? { detail } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
function metadata(
|
||||
binding: MatrixNativeRuntimeSessionBinding,
|
||||
scope: RuntimeScope,
|
||||
idempotencyKey?: string,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
session_id: binding.id,
|
||||
runtime_id: binding.runtimeId,
|
||||
actor_id: scope.actorId,
|
||||
tenant_id: scope.tenantId,
|
||||
channel_id: scope.channelId,
|
||||
correlation_id: scope.correlationId,
|
||||
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function transactionId(kind: 'send' | 'terminate', sessionId: string, identity: string): string {
|
||||
return `mosaic-${kind}-${createHash('sha256').update(`${sessionId}\u0000${identity}`).digest('base64url')}`;
|
||||
}
|
||||
|
||||
function normalizeEvent(
|
||||
event: MatrixSyncEvent,
|
||||
binding: MatrixNativeRuntimeSessionBinding,
|
||||
cursor: string,
|
||||
): RuntimeStreamEvent | undefined {
|
||||
if (event.type !== 'mosaic.runtime.event' || event.sender !== binding.remoteUserId)
|
||||
return undefined;
|
||||
const payload = event.content?.['mosaic.runtime.v1'];
|
||||
if (
|
||||
!isRecord(payload) ||
|
||||
payload['session_id'] !== binding.id ||
|
||||
typeof payload['type'] !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const occurredAt =
|
||||
typeof payload['occurred_at'] === 'string'
|
||||
? payload['occurred_at']
|
||||
: new Date(event.origin_server_ts ?? 0).toISOString();
|
||||
const eventCursor = typeof payload['cursor'] === 'string' ? payload['cursor'] : cursor;
|
||||
switch (payload['type']) {
|
||||
case 'session.state':
|
||||
return isState(payload['state'])
|
||||
? {
|
||||
type: 'session.state',
|
||||
sessionId: binding.id,
|
||||
cursor: eventCursor,
|
||||
occurredAt,
|
||||
state: payload['state'],
|
||||
}
|
||||
: undefined;
|
||||
case 'message.delta':
|
||||
return typeof payload['content'] === 'string'
|
||||
? {
|
||||
type: 'message.delta',
|
||||
sessionId: binding.id,
|
||||
cursor: eventCursor,
|
||||
occurredAt,
|
||||
content: payload['content'],
|
||||
}
|
||||
: undefined;
|
||||
case 'message.complete':
|
||||
return typeof payload['message_id'] === 'string'
|
||||
? {
|
||||
type: 'message.complete',
|
||||
sessionId: binding.id,
|
||||
cursor: eventCursor,
|
||||
occurredAt,
|
||||
messageId: payload['message_id'],
|
||||
}
|
||||
: undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isState(value: unknown): value is RuntimeSessionState {
|
||||
return (
|
||||
value === 'starting' ||
|
||||
value === 'active' ||
|
||||
value === 'idle' ||
|
||||
value === 'stopped' ||
|
||||
value === 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
function validatedHomeserverUrl(value: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'invalid_request',
|
||||
'Matrix homeserver URL must be absolute',
|
||||
);
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
throw new MatrixRuntimeTransportError(
|
||||
'invalid_request',
|
||||
'Matrix homeserver URL must use HTTPS',
|
||||
);
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Matrix transport request failed';
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readPersonaContractBlock } from './persona-contract.js';
|
||||
|
||||
/**
|
||||
* Persona-contract launch injection (A3b). Asserts the override-aware resolver
|
||||
* is wired so a customized persona in roles.local/ wins at launch (AC-NS-7), and
|
||||
* that any miss (unset/empty/unknown class, missing file) no-ops silently —
|
||||
* never throws — mirroring readFleetCommsBlock's tolerant contract.
|
||||
*/
|
||||
|
||||
const BASELINE_CODER = `# Coder — fleet role definition
|
||||
|
||||
The **coder** persona (\`class: coder\`, \`domain: engineering\`).
|
||||
|
||||
## Mandate
|
||||
|
||||
BASELINE-MANDATE: implement the assigned lane.
|
||||
`;
|
||||
|
||||
const OVERRIDE_CODER = `# Coder — fleet role definition (override)
|
||||
|
||||
The **coder** persona (\`class: coder\`).
|
||||
|
||||
## Mandate
|
||||
|
||||
OVERRIDE-MANDATE: implement the assigned lane, the user's way.
|
||||
`;
|
||||
|
||||
function makeHome(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-persona-'));
|
||||
return join(root, 'mosaic-home');
|
||||
}
|
||||
|
||||
function seedBaseline(home: string, klass: string, body: string): void {
|
||||
const dir = join(home, 'fleet', 'roles');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${klass}.md`), body);
|
||||
}
|
||||
|
||||
function seedOverride(home: string, klass: string, body: string): void {
|
||||
const dir = join(home, 'fleet', 'roles.local');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${klass}.md`), body);
|
||||
}
|
||||
|
||||
describe('readPersonaContractBlock — launch-time persona injection (A3b)', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = makeHome();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// root is the parent of mosaic-home
|
||||
rmSync(join(home, '..'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects the baseline persona when the class has a fleet/roles/<class>.md', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
const block = readPersonaContractBlock(home, 'coder');
|
||||
expect(block).toContain('# Persona Contract (coder)');
|
||||
expect(block).toContain('BASELINE-MANDATE');
|
||||
expect(block).toContain('baseline `fleet/roles/` layer');
|
||||
});
|
||||
|
||||
it('OVERRIDE WINS: roles.local/<class>.md content is injected over the baseline (AC-NS-7)', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
seedOverride(home, 'coder', OVERRIDE_CODER);
|
||||
const block = readPersonaContractBlock(home, 'coder');
|
||||
expect(block).toContain('# Persona Contract (coder)');
|
||||
expect(block).toContain('OVERRIDE-MANDATE'); // override body present
|
||||
expect(block).not.toContain('BASELINE-MANDATE'); // baseline NOT used
|
||||
expect(block).toContain('roles.local'); // layer note names the override layer
|
||||
});
|
||||
|
||||
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
||||
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
|
||||
const block = readPersonaContractBlock(home, 'mascot');
|
||||
expect(block).toContain('# Persona Contract (mascot)');
|
||||
expect(block).toContain('CUSTOM-ROLE');
|
||||
});
|
||||
|
||||
it('canonicalizes an approved alias before launch-time override lookup', () => {
|
||||
seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n');
|
||||
seedOverride(
|
||||
home,
|
||||
'implementer',
|
||||
'# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n',
|
||||
);
|
||||
const block = readPersonaContractBlock(home, 'implementer');
|
||||
expect(block).toContain('# Persona Contract (code)');
|
||||
expect(block).toContain('CANONICAL-CODE');
|
||||
expect(block).not.toContain('LEGACY-OVERRIDE');
|
||||
});
|
||||
|
||||
it('no-ops (empty string) when the class is undefined', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('no-ops (empty string) when the class is empty/whitespace', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
expect(readPersonaContractBlock(home, '')).toBe('');
|
||||
expect(readPersonaContractBlock(home, ' ')).toBe('');
|
||||
});
|
||||
|
||||
it('no-ops (empty string) for an unknown class with no role file', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
expect(readPersonaContractBlock(home, 'nonexistent')).toBe('');
|
||||
});
|
||||
|
||||
it('no-ops (empty string, no throw) when no roles directories exist at all', () => {
|
||||
expect(() => readPersonaContractBlock(home, 'coder')).not.toThrow();
|
||||
expect(readPersonaContractBlock(home, 'coder')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Persona-contract injection at launch (North Star A3b).
|
||||
*
|
||||
* A spawned fleet agent should boot already knowing WHO it is: its class's role
|
||||
* contract (mandate + boundaries). The companion goal A3a exports the agent's
|
||||
* resolved class into the pane env as `MOSAIC_AGENT_CLASS`; here we read that
|
||||
* class at launch (composeContract → system prompt) and inject the resolved
|
||||
* persona contract so the identity is resident from the agent's first turn.
|
||||
*
|
||||
* OVERRIDE-AWARE: resolution goes through fleet-personas' resolver, so a
|
||||
* user-customized persona in the PRESERVE-protected `fleet/roles.local/` layer
|
||||
* WINS over the baseline `fleet/roles/` of the same class. That is the
|
||||
* launch-time proof of AC-NS-7 — a customized persona actually reaches the model
|
||||
* when the agent boots, not just in `mosaic fleet persona show`.
|
||||
*
|
||||
* Tolerant by contract (mirrors readFleetCommsBlock): an empty/missing class, an
|
||||
* unknown class, or a missing role file all yield '' so the launcher no-ops
|
||||
* silently. This MUST never throw during launch.
|
||||
*
|
||||
* Standalone module (no fleet.ts import) to keep launch.ts's prompt path free of
|
||||
* the heavy fleet command module; it depends only on the lightweight persona
|
||||
* resolver.
|
||||
*/
|
||||
|
||||
import {
|
||||
resolvePersonaSync,
|
||||
defaultRolesDir,
|
||||
defaultOverrideDir,
|
||||
} from '../commands/fleet-personas.js';
|
||||
|
||||
/**
|
||||
* Resolve `klass`'s persona contract (override-aware) and render it as a
|
||||
* clearly-delimited launch block. Returns '' on any miss (falsy class, unknown
|
||||
* class, missing/unreadable file) so composeContract can push it unconditionally
|
||||
* and have it no-op silently. Never throws.
|
||||
*/
|
||||
export function readPersonaContractBlock(mosaicHome: string, klass: string | undefined): string {
|
||||
if (!klass || !klass.trim()) return '';
|
||||
let resolved: ReturnType<typeof resolvePersonaSync>;
|
||||
try {
|
||||
resolved = resolvePersonaSync(klass.trim(), {
|
||||
rolesDir: defaultRolesDir(mosaicHome),
|
||||
overrideDir: defaultOverrideDir(mosaicHome),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort onboarding: a resolver hiccup must not abort the launch.
|
||||
return '';
|
||||
}
|
||||
if (!resolved) return '';
|
||||
|
||||
const layerNote =
|
||||
resolved.layer === 'override'
|
||||
? '_(resolved from the `fleet/roles.local/` override layer — wins over baseline)_'
|
||||
: '_(resolved from the baseline `fleet/roles/` layer)_';
|
||||
|
||||
return `# Persona Contract (${resolved.klass})
|
||||
|
||||
${layerNote}
|
||||
|
||||
You are operating as the **${resolved.klass}** persona. The role contract below is your identity — its mandate and boundaries govern what you own and what you must not do for this assignment.
|
||||
|
||||
${resolved.content.trim()}`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
|
||||
import { TmuxPromotionTransport } from './promotion-transport.js';
|
||||
|
||||
const sessionId = 'a'.repeat(64);
|
||||
const roster: FleetRoster = {
|
||||
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
||||
defaults: { workingDirectory: '~/src' },
|
||||
runtimes: {},
|
||||
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
||||
transport: 'tmux',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
|
||||
return { exitCode, stderr, stdout };
|
||||
}
|
||||
|
||||
describe('TmuxPromotionTransport', () => {
|
||||
it('resolves the exact roster seat and sends the registered command literally', async () => {
|
||||
const runner = vi
|
||||
.fn<CommandRunner>()
|
||||
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
|
||||
.mockResolvedValueOnce(result())
|
||||
.mockResolvedValueOnce(result());
|
||||
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
const target = await transport.resolve('claude-seat');
|
||||
await transport.sendPromotion(target);
|
||||
|
||||
expect(target).toEqual({
|
||||
bundle: 'mosaic-fleet',
|
||||
seat: 'claude-seat',
|
||||
sessionId,
|
||||
});
|
||||
expect(environmentReader).toHaveBeenCalledWith(1234);
|
||||
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'send-keys',
|
||||
'-t',
|
||||
'=claude-seat:0.0',
|
||||
'-l',
|
||||
'/mosaic-promote',
|
||||
]);
|
||||
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'send-keys',
|
||||
'-t',
|
||||
'=claude-seat:0.0',
|
||||
'Enter',
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
|
||||
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
|
||||
// transport must WALK the subtree. This test exercises the real walk (no
|
||||
// full mock of the resolution) — the seam the original unit test hid.
|
||||
it('walks the pane subtree to the claude child that carries the lease id', async () => {
|
||||
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
|
||||
const environmentReader = vi.fn(async (pid: number) =>
|
||||
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
|
||||
);
|
||||
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
childrenReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
const target = await transport.resolve('claude-seat');
|
||||
|
||||
expect(target.sessionId).toBe(sessionId);
|
||||
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
|
||||
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
|
||||
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
|
||||
});
|
||||
|
||||
it('fails closed when no process in the pane subtree carries a lease id', async () => {
|
||||
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
||||
const environmentReader = vi.fn(async () => `FOO=bar\0`);
|
||||
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
||||
const transport = new TmuxPromotionTransport({
|
||||
environmentReader,
|
||||
childrenReader,
|
||||
mosaicHome: '/mosaic',
|
||||
rosterLoader: async () => roster,
|
||||
runner,
|
||||
});
|
||||
|
||||
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
|
||||
expect(childrenReader).toHaveBeenCalledWith(1234);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import {
|
||||
buildTmuxListPanesCommand,
|
||||
getRosterAgent,
|
||||
parseTmuxListPanes,
|
||||
resolveFleetPaths,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
type FleetRoster,
|
||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||
socketArgs,
|
||||
} from '../commands/fleet.js';
|
||||
import { loadFleetRoster } from './fleet-roster-v1.js';
|
||||
|
||||
const PROMOTION_COMMAND = '/mosaic-promote';
|
||||
const SESSION_ID_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const TRANSPORT_COMMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface PromotionTarget {
|
||||
bundle: string;
|
||||
seat: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface PromotionTransport {
|
||||
resolve(seat: string): Promise<PromotionTarget>;
|
||||
sendPromotion(target: PromotionTarget): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TmuxPromotionTransportOptions {
|
||||
environmentReader?: (pid: number) => Promise<string>;
|
||||
childrenReader?: (pid: number) => Promise<number[]>;
|
||||
mosaicHome: string;
|
||||
rosterLoader?: () => Promise<FleetRoster>;
|
||||
runner: CommandRunner;
|
||||
}
|
||||
|
||||
// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see
|
||||
// launch.ts:99 — deliberate, so the parent survives to propagate signals), so
|
||||
// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root
|
||||
// pid. Bound the descendant search so a hung/large process tree can't stall it.
|
||||
const MAX_SUBTREE_PIDS = 128;
|
||||
|
||||
/** Local, roster-bound transport for the in-seat promotion command. */
|
||||
export class TmuxPromotionTransport implements PromotionTransport {
|
||||
private readonly environmentReader: (pid: number) => Promise<string>;
|
||||
private readonly childrenReader: (pid: number) => Promise<number[]>;
|
||||
private readonly rosterLoader: () => Promise<FleetRoster>;
|
||||
|
||||
constructor(private readonly options: TmuxPromotionTransportOptions) {
|
||||
this.environmentReader = options.environmentReader ?? readPaneEnvironment;
|
||||
this.childrenReader = options.childrenReader ?? readChildPids;
|
||||
this.rosterLoader =
|
||||
options.rosterLoader ??
|
||||
(() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath));
|
||||
}
|
||||
|
||||
async resolve(seat: string): Promise<PromotionTarget> {
|
||||
const roster = await this.rosterLoader();
|
||||
const agent = getRosterAgent(roster, seat);
|
||||
if (agent.runtime !== 'claude') {
|
||||
throw new Error(`Lease promotion is currently available only for Claude seats: ${seat}.`);
|
||||
}
|
||||
const paneResult = await this.run(
|
||||
buildTmuxListPanesCommand(agent.name, roster.tmux.socketName),
|
||||
);
|
||||
if (paneResult.exitCode !== 0) {
|
||||
throw new Error(`Promotion seat is unavailable: ${seat}.`);
|
||||
}
|
||||
const pane = parseTmuxListPanes(paneResult.stdout);
|
||||
const allowedCommands = RUNTIME_ACCEPTABLE_COMMANDS.claude;
|
||||
if (
|
||||
pane.dead ||
|
||||
pane.pid === null ||
|
||||
pane.command === null ||
|
||||
allowedCommands === undefined ||
|
||||
!allowedCommands.includes(pane.command)
|
||||
) {
|
||||
throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`);
|
||||
}
|
||||
const sessionId = await this.resolveLeaseSessionId(pane.pid);
|
||||
if (sessionId === null) {
|
||||
throw new Error(`Promotion seat has no readable lease session: ${seat}.`);
|
||||
}
|
||||
return {
|
||||
bundle: roster.tmux.socketName || 'default',
|
||||
seat: agent.name,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the lease session id in the pane's process subtree. The pane's root pid
|
||||
* is node(mosaic), which has no lease env; the id lives on the claude child.
|
||||
* BFS from the root, bounded, returning the first descendant that carries a
|
||||
* valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found.
|
||||
*/
|
||||
private async resolveLeaseSessionId(rootPid: number): Promise<string | null> {
|
||||
const queue: number[] = [rootPid];
|
||||
const seen = new Set<number>();
|
||||
while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) {
|
||||
const pid = queue.shift()!;
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
let sessionId: string | null = null;
|
||||
try {
|
||||
sessionId = parseLeaseSessionId(await this.environmentReader(pid));
|
||||
} catch {
|
||||
sessionId = null;
|
||||
}
|
||||
if (sessionId !== null) return sessionId;
|
||||
let children: number[] = [];
|
||||
try {
|
||||
children = await this.childrenReader(pid);
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
for (const child of children) {
|
||||
if (!seen.has(child)) queue.push(child);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendPromotion(target: PromotionTarget): Promise<void> {
|
||||
const targetPane = `=${target.seat}:0.0`;
|
||||
const socketName = target.bundle === 'default' ? '' : target.bundle;
|
||||
// Registered Claude commands must arrive as their exact literal text; the
|
||||
// fleet agent sender prepends an identity envelope, so it cannot carry this
|
||||
// command without preventing the UserPromptSubmit matcher from recognizing it.
|
||||
await this.runPromotionCommand([
|
||||
'tmux',
|
||||
...socketArgs(socketName),
|
||||
'send-keys',
|
||||
'-t',
|
||||
targetPane,
|
||||
'-l',
|
||||
PROMOTION_COMMAND,
|
||||
]);
|
||||
await this.runPromotionCommand([
|
||||
'tmux',
|
||||
...socketArgs(socketName),
|
||||
'send-keys',
|
||||
'-t',
|
||||
targetPane,
|
||||
'Enter',
|
||||
]);
|
||||
}
|
||||
|
||||
private async runPromotionCommand(command: string[]): Promise<void> {
|
||||
const result = await this.run(command);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Promotion command delivery failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private async run(command: string[]): Promise<CommandResult> {
|
||||
const [executable, ...args] = command;
|
||||
if (executable === undefined) {
|
||||
throw new Error('Promotion transport command is empty.');
|
||||
}
|
||||
return await withTimeout(this.options.runner(executable, args), TRANSPORT_COMMAND_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`Promotion transport command timed out after ${timeoutMs}ms.`));
|
||||
}, timeoutMs);
|
||||
void operation.then(
|
||||
(value) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readPaneEnvironment(pid: number): Promise<string> {
|
||||
return readFile(`/proc/${pid}/environ`, 'utf8');
|
||||
}
|
||||
|
||||
async function readChildPids(pid: number): Promise<number[]> {
|
||||
// Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN).
|
||||
try {
|
||||
const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8');
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseLeaseSessionId(environment: string): string | null {
|
||||
const value = environment
|
||||
.split('\0')
|
||||
.find((entry) => entry.startsWith('MOSAIC_LEASE_SESSION_ID='))
|
||||
?.slice('MOSAIC_LEASE_SESSION_ID='.length);
|
||||
return value !== undefined && SESSION_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROSTER_V2_JSON_SCHEMA,
|
||||
RosterV2ValidationError,
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
validateRosterV2Semantics,
|
||||
} from './roster-v2.js';
|
||||
|
||||
const validRoster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: coder0
|
||||
alias: Coder 0
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: ~/src
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let semanticTmp: string | undefined;
|
||||
|
||||
it('preserves an explicit empty socket as the literal default tmux server', () => {
|
||||
const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''"));
|
||||
expect(roster.tmux.socketName).toBe('');
|
||||
expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""');
|
||||
});
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||
semanticTmp = undefined;
|
||||
});
|
||||
|
||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
|
||||
const rolesDir = join(semanticTmp, 'roles');
|
||||
const overrideDir = join(semanticTmp, 'roles.local');
|
||||
await mkdir(rolesDir, { recursive: true });
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
for (const klass of [
|
||||
'code',
|
||||
'review',
|
||||
'interaction',
|
||||
'orchestrator',
|
||||
'merge-gate',
|
||||
'validator',
|
||||
'team-leader',
|
||||
]) {
|
||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
|
||||
}
|
||||
return { rolesDir, overrideDir };
|
||||
}
|
||||
|
||||
function rosterWithClass(klass: string, toolPolicy = klass): string {
|
||||
return validRoster
|
||||
.replace('class: code', `class: ${klass}`)
|
||||
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
|
||||
}
|
||||
|
||||
describe('roster v2 semantic validation', (): void => {
|
||||
it.each([
|
||||
['implementer', 'code'],
|
||||
['reviewer', 'review'],
|
||||
['operator-interaction', 'interaction'],
|
||||
])(
|
||||
'canonicalizes requested alias %s while retaining requested and canonical class',
|
||||
async (requested: string, canonical: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
|
||||
const validated = await validateRosterV2Semantics(roster, dirs);
|
||||
expect(validated.agents[0]).toMatchObject({
|
||||
requestedClass: requested,
|
||||
canonicalClass: canonical,
|
||||
canonicalToolPolicy: canonical,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['worker', 'analyst', 'canary'])(
|
||||
'rejects %s when no genuine custom role exists',
|
||||
async (klass: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
|
||||
).rejects.toThrow(/unresolved|readable persona/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a genuine custom roles.local class without protected authority', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
|
||||
const validated = await validateRosterV2Semantics(
|
||||
parseRosterV2(rosterWithClass('worker'), 'yaml'),
|
||||
dirs,
|
||||
);
|
||||
expect(validated.agents[0]?.authority).toMatchObject({
|
||||
mayMerge: false,
|
||||
mayOrchestrate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(
|
||||
join(dirs.rolesDir, 'LIBRARY.md'),
|
||||
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
|
||||
'utf8',
|
||||
);
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it('rejects an unreadable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await mkdir(join(dirs.overrideDir, 'worker.md'));
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['merge-gate', 'code'],
|
||||
['validator', 'merge-gate'],
|
||||
['orchestrator', 'interaction'],
|
||||
['team-leader', 'orchestrator'],
|
||||
['interaction', 'orchestrator'],
|
||||
['code', 'merge-gate'],
|
||||
['worker', 'validator'],
|
||||
])(
|
||||
'denies protected class/tool-policy mismatch %s with %s',
|
||||
async (klass: string, toolPolicy: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
if (klass === 'worker') {
|
||||
await writeFile(
|
||||
join(dirs.overrideDir, 'worker.md'),
|
||||
'# worker\n\n(`class: worker`)\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
|
||||
).rejects.toThrow(/tool policy.*must match|mismatch/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('roster v2 structural compiler', (): void => {
|
||||
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
||||
const roster = parseRosterV2(validRoster, 'yaml');
|
||||
|
||||
expect(roster).toEqual({
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '~/src', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '~/src',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart());
|
||||
});
|
||||
|
||||
it('parses JSON and produces the same normalized model', (): void => {
|
||||
const yaml = parseRosterV2(validRoster, 'yaml');
|
||||
const json = JSON.stringify({
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
|
||||
defaults: { working_directory: '~/src', runtime: 'pi' },
|
||||
runtimes: { pi: { reset_command: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
class: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
tool_policy: 'code',
|
||||
working_directory: '~/src',
|
||||
persistent_persona: false,
|
||||
reset_between_tasks: true,
|
||||
lifecycle: { enabled: true, desired_state: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parseRosterV2(json, 'json')).toEqual(yaml);
|
||||
});
|
||||
|
||||
it('sorts runtime and agent maps in the deterministic renderer', (): void => {
|
||||
const roster = parseRosterV2(
|
||||
validRoster
|
||||
.replace(
|
||||
'runtimes:\n pi:\n reset_command: /new',
|
||||
'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear',
|
||||
)
|
||||
.replace(
|
||||
'agents:\n - name: coder0',
|
||||
'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
|
||||
),
|
||||
'yaml',
|
||||
);
|
||||
|
||||
const rendered = renderRosterV2Yaml(roster);
|
||||
expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:'));
|
||||
expect(rendered.indexOf(' - name: coder0')).toBeLessThan(
|
||||
rendered.indexOf(' - name: reviewer'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i],
|
||||
[
|
||||
'unknown connector',
|
||||
`${validRoster}\nconnector:\n kind: matrix\n`,
|
||||
/unsupported field.*connector/i,
|
||||
],
|
||||
[
|
||||
'remote host',
|
||||
validRoster.replace(
|
||||
'runtime: pi\n provider',
|
||||
'runtime: pi\n host: remote\n provider',
|
||||
),
|
||||
/unsupported field.*host/i,
|
||||
],
|
||||
[
|
||||
'secret reference',
|
||||
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'),
|
||||
/unsupported field.*secret_ref/i,
|
||||
],
|
||||
[
|
||||
'channel override',
|
||||
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'),
|
||||
/unsupported field.*channels/i,
|
||||
],
|
||||
[
|
||||
'arbitrary command',
|
||||
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'),
|
||||
/unsupported field.*command/i,
|
||||
],
|
||||
[
|
||||
'gateway field',
|
||||
`${validRoster}\ngateway:\n url: https://gateway.example\n`,
|
||||
/unsupported field.*gateway/i,
|
||||
],
|
||||
[
|
||||
'missing required field',
|
||||
validRoster.replace(' model: gpt-5.6-sol\n', ''),
|
||||
/model.*required/i,
|
||||
],
|
||||
[
|
||||
'invalid type',
|
||||
validRoster.replace('generation: 7', 'generation: seven'),
|
||||
/generation.*integer/i,
|
||||
],
|
||||
[
|
||||
'unsafe generation',
|
||||
validRoster.replace('generation: 7', 'generation: 9007199254740992'),
|
||||
/generation.*integer/i,
|
||||
],
|
||||
[
|
||||
'duplicate names',
|
||||
validRoster.replace(
|
||||
' - name: coder0',
|
||||
' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
|
||||
),
|
||||
/duplicate agent name/i,
|
||||
],
|
||||
['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i],
|
||||
[
|
||||
'invalid transport',
|
||||
validRoster.replace('transport: tmux', 'transport: matrix'),
|
||||
/transport.*tmux/i,
|
||||
],
|
||||
[
|
||||
'invalid runtime',
|
||||
validRoster.replace('runtime: pi', 'runtime: matrix'),
|
||||
/runtime.*supported/i,
|
||||
],
|
||||
[
|
||||
'invalid reasoning',
|
||||
validRoster.replace('reasoning: high', 'reasoning: extreme'),
|
||||
/reasoning.*low.*medium.*high/i,
|
||||
],
|
||||
[
|
||||
'ambiguous socket',
|
||||
validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'),
|
||||
/socket_name/i,
|
||||
],
|
||||
[
|
||||
'agent socket override',
|
||||
validRoster.replace(
|
||||
'runtime: pi\n provider',
|
||||
'runtime: pi\n socket: another\n provider',
|
||||
),
|
||||
/unsupported field.*socket/i,
|
||||
],
|
||||
])('rejects %s', (_name: string, source: string, expected: RegExp): void => {
|
||||
expect((): void => {
|
||||
parseRosterV2(source, 'yaml');
|
||||
}).toThrow(expected);
|
||||
});
|
||||
|
||||
it('rejects malformed JSON as a validation error', (): void => {
|
||||
expect((): void => {
|
||||
parseRosterV2('{', 'json');
|
||||
}).toThrow(RosterV2ValidationError);
|
||||
});
|
||||
|
||||
it('declares supported runtime map keys in the executable schema', (): void => {
|
||||
expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({
|
||||
properties: {
|
||||
runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise<void> => {
|
||||
const schemaPath = fileURLToPath(
|
||||
new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url),
|
||||
);
|
||||
const documented = await readFile(schemaPath, 'utf8');
|
||||
|
||||
expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,571 @@
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
authorityForCanonicalClass,
|
||||
canonicalizeRoleClass,
|
||||
defaultOverrideDir,
|
||||
defaultRolesDir,
|
||||
extractClassesFromDir,
|
||||
resolvePersonaFrom,
|
||||
type PersonaDirs,
|
||||
type PersonaResolution,
|
||||
type RoleAuthority,
|
||||
} from '../commands/fleet-personas.js';
|
||||
import { compareCodePoints } from './deterministic-order.js';
|
||||
|
||||
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
||||
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||
export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const;
|
||||
|
||||
export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number];
|
||||
export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number];
|
||||
export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number];
|
||||
export type RosterV2InputFormat = 'json' | 'yaml';
|
||||
|
||||
export interface FleetRosterV2Tmux {
|
||||
readonly socketName: string;
|
||||
readonly holderSession: string;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2Defaults {
|
||||
readonly workingDirectory: string;
|
||||
readonly runtime: RosterV2RuntimeName;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2Runtime {
|
||||
readonly resetCommand: string;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2Lifecycle {
|
||||
readonly enabled: boolean;
|
||||
readonly desiredState: RosterV2DesiredState;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2Launch {
|
||||
readonly yolo: boolean;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2Agent {
|
||||
readonly name: string;
|
||||
readonly alias: string;
|
||||
readonly className: string;
|
||||
readonly runtime: RosterV2RuntimeName;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly reasoning: RosterV2ReasoningLevel;
|
||||
readonly toolPolicy: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly persistentPersona: boolean;
|
||||
readonly resetBetweenTasks: boolean;
|
||||
readonly lifecycle: FleetRosterV2Lifecycle;
|
||||
readonly launch: FleetRosterV2Launch;
|
||||
}
|
||||
|
||||
export interface FleetRosterV2 {
|
||||
readonly version: 2;
|
||||
readonly generation: number;
|
||||
readonly transport: 'tmux';
|
||||
readonly tmux: FleetRosterV2Tmux;
|
||||
readonly defaults: FleetRosterV2Defaults;
|
||||
readonly runtimes: Readonly<Record<string, FleetRosterV2Runtime>>;
|
||||
readonly agents: readonly FleetRosterV2Agent[];
|
||||
}
|
||||
|
||||
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
|
||||
readonly requestedClass: string;
|
||||
readonly canonicalClass: string;
|
||||
readonly canonicalToolPolicy: string;
|
||||
readonly persona: PersonaResolution;
|
||||
readonly authority: RoleAuthority;
|
||||
}
|
||||
|
||||
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
|
||||
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
|
||||
}
|
||||
|
||||
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
|
||||
'merge-gate',
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'interaction',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate filesystem-backed roster semantics after synchronous structural parsing.
|
||||
* Directory scans are batched once and every class must resolve to readable content.
|
||||
*/
|
||||
export async function validateRosterV2Semantics(
|
||||
roster: FleetRosterV2,
|
||||
opts: PersonaDirs = {},
|
||||
): Promise<SemanticallyValidatedRosterV2> {
|
||||
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
|
||||
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
|
||||
const agents: SemanticallyValidatedRosterV2Agent[] = [];
|
||||
for (const agent of roster.agents) {
|
||||
const requestedClass = agent.className;
|
||||
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
||||
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
|
||||
const persona = await resolvePersonaFrom(requestedClass, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
});
|
||||
if (!persona || persona.content.trim() === '') {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
|
||||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
|
||||
canonicalToolPolicy !== canonicalClass
|
||||
) {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
|
||||
);
|
||||
}
|
||||
agents.push(
|
||||
Object.freeze({
|
||||
...agent,
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
canonicalToolPolicy,
|
||||
persona,
|
||||
authority: authorityForCanonicalClass(canonicalClass),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
|
||||
}
|
||||
|
||||
export class RosterV2ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RosterV2ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema };
|
||||
|
||||
/**
|
||||
* Executable v2 structural contract. The checked-in documentation schema is
|
||||
* structurally compared to this value in roster-v2.spec.ts.
|
||||
*/
|
||||
export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
$id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json',
|
||||
title: 'Mosaic local tmux fleet roster v2',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'],
|
||||
properties: {
|
||||
version: { const: 2 },
|
||||
generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
|
||||
transport: { const: 'tmux' },
|
||||
tmux: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['socket_name', 'holder_session'],
|
||||
properties: {
|
||||
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' },
|
||||
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['working_directory', 'runtime'],
|
||||
properties: {
|
||||
working_directory: { type: 'string', minLength: 1 },
|
||||
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
||||
},
|
||||
},
|
||||
runtimes: {
|
||||
type: 'object',
|
||||
minProperties: 1,
|
||||
propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['reset_command'],
|
||||
properties: { reset_command: { type: 'string', minLength: 1 } },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
'name',
|
||||
'alias',
|
||||
'class',
|
||||
'runtime',
|
||||
'provider',
|
||||
'model',
|
||||
'reasoning',
|
||||
'tool_policy',
|
||||
'working_directory',
|
||||
'persistent_persona',
|
||||
'reset_between_tasks',
|
||||
'lifecycle',
|
||||
'launch',
|
||||
],
|
||||
properties: {
|
||||
name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' },
|
||||
alias: { type: 'string', minLength: 1 },
|
||||
class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
||||
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
|
||||
provider: { type: 'string', minLength: 1 },
|
||||
model: { type: 'string', minLength: 1 },
|
||||
reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] },
|
||||
tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
|
||||
working_directory: { type: 'string', minLength: 1 },
|
||||
persistent_persona: { type: 'boolean' },
|
||||
reset_between_tasks: { type: 'boolean' },
|
||||
lifecycle: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['enabled', 'desired_state'],
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] },
|
||||
},
|
||||
},
|
||||
launch: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['yolo'],
|
||||
properties: { yolo: { type: 'boolean' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'];
|
||||
const TMUX_KEYS = ['socket_name', 'holder_session'];
|
||||
const DEFAULT_KEYS = ['working_directory', 'runtime'];
|
||||
const RUNTIME_KEYS = ['reset_command'];
|
||||
const AGENT_KEYS = [
|
||||
'name',
|
||||
'alias',
|
||||
'class',
|
||||
'runtime',
|
||||
'provider',
|
||||
'model',
|
||||
'reasoning',
|
||||
'tool_policy',
|
||||
'working_directory',
|
||||
'persistent_persona',
|
||||
'reset_between_tasks',
|
||||
'lifecycle',
|
||||
'launch',
|
||||
];
|
||||
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
|
||||
const LAUNCH_KEYS = ['yolo'];
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/;
|
||||
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
|
||||
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */
|
||||
export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 {
|
||||
const parsed = parseSource(source, format);
|
||||
return normalizeRosterV2(parsed);
|
||||
}
|
||||
|
||||
/** Renders canonical snake_case YAML with sorted runtime and agent entries. */
|
||||
export function renderRosterV2Yaml(roster: FleetRosterV2): string {
|
||||
const normalized = normalizeRosterV2(toSourceShape(roster));
|
||||
return YAML.stringify(toSourceShape(normalized));
|
||||
}
|
||||
|
||||
function parseSource(source: string, format?: RosterV2InputFormat): unknown {
|
||||
const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml');
|
||||
try {
|
||||
if (resolvedFormat === 'json') return JSON.parse(source) as unknown;
|
||||
return YAML.parse(source) as unknown;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRosterV2(raw: unknown): FleetRosterV2 {
|
||||
const root = requiredObject(raw, 'Roster v2');
|
||||
assertKnownKeys(root, 'Roster v2', ROOT_KEYS);
|
||||
if (root.version === 1) {
|
||||
throw new RosterV2ValidationError(
|
||||
'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.',
|
||||
);
|
||||
}
|
||||
if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.');
|
||||
|
||||
const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation');
|
||||
const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const);
|
||||
const tmux = normalizeTmux(root.tmux);
|
||||
const defaults = normalizeDefaults(root.defaults);
|
||||
const runtimes = normalizeRuntimes(root.runtimes);
|
||||
const agents = normalizeAgents(root.agents, runtimes);
|
||||
|
||||
if (!runtimes[defaults.runtime]) {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`,
|
||||
);
|
||||
}
|
||||
return { version: 2, generation, transport, tmux, defaults, runtimes, agents };
|
||||
}
|
||||
|
||||
function normalizeTmux(value: unknown): FleetRosterV2Tmux {
|
||||
const raw = requiredObject(value, 'Roster v2 tmux');
|
||||
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
|
||||
return {
|
||||
socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'),
|
||||
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaults(value: unknown): FleetRosterV2Defaults {
|
||||
const raw = requiredObject(value, 'Roster v2 defaults');
|
||||
assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS);
|
||||
return {
|
||||
workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'),
|
||||
runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV2Runtime>> {
|
||||
const raw = requiredObject(value, 'Roster v2 runtimes');
|
||||
const names = Object.keys(raw);
|
||||
if (names.length === 0)
|
||||
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
|
||||
|
||||
const result: Record<string, FleetRosterV2Runtime> = {};
|
||||
for (const name of names.sort(compareCodePoints)) {
|
||||
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
|
||||
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
|
||||
result[runtime] = {
|
||||
resetCommand: requiredString(
|
||||
config.reset_command,
|
||||
`Roster v2 runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAgents(
|
||||
value: unknown,
|
||||
runtimes: Readonly<Record<string, FleetRosterV2Runtime>>,
|
||||
): readonly FleetRosterV2Agent[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => {
|
||||
const raw = requiredObject(candidate, `Roster v2 agents[${index}]`);
|
||||
assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS);
|
||||
const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`);
|
||||
if (seen.has(name))
|
||||
throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`);
|
||||
seen.add(name);
|
||||
const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`);
|
||||
if (!runtimes[runtime]) {
|
||||
throw new RosterV2ValidationError(
|
||||
`Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`),
|
||||
className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`),
|
||||
runtime,
|
||||
provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`),
|
||||
model: requiredString(raw.model, `Roster v2 agent "${name}" model`),
|
||||
reasoning: requiredEnum(
|
||||
raw.reasoning,
|
||||
`Roster v2 agent "${name}" reasoning`,
|
||||
ROSTER_V2_REASONING_LEVELS,
|
||||
),
|
||||
toolPolicy: requiredPolicyIdentifier(
|
||||
raw.tool_policy,
|
||||
`Roster v2 agent "${name}" tool_policy`,
|
||||
),
|
||||
workingDirectory: requiredString(
|
||||
raw.working_directory,
|
||||
`Roster v2 agent "${name}" working_directory`,
|
||||
),
|
||||
persistentPersona: requiredBoolean(
|
||||
raw.persistent_persona,
|
||||
`Roster v2 agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: requiredBoolean(
|
||||
raw.reset_between_tasks,
|
||||
`Roster v2 agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
lifecycle: normalizeLifecycle(raw.lifecycle, name),
|
||||
launch: normalizeLaunch(raw.launch, name),
|
||||
};
|
||||
});
|
||||
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
||||
compareCodePoints(left.name, right.name),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle {
|
||||
const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`);
|
||||
assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS);
|
||||
return {
|
||||
enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`),
|
||||
desiredState: requiredEnum(
|
||||
raw.desired_state,
|
||||
`Roster v2 agent "${agentName}" lifecycle desired_state`,
|
||||
ROSTER_V2_DESIRED_STATES,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch {
|
||||
const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`);
|
||||
assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS);
|
||||
return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) };
|
||||
}
|
||||
|
||||
function requiredObject(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new RosterV2ValidationError(`${label} must be an object.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredIdentifier(value: unknown, label: string): string {
|
||||
const result = requiredString(value, label);
|
||||
if (!IDENTIFIER.test(result)) {
|
||||
throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredTmuxSocket(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new RosterV2ValidationError(`${label} is required and must be a string.`);
|
||||
}
|
||||
const result = value.trim();
|
||||
if (!TMUX_SOCKET_IDENTIFIER.test(result)) {
|
||||
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredTmuxIdentifier(value: unknown, label: string): string {
|
||||
const result = requiredString(value, label);
|
||||
if (!TMUX_IDENTIFIER.test(result))
|
||||
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredPolicyIdentifier(value: unknown, label: string): string {
|
||||
const result = requiredString(value, label);
|
||||
if (!POLICY_IDENTIFIER.test(result))
|
||||
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
||||
throw new RosterV2ValidationError(`${label} must be a positive integer.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredBoolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName {
|
||||
return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES);
|
||||
}
|
||||
|
||||
function requiredEnum<T extends string>(value: unknown, label: string, allowed: readonly T[]): T {
|
||||
if (typeof value !== 'string' || !allowed.includes(value as T)) {
|
||||
throw new RosterV2ValidationError(
|
||||
`${label} must be one of the supported values: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
||||
return {
|
||||
version: roster.version,
|
||||
generation: roster.generation,
|
||||
transport: roster.transport,
|
||||
tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession },
|
||||
defaults: {
|
||||
working_directory: roster.defaults.workingDirectory,
|
||||
runtime: roster.defaults.runtime,
|
||||
},
|
||||
runtimes: Object.fromEntries(
|
||||
Object.entries(roster.runtimes)
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([name, runtime]): [string, unknown] => [
|
||||
name,
|
||||
{ reset_command: runtime.resetCommand },
|
||||
]),
|
||||
),
|
||||
agents: [...roster.agents]
|
||||
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
||||
compareCodePoints(left.name, right.name),
|
||||
)
|
||||
.map(
|
||||
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
|
||||
name: agent.name,
|
||||
alias: agent.alias,
|
||||
class: agent.className,
|
||||
runtime: agent.runtime,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
reasoning: agent.reasoning,
|
||||
tool_policy: agent.toolPolicy,
|
||||
working_directory: agent.workingDirectory,
|
||||
persistent_persona: agent.persistentPersona,
|
||||
reset_between_tasks: agent.resetBetweenTasks,
|
||||
lifecycle: {
|
||||
enabled: agent.lifecycle.enabled,
|
||||
desired_state: agent.lifecycle.desiredState,
|
||||
},
|
||||
launch: { yolo: agent.launch.yolo },
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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 type { Stats } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
afterStat?: (path: string, stats: Stats) => Stats;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
statSync: (path: PathLike) => {
|
||||
const result = actual.statSync(path);
|
||||
return filesystemRaceState.afterStat?.(String(path), result) ?? 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;
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects canonical path escape', () => {
|
||||
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||
'path escapes managed root',
|
||||
);
|
||||
});
|
||||
|
||||
// stack#1380: the guard resolves symlinks and validates the resolved target
|
||||
// instead of refusing any symlink component.
|
||||
it('permits a symlink ancestor whose resolved target is inside the root', () => {
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'external\n');
|
||||
symlinkSync(external, join(root, 'linked'));
|
||||
|
||||
const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root });
|
||||
expect(snapshot.content.toString('utf8')).toBe('external\n');
|
||||
});
|
||||
|
||||
it('permits a symlinked file whose resolved target is inside the root', () => {
|
||||
const external = join(root, 'external-file');
|
||||
writeFileSync(external, 'external\n');
|
||||
symlinkSync(external, join(root, 'linked-file'));
|
||||
|
||||
const snapshot = readRegularFileSecure(join(root, 'linked-file'), { root });
|
||||
expect(snapshot.content.toString('utf8')).toBe('external\n');
|
||||
});
|
||||
|
||||
it('permits a symlink resolving into an additional sanctioned root (split-home roster shape)', () => {
|
||||
const brain = `${root}-brain`;
|
||||
mkdirSync(join(brain, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(brain, 'fleet', 'roster.yaml'), 'roster\n', { mode: 0o600 });
|
||||
mkdirSync(join(root, 'fleet'));
|
||||
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
|
||||
|
||||
const snapshot = readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), {
|
||||
root,
|
||||
symlinkTargetRoots: [brain],
|
||||
});
|
||||
expect(snapshot.content.toString('utf8')).toBe('roster\n');
|
||||
});
|
||||
|
||||
it('refuses a symlink whose resolved target escapes every sanctioned root', () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'mosaic-secure-outside-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(outside, 'roster.yaml'), 'escaped\n', { mode: 0o600 });
|
||||
symlinkSync(join(outside, 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), { root })).toThrow(
|
||||
/symlink target escapes managed roots/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a group-writable symlink target', () => {
|
||||
const loose = join(root, 'loose');
|
||||
mkdirSync(loose);
|
||||
chmodSync(loose, 0o770); // group-writable bit survives umask via explicit chmod
|
||||
writeFileSync(join(loose, 'file'), 'loose\n');
|
||||
symlinkSync(loose, join(root, 'linked-loose'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-loose', 'file'), { root })).toThrow(
|
||||
/group- or world-writable/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a symlink target owned by another user', () => {
|
||||
const external = join(root, 'foreign');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'foreign\n');
|
||||
symlinkSync(external, join(root, 'linked-foreign'));
|
||||
|
||||
filesystemRaceState.afterStat = (path, stats): Stats => {
|
||||
if (resolve(path) === resolve(external)) {
|
||||
return { ...stats, uid: stats.uid + 4242 } as Stats;
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
try {
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-foreign', 'file'), { root })).toThrow(
|
||||
/not owned by the current user/,
|
||||
);
|
||||
} finally {
|
||||
filesystemRaceState.afterStat = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
root: string;
|
||||
maxBytes?: number;
|
||||
executable?: boolean;
|
||||
/**
|
||||
* Additional roots a symlink component may resolve into (stack#1380).
|
||||
* Default: only the managed root itself. Every symlink hop is validated —
|
||||
* containment under the root or one of these roots, current-user ownership,
|
||||
* no group/world-writable mode — and refusal stays the default for anything
|
||||
* else. Callers that operate the split-home layout pass the brain home so
|
||||
* the framework-created roster symlink resolves.
|
||||
*/
|
||||
symlinkTargetRoots?: string[];
|
||||
}
|
||||
|
||||
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 containedUnder(root: string, target: string): boolean {
|
||||
const rel = relative(resolve(root), resolve(target));
|
||||
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) && rel !== '';
|
||||
}
|
||||
|
||||
const MAX_SYMLINK_HOPS = 40;
|
||||
|
||||
/**
|
||||
* Resolve every symlink on `lexical` component-wise, validating each hop
|
||||
* (stack#1380 resolve-then-validate): the hop target must stay under one of
|
||||
* the sanctioned roots, must be owned by the current user (or root), and must
|
||||
* not be group- or world-writable. Returns a symlink-free absolute path.
|
||||
*/
|
||||
function resolveRealPath(lexical: string, sanctionedRoots: string[]): string {
|
||||
const hopTargets: string[] = [];
|
||||
let current: string = sep;
|
||||
for (const piece of resolve(lexical).split(sep).filter(Boolean)) {
|
||||
current = resolve(current, piece);
|
||||
for (let hops = 0; lstatSync(current).isSymbolicLink(); ) {
|
||||
if (++hops > MAX_SYMLINK_HOPS) {
|
||||
throw new Error(`symlink chain exceeds ${MAX_SYMLINK_HOPS} hops: ${lexical}`);
|
||||
}
|
||||
const linkTarget = readlinkSync(current);
|
||||
const absolute = resolve(dirname(current), linkTarget);
|
||||
if (!sanctionedRoots.some((root) => containedUnder(root, absolute))) {
|
||||
throw new Error(
|
||||
`symlink target escapes managed roots [${sanctionedRoots.join(', ')}]: ${absolute}`,
|
||||
);
|
||||
}
|
||||
hopTargets.push(absolute);
|
||||
current = absolute;
|
||||
}
|
||||
}
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||
for (const hop of hopTargets) {
|
||||
const stat = statSync(hop);
|
||||
if (stat.uid !== uid && stat.uid !== 0) {
|
||||
throw new Error(`symlink target is not owned by the current user: ${hop}`);
|
||||
}
|
||||
if (stat.mode & 0o022) {
|
||||
throw new Error(`symlink target is group- or world-writable: ${hop}`);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(
|
||||
root: string,
|
||||
target: string,
|
||||
symlinkTargetRoots: 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');
|
||||
|
||||
// stack#1380: resolve-then-validate. The lexical path must name the managed
|
||||
// root (above); symlink components are then resolved hop-by-hop under the
|
||||
// sanctioned roots (validated per hop), and the descriptor traversal walks
|
||||
// the symlink-free real path — keeping the O_NOFOLLOW chain as the race
|
||||
// guard for anything substituted after resolution.
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))];
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = resolveRealPath(canonicalTarget, sanctioned);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !('code' in error)) throw error;
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!sanctioned.some((sr) => containedUnder(sr, realTarget) || resolve(sr) === realTarget)) {
|
||||
throw new Error(
|
||||
`resolved path escapes managed roots [${sanctioned.join(', ')}]: ${realTarget}`,
|
||||
);
|
||||
}
|
||||
|
||||
const chain = openDirectoryChain(dirname(realTarget));
|
||||
try {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(
|
||||
procDescriptorPath(chain.fd, basename(realTarget)),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||
}
|
||||
chain.descriptors.push(fd);
|
||||
return { fd, descriptors: chain.descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(chain.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, options.symlinkTargetRoots ?? []);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js';
|
||||
import {
|
||||
provisionInteractionService,
|
||||
readInteractionServiceProfile,
|
||||
} from './interaction-service-profile.js';
|
||||
import type { InteractionServiceProfileError } from './interaction-service-profile.js';
|
||||
|
||||
const frameworkFleet = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'framework',
|
||||
'fleet',
|
||||
);
|
||||
const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml');
|
||||
const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml');
|
||||
const policyScript = join(
|
||||
frameworkFleet,
|
||||
'..',
|
||||
'tools',
|
||||
'fleet',
|
||||
'print-interaction-effective-policy.sh',
|
||||
);
|
||||
const interactionStartScript = join(
|
||||
frameworkFleet,
|
||||
'..',
|
||||
'tools',
|
||||
'fleet',
|
||||
'start-interaction-service.sh',
|
||||
);
|
||||
const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe('operator interaction service profile', (): void => {
|
||||
it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise<void> => {
|
||||
const profile = await readInteractionServiceProfile(profilePath);
|
||||
const provisioned = provisionInteractionService(profile, { agentName: 'Nova' });
|
||||
|
||||
expect(provisioned.rosterAgent).toEqual({
|
||||
name: 'Nova',
|
||||
runtime: 'pi',
|
||||
className: 'operator-interaction',
|
||||
modelHint: 'openai/gpt-5.6-sol',
|
||||
reasoningLevel: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
persistentPersona: true,
|
||||
});
|
||||
expect(provisioned.effectivePolicy).toEqual({
|
||||
agentName: 'Nova',
|
||||
runtime: 'pi',
|
||||
model: 'openai/gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
});
|
||||
expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch(
|
||||
/token|secret|credential|api.?key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise<void> => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-'));
|
||||
const rosterPath = join(directory, 'roster.yaml');
|
||||
try {
|
||||
const example = await readFile(examplePath, 'utf8');
|
||||
await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8');
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
const agent = roster.agents[0]!;
|
||||
|
||||
expect(agent.name).toBe('Nova');
|
||||
expect(agent.reasoningLevel).toBe('high');
|
||||
expect(agent.toolPolicy).toBe('operator-interaction');
|
||||
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova');
|
||||
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high');
|
||||
|
||||
const { stdout } = await execFileAsync(policyScript, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
agentName: 'Nova',
|
||||
runtime: 'pi',
|
||||
model: 'openai/gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails before launch when service environment drifts from the pinned policy', async (): Promise<void> => {
|
||||
await expect(
|
||||
execFileAsync(interactionStartScript, ['Nova'], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: 'Nova',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'other/model',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
});
|
||||
|
||||
it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise<void> => {
|
||||
await expect(
|
||||
execFileAsync(policyScript, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: 'Nova"bad',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
await expect(
|
||||
execFileAsync(agentStartScript, ['Nova'], {
|
||||
env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
});
|
||||
|
||||
it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise<void> => {
|
||||
const [profile, source, example] = await Promise.all([
|
||||
readFile(profilePath, 'utf8'),
|
||||
readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'),
|
||||
readFile(examplePath, 'utf8'),
|
||||
]);
|
||||
|
||||
expect(profile).not.toMatch(/tess/i);
|
||||
expect(source).not.toMatch(/tess/i);
|
||||
expect(example).toMatch(/name: Tess/);
|
||||
});
|
||||
|
||||
it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise<void> => {
|
||||
await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({
|
||||
code: 'invalid_profile',
|
||||
} satisfies Partial<InteractionServiceProfileError>);
|
||||
await expect(
|
||||
readInteractionServiceProfile(profilePath, { reasoning: 'medium' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_profile',
|
||||
} satisfies Partial<InteractionServiceProfileError>);
|
||||
});
|
||||
});
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
|
||||
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
|
||||
import type { FleetRuntimeTransportError } from './tmux-runtime-transport.js';
|
||||
|
||||
const roster: FleetRoster = {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'tess-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '~/src' },
|
||||
runtimes: { codex: { resetCommand: '/clear' } },
|
||||
agents: [{ name: 'coder0', runtime: 'codex', className: 'code' }],
|
||||
};
|
||||
|
||||
function commandResult(stdout = '', exitCode = 0, stderr = ''): CommandResult {
|
||||
return { stdout, stderr, exitCode };
|
||||
}
|
||||
|
||||
describe('FleetTmuxRuntimeTransport security boundary', (): void => {
|
||||
it('rejects a prefix target before it invokes tmux', async (): Promise<void> => {
|
||||
const runner = vi.fn<CommandRunner>(async (): Promise<CommandResult> => commandResult());
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await expect(transport.verifySession('coder')).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
} satisfies Partial<FleetRuntimeTransportError>);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the roster socket and exact pane target while verifying peer identity', async (): Promise<void> => {
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (): Promise<CommandResult> => commandResult('111 codex 0 0 0 0\n'),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await expect(transport.verifySession('coder0')).resolves.toEqual({
|
||||
id: 'coder0',
|
||||
runtimeId: 'codex',
|
||||
socketName: 'tess-fleet',
|
||||
});
|
||||
expect(runner).toHaveBeenCalledWith('tmux', [
|
||||
'-L',
|
||||
'tess-fleet',
|
||||
'list-panes',
|
||||
'-t',
|
||||
'=coder0:0.0',
|
||||
'-F',
|
||||
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies a live pane whose runtime does not match the roster identity', async (): Promise<void> => {
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (): Promise<CommandResult> => commandResult('111 python3 0 0 0 0\n'),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await expect(transport.verifySession('coder0')).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeTransportError>);
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces a runtime identity mismatch instead of hiding it from fleet status', async (): Promise<void> => {
|
||||
const rosterWithMismatchedPeer: FleetRoster = {
|
||||
...roster,
|
||||
agents: [...roster.agents, { name: 'coder1', runtime: 'codex', className: 'code' }],
|
||||
};
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (_command: string, args: string[]): Promise<CommandResult> =>
|
||||
commandResult(
|
||||
args.includes('=coder1:0.0') ? '222 python3 0 0 0 0\n' : '111 codex 0 0 0 0\n',
|
||||
),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => rosterWithMismatchedPeer,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await expect(transport.listSessions()).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeTransportError>);
|
||||
});
|
||||
|
||||
it('denies sending when the roster socket does not contain the exact target', async (): Promise<void> => {
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (): Promise<CommandResult> => commandResult('', 1, "can't find session: coder0"),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await expect(transport.sendMessage('coder0', 'hello', 'tess')).rejects.toMatchObject({
|
||||
code: 'unavailable',
|
||||
} satisfies Partial<FleetRuntimeTransportError>);
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends only through the maintained sender after exact target and identity verification', async (): Promise<void> => {
|
||||
const runner = vi
|
||||
.fn<CommandRunner>()
|
||||
.mockResolvedValueOnce(commandResult('111 codex 0 0 0 0\n'))
|
||||
.mockResolvedValueOnce(commandResult());
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await transport.sendMessage('coder0', 'hello', 'tess');
|
||||
|
||||
expect(runner).toHaveBeenNthCalledWith(2, '/mosaic/tools/tmux/agent-send.sh', [
|
||||
'-L',
|
||||
'tess-fleet',
|
||||
'-S',
|
||||
'tess',
|
||||
'-s',
|
||||
'coder0',
|
||||
'-m',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('terminates only the exact roster target after identity verification', async (): Promise<void> => {
|
||||
const runner = vi
|
||||
.fn<CommandRunner>()
|
||||
.mockResolvedValueOnce(commandResult('111 codex 0 0 0 0\n'))
|
||||
.mockResolvedValueOnce(commandResult());
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
rosterLoader: async (): Promise<FleetRoster> => roster,
|
||||
runner,
|
||||
mosaicHome: '/mosaic',
|
||||
});
|
||||
|
||||
await transport.terminate('coder0');
|
||||
|
||||
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
|
||||
'-L',
|
||||
'tess-fleet',
|
||||
'kill-session',
|
||||
'-t',
|
||||
'=coder0',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
buildAgentSendCommand,
|
||||
buildTmuxListPanesCommand,
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
parseTmuxListPanes,
|
||||
resolveFleetPaths,
|
||||
RUNTIME_ACCEPTABLE_COMMANDS,
|
||||
socketArgs,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
type FleetRoster,
|
||||
} from '../commands/fleet.js';
|
||||
|
||||
export type FleetRuntimeTransportErrorCode =
|
||||
| 'forbidden'
|
||||
| 'invalid_request'
|
||||
| 'not_found'
|
||||
| 'unavailable';
|
||||
|
||||
/** A roster-bound, verified tmux target. It never accepts a caller-selected socket. */
|
||||
export interface FleetRuntimeTarget {
|
||||
id: string;
|
||||
runtimeId: string;
|
||||
socketName: string;
|
||||
}
|
||||
|
||||
/** Narrow transport boundary consumed by the runtime provider. */
|
||||
export interface FleetRuntimeTransport {
|
||||
verifySession(sessionId: string): Promise<FleetRuntimeTarget>;
|
||||
listSessions(): Promise<FleetRuntimeTarget[]>;
|
||||
sendMessage(sessionId: string, message: string, sourceLabel: string): Promise<void>;
|
||||
terminate(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FleetTmuxRuntimeTransportOptions {
|
||||
mosaicHome: string;
|
||||
rosterPath?: string;
|
||||
rosterLoader?: () => Promise<FleetRoster>;
|
||||
runner: CommandRunner;
|
||||
}
|
||||
|
||||
/** A typed, fail-closed error for roster, target, identity, and tmux failures. */
|
||||
export class FleetRuntimeTransportError extends Error {
|
||||
constructor(
|
||||
readonly code: FleetRuntimeTransportErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetRuntimeTransportError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roster-bound transport for the local fleet tmux server. Every side-effecting
|
||||
* operation verifies an exact roster name, the roster socket, and the runtime
|
||||
* command in the exact pane before it invokes the maintained sender or tmux.
|
||||
*/
|
||||
export class FleetTmuxRuntimeTransport implements FleetRuntimeTransport {
|
||||
private readonly rosterLoader: () => Promise<FleetRoster>;
|
||||
|
||||
constructor(private readonly options: FleetTmuxRuntimeTransportOptions) {
|
||||
this.rosterLoader =
|
||||
options.rosterLoader ??
|
||||
(() =>
|
||||
loadFleetRoster(options.rosterPath ?? resolveFleetPaths(options.mosaicHome).rosterPath));
|
||||
}
|
||||
|
||||
async verifySession(sessionId: string): Promise<FleetRuntimeTarget> {
|
||||
const roster = await this.loadRoster();
|
||||
return this.verifyRosterSession(roster, sessionId);
|
||||
}
|
||||
|
||||
async listSessions(): Promise<FleetRuntimeTarget[]> {
|
||||
const roster = await this.loadRoster();
|
||||
const targets = await Promise.all(
|
||||
roster.agents.map(async (agent): Promise<FleetRuntimeTarget | undefined> => {
|
||||
try {
|
||||
return await this.verifyRosterSession(roster, agent.name);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetRuntimeTransportError && error.code === 'unavailable') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return targets.filter((target): target is FleetRuntimeTarget => target !== undefined);
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, message: string, sourceLabel: string): Promise<void> {
|
||||
if (message.length === 0) {
|
||||
throw new FleetRuntimeTransportError('invalid_request', 'Fleet message content is required');
|
||||
}
|
||||
if (sourceLabel.length === 0) {
|
||||
throw new FleetRuntimeTransportError(
|
||||
'invalid_request',
|
||||
'Fleet message source label is required',
|
||||
);
|
||||
}
|
||||
const target = await this.verifySession(sessionId);
|
||||
const command = buildAgentSendCommand(
|
||||
resolveFleetPaths(this.options.mosaicHome),
|
||||
target.id,
|
||||
message,
|
||||
target.socketName,
|
||||
sourceLabel,
|
||||
);
|
||||
await this.runChecked(command, 'Fleet message delivery failed');
|
||||
}
|
||||
|
||||
async terminate(sessionId: string): Promise<void> {
|
||||
const target = await this.verifySession(sessionId);
|
||||
await this.runChecked(
|
||||
['tmux', ...socketArgs(target.socketName), 'kill-session', '-t', `=${target.id}`],
|
||||
'Fleet session termination failed',
|
||||
);
|
||||
}
|
||||
|
||||
private async loadRoster(): Promise<FleetRoster> {
|
||||
try {
|
||||
return await this.rosterLoader();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Fleet roster is unavailable';
|
||||
throw new FleetRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Fleet roster is unavailable: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyRosterSession(
|
||||
roster: FleetRoster,
|
||||
sessionId: string,
|
||||
): Promise<FleetRuntimeTarget> {
|
||||
const agent = this.exactRosterAgent(roster, sessionId);
|
||||
const socketName = roster.tmux.socketName;
|
||||
const result = await this.run(buildTmuxListPanesCommand(agent.name, socketName));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new FleetRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Fleet session is unavailable: ${agent.name}`,
|
||||
);
|
||||
}
|
||||
const pane = parseTmuxListPanes(result.stdout);
|
||||
if (pane.dead || pane.command === null) {
|
||||
throw new FleetRuntimeTransportError(
|
||||
'unavailable',
|
||||
`Fleet session is unavailable: ${agent.name}`,
|
||||
);
|
||||
}
|
||||
if (!hasExactRuntimeIdentity(agent.runtime, pane.command)) {
|
||||
throw new FleetRuntimeTransportError(
|
||||
'forbidden',
|
||||
`Fleet runtime identity mismatch: ${agent.name}`,
|
||||
);
|
||||
}
|
||||
return { id: agent.name, runtimeId: agent.runtime, socketName };
|
||||
}
|
||||
|
||||
private exactRosterAgent(roster: FleetRoster, sessionId: string): FleetRoster['agents'][number] {
|
||||
try {
|
||||
const agent = getRosterAgent(roster, sessionId);
|
||||
if (agent.name !== sessionId) {
|
||||
throw new FleetRuntimeTransportError('not_found', 'Fleet session target must be exact');
|
||||
}
|
||||
return agent;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetRuntimeTransportError) {
|
||||
throw error;
|
||||
}
|
||||
throw new FleetRuntimeTransportError('not_found', 'Fleet session target is not roster-bound');
|
||||
}
|
||||
}
|
||||
|
||||
private async run(command: string[]): Promise<CommandResult> {
|
||||
const [executable, ...args] = command;
|
||||
if (executable === undefined) {
|
||||
throw new FleetRuntimeTransportError('invalid_request', 'Fleet command is required');
|
||||
}
|
||||
try {
|
||||
return await this.options.runner(executable, args);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Fleet command failed';
|
||||
throw new FleetRuntimeTransportError('unavailable', message);
|
||||
}
|
||||
}
|
||||
|
||||
private async runChecked(command: string[], failureMessage: string): Promise<void> {
|
||||
const result = await this.run(command);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new FleetRuntimeTransportError('unavailable', failureMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactRuntimeIdentity(runtimeId: string, paneCommand: string): boolean {
|
||||
const expectedCommands = RUNTIME_ACCEPTABLE_COMMANDS[runtimeId];
|
||||
return expectedCommands !== undefined && expectedCommands.includes(paneCommand);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user