feat(fleet): enforce generated environment boundary (#772)
This commit was merged in pull request #772.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
@@ -284,6 +284,8 @@ describe('fleet roster parsing', () => {
|
||||
'MOSAIC_AGENT_CLASS=implementer',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_MODEL=',
|
||||
'MOSAIC_AGENT_REASONING=',
|
||||
'MOSAIC_AGENT_TOOL_POLICY=',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
@@ -325,57 +327,38 @@ describe('fleet roster parsing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
||||
const generated = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
].join('\n');
|
||||
const existing = [
|
||||
'MOSAIC_AGENT_NAME=old-name',
|
||||
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
||||
'MOSAIC_TMUX_SOCKET=old-socket',
|
||||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||
'# site note',
|
||||
'',
|
||||
].join('\n');
|
||||
it('rejects legacy command overrides instead of merging them into generated authority', () => {
|
||||
const generated = generateAgentEnv(
|
||||
{
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/new' },
|
||||
runtimes: {},
|
||||
agents: [],
|
||||
},
|
||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
||||
);
|
||||
|
||||
expect(mergeAgentEnv(generated, existing)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||
'# site note',
|
||||
'',
|
||||
].join('\n'),
|
||||
expect((): string => mergeAgentEnv(generated, 'MOSAIC_AGENT_COMMAND=legacy-command\n')).toThrow(
|
||||
/MOSAIC_AGENT_COMMAND/,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
|
||||
const generated = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=orchestrator',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'',
|
||||
].join('\n');
|
||||
const existing = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=worker',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'',
|
||||
].join('\n');
|
||||
it('does not merge a valid local value into the generated projection', () => {
|
||||
const generated = generateAgentEnv(
|
||||
{
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/new' },
|
||||
runtimes: {},
|
||||
agents: [],
|
||||
},
|
||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
||||
);
|
||||
|
||||
const merged = mergeAgentEnv(generated, existing);
|
||||
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
|
||||
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
|
||||
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
|
||||
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
|
||||
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
|
||||
expect(mergeAgentEnv(generated, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n')).toBe(generated);
|
||||
});
|
||||
|
||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||
@@ -1129,6 +1112,92 @@ describe('fleet command construction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('creates private managed directories and roster/projection files through fresh init and install under umask 022', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMosaicHome = process.env.MOSAIC_HOME;
|
||||
const originalUmask = process.umask(0o022);
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'minimal', '--write']),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
for (const path of [
|
||||
mosaicHome,
|
||||
join(mosaicHome, 'fleet'),
|
||||
join(mosaicHome, 'fleet', 'agents'),
|
||||
]) {
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
expect((await stat(join(mosaicHome, 'fleet', 'roster.yaml'))).mode & 0o777).toBe(0o600);
|
||||
expect(
|
||||
(await stat(join(mosaicHome, 'fleet', 'agents', 'canary-pi.env.generated'))).mode & 0o777,
|
||||
).toBe(0o600);
|
||||
} finally {
|
||||
process.umask(originalUmask);
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||
else process.env.MOSAIC_HOME = originalMosaicHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a private real projection directory and private generated files on fresh install', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(mosaicHome, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await writeFile(
|
||||
join(mosaicHome, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: code',
|
||||
].join('\n'),
|
||||
);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
mosaicHome,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const directoryMetadata = await lstat(agentEnvDir);
|
||||
expect(directoryMetadata.isDirectory()).toBe(true);
|
||||
expect(directoryMetadata.isSymbolicLink()).toBe(false);
|
||||
expect(directoryMetadata.mode & 0o777).toBe(0o700);
|
||||
expect((await stat(join(agentEnvDir, 'coder0.env.generated'))).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||||
'rejects single-agent %s for agents outside the roster',
|
||||
async (action) => {
|
||||
@@ -1687,8 +1756,10 @@ describe('fleet ps — drift detection', () => {
|
||||
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||
async function rosterHome(agents: string): Promise<string> {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||
const fleetDir = join(home, 'fleet');
|
||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(fleetDir, 0o700);
|
||||
await writeFile(join(fleetDir, 'roster.yaml'), agents);
|
||||
return home;
|
||||
}
|
||||
|
||||
@@ -3459,7 +3530,11 @@ describe('fleet add command', () => {
|
||||
|
||||
async function makeHome(agents = ['orchestrator']): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
|
||||
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
|
||||
const fleetDir = join(dir, 'fleet');
|
||||
const agentEnvDir = join(fleetDir, 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentEnvDir, 0o700);
|
||||
const agentLines = agents.map((name) => {
|
||||
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
|
||||
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
|
||||
@@ -3493,11 +3568,110 @@ describe('fleet add command', () => {
|
||||
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toContain('coder0');
|
||||
|
||||
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||
const envContent = await readFile(
|
||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'utf8',
|
||||
);
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||
});
|
||||
|
||||
it('rejects an unprojectable dogfood runtime without mutating roster or projection state', async () => {
|
||||
home = await makeHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const trackedPaths = [
|
||||
rosterPath,
|
||||
join(agentEnvDir, 'dogfood.env.generated'),
|
||||
join(agentEnvDir, 'dogfood.env.local'),
|
||||
join(agentEnvDir, 'dogfood.env.quarantine'),
|
||||
];
|
||||
await writeFile(trackedPaths[1]!, 'generated-before\n');
|
||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
||||
await writeFile(trackedPaths[3]!, 'quarantine-before\n');
|
||||
const beforeState = new Map<string, Buffer>();
|
||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'dogfood',
|
||||
'--runtime',
|
||||
'dogfood',
|
||||
'--class',
|
||||
'worker',
|
||||
'--no-start',
|
||||
]),
|
||||
).rejects.toThrow(/runtime/i);
|
||||
|
||||
for (const path of trackedPaths) {
|
||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects supported add with a deterministic quarantine conflict before roster persistence', async () => {
|
||||
home = await makeHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const trackedPaths = [
|
||||
rosterPath,
|
||||
join(agentEnvDir, 'coder1.env.generated'),
|
||||
join(agentEnvDir, 'coder1.env.local'),
|
||||
join(agentEnvDir, 'coder1.env'),
|
||||
join(agentEnvDir, 'coder1.env.quarantine'),
|
||||
];
|
||||
await writeFile(trackedPaths[1]!, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
||||
await writeFile(trackedPaths[3]!, 'MOSAIC_AGENT_COMMAND=must-be-quarantined\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(trackedPaths[4]!, 'quarantine-before\n', { mode: 0o600 });
|
||||
const beforeState = new Map<string, Buffer>();
|
||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'coder1',
|
||||
'--runtime',
|
||||
'codex',
|
||||
'--class',
|
||||
'worker',
|
||||
'--no-start',
|
||||
]),
|
||||
).rejects.toThrow('quarantine-exists');
|
||||
|
||||
for (const path of trackedPaths) {
|
||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('--no-start skips the start command', async () => {
|
||||
home = await makeHome();
|
||||
const calls: string[][] = [];
|
||||
@@ -3659,7 +3833,10 @@ describe('fleet remove command', () => {
|
||||
].join('\n'),
|
||||
);
|
||||
// Create env and heartbeat files for coder0
|
||||
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
|
||||
await writeFile(
|
||||
join(dir, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'MOSAIC_AGENT_NAME=coder0\n',
|
||||
);
|
||||
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
|
||||
return dir;
|
||||
}
|
||||
@@ -3738,12 +3915,15 @@ describe('fleet remove command', () => {
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
|
||||
|
||||
// Env file should still exist
|
||||
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||
// Generated projection should still exist.
|
||||
const envContent = await readFile(
|
||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'utf8',
|
||||
);
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
});
|
||||
|
||||
it('env file is removed by default (no --keep-files)', async () => {
|
||||
it('generated projection is removed by default (no --keep-files)', async () => {
|
||||
home = await makeHome();
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const program = new Command();
|
||||
@@ -3752,7 +3932,9 @@ describe('fleet remove command', () => {
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||
|
||||
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||
await expect(
|
||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
|
||||
|
||||
@@ -19,6 +19,16 @@ import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
ensureFleetHolderIdentity,
|
||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
||||
parseAgentEnvironment,
|
||||
prepareAgentEnvironmentProjection,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from '../fleet/generated-env-boundary.js';
|
||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||
@@ -509,50 +519,35 @@ export async function generateNorthStarMarkdown(repoRoot?: string): Promise<stri
|
||||
}
|
||||
|
||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return [
|
||||
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
|
||||
// matching persona contract for the agent's class (default `worker`).
|
||||
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
|
||||
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
|
||||
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||
...(agent.reasoningLevel !== undefined
|
||||
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||
: []),
|
||||
...(agent.toolPolicy !== undefined
|
||||
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||
: []),
|
||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
return renderGeneratedAgentEnvironment(generateAgentEnvValues(roster, agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility shim for callers that previously merged site-owned data into a
|
||||
* generated file. Local data is validated but never appended to the generated
|
||||
* projection, preventing a local override from becoming hidden authority.
|
||||
*/
|
||||
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
||||
if (!existingEnv?.trim()) {
|
||||
return generatedEnv;
|
||||
}
|
||||
const generatedKeys = new Set(
|
||||
generatedEnv
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1])
|
||||
.filter((key): key is string => key !== undefined),
|
||||
);
|
||||
const preservedLines = existingEnv.split('\n').filter((line) => {
|
||||
if (!line.trim()) {
|
||||
return false;
|
||||
}
|
||||
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
|
||||
return key === undefined || !generatedKeys.has(key);
|
||||
});
|
||||
if (preservedLines.length === 0) {
|
||||
return generatedEnv;
|
||||
}
|
||||
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n');
|
||||
parseAgentEnvironment(generatedEnv, 'generated');
|
||||
if (existingEnv?.trim()) parseAgentEnvironment(existingEnv, 'local');
|
||||
return generatedEnv;
|
||||
}
|
||||
|
||||
function generateAgentEnvValues(
|
||||
roster: FleetRoster,
|
||||
agent: FleetAgent,
|
||||
): Readonly<Record<string, string>> {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||
@@ -1544,8 +1539,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
if (resolve(destination) === resolve(activePaths.rosterPath)) {
|
||||
await writeManagedFleetRoster(activePaths.mosaicHome, destination, content);
|
||||
} else {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
}
|
||||
|
||||
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||
@@ -1948,15 +1947,17 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
};
|
||||
|
||||
const updatedRoster = addAgentToRoster(roster, newAgent);
|
||||
const generated = generateAgentEnvValues(updatedRoster, newAgent);
|
||||
// Preflight every deterministic projection, local, legacy, quarantine,
|
||||
// and managed-directory condition before roster authority changes.
|
||||
const preparedProjection = await prepareAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: name,
|
||||
generated,
|
||||
});
|
||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||
|
||||
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
|
||||
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
await writeFile(
|
||||
envPath,
|
||||
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
|
||||
);
|
||||
await applyPreparedAgentEnvironmentProjection(preparedProjection);
|
||||
|
||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||
|
||||
@@ -2037,10 +2038,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// Write updated roster
|
||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||
|
||||
// Delete env and heartbeat files (best-effort, non-fatal)
|
||||
// Delete the non-authoritative generated projection and heartbeat files
|
||||
// (best-effort, non-fatal). Operator local/quarantine data is retained.
|
||||
if (!opts.keepFiles) {
|
||||
try {
|
||||
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
|
||||
await unlink(join(activePaths.agentEnvDir, `${name}.env.generated`));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
@@ -2328,16 +2330,17 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
|
||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||
const startInteractionServicePath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'start-interaction-service.sh',
|
||||
);
|
||||
const startTmuxHolderPath = join(activePaths.fleetToolsDir, 'start-tmux-holder.sh');
|
||||
const printInteractionPolicyPath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'print-interaction-effective-policy.sh',
|
||||
@@ -2347,6 +2350,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
const executableToolPaths = [
|
||||
startAgentSessionPath,
|
||||
startInteractionServicePath,
|
||||
startTmuxHolderPath,
|
||||
printInteractionPolicyPath,
|
||||
sendMessagePath,
|
||||
agentSendPath,
|
||||
@@ -2359,6 +2363,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||
startInteractionServicePath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-tmux-holder.sh'),
|
||||
startTmuxHolderPath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||
printInteractionPolicyPath,
|
||||
@@ -2382,9 +2390,12 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
);
|
||||
|
||||
for (const agent of roster.agents) {
|
||||
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv));
|
||||
await writeAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generateAgentEnvValues(roster, agent),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||
@@ -2656,19 +2667,6 @@ function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
|
||||
function shellEnvValue(value: string): string {
|
||||
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
|
||||
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
|
||||
// a tmux socket named "''"), which would defeat the default-socket behavior.
|
||||
if (value === '') {
|
||||
return '';
|
||||
}
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
@@ -2770,13 +2768,8 @@ export function countEnhancers(roster: FleetRoster): number {
|
||||
}
|
||||
|
||||
/** Valid runtime identifiers for fleet agents. */
|
||||
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||
'pi',
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'dogfood',
|
||||
];
|
||||
/** Runtime launchers accepted by `fleet add`; shared with the generated projection validator. */
|
||||
export const VALID_FLEET_RUNTIMES: readonly string[] = GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES;
|
||||
|
||||
/**
|
||||
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
|
||||
|
||||
Reference in New Issue
Block a user