feat(fleet): enforce generated environment boundary (#772)
This commit was merged in pull request #772.
This commit is contained in:
@@ -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