Files
stack/packages/mosaic/src/commands/fleet-launch-command.ts
T

714 lines
24 KiB
TypeScript

import {
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
type Stats,
} from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import {
harnessHome,
launchFleetRuntime,
type FleetHarnessContext,
type RuntimeName,
} from './launch.js';
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [
'schema',
'harness',
'bundle',
'model',
'overlay',
'plugins',
'skills',
'env',
];
const RUNTIMES: readonly RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREDENTIAL_FILES: Record<RuntimeName, string> = {
claude: '.credentials.json',
pi: 'auth.json',
codex: 'auth.json',
opencode: 'auth.json',
};
export type FleetLaunchErrorCode =
| 'SCHEMA_TOO_NEW'
| 'PROFILE_INVALID'
| 'COMPOSITION_FAILED'
| 'FIRST_AUTH_REFUSAL';
export class FleetLaunchError extends Error {
constructor(
readonly code: FleetLaunchErrorCode,
message: string,
) {
super(message);
this.name = 'FleetLaunchError';
}
}
export interface FleetAgentLaunchProfile {
readonly schema: 1;
readonly harness: RuntimeName;
readonly bundle: string;
readonly model?: string;
readonly overlay?: string;
readonly plugins: readonly string[];
readonly skills: readonly string[];
readonly env: Readonly<Record<string, string>>;
}
export interface FleetLaunchRoots {
readonly systemHome: string;
readonly userHome: string;
}
interface SettingsLayer {
readonly name: 'system' | 'user' | 'agent';
readonly path: string;
readonly present: boolean;
readonly value: Record<string, unknown>;
}
interface PlannedLink {
readonly kind: 'plugin' | 'skill';
readonly name: string;
readonly link: string;
readonly target: string;
}
export interface FleetLaunchComposition {
readonly name: string;
readonly profilePath: string;
readonly profile: FleetAgentLaunchProfile;
readonly agentDir: string;
readonly seatHome: string;
readonly settings: {
readonly layers: readonly SettingsLayer[];
readonly merged: Record<string, unknown>;
readonly output: string;
readonly snapshot: string;
};
readonly bundle: {
readonly requested: string;
readonly resolved: string;
readonly email?: string;
readonly display: string;
};
readonly credential: {
readonly link: string;
readonly target: string;
};
readonly installs: readonly PlannedLink[];
readonly prune: readonly string[];
readonly env: Readonly<Record<string, string>>;
readonly argv: readonly string[];
}
export interface FleetLaunchCommandDeps {
readonly userHome?: string;
readonly launcher?: (
runtime: RuntimeName,
args: string[],
declaredEnv: Readonly<Record<string, string>>,
context: FleetHarnessContext,
) => void;
}
function requiredObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a JSON object.`);
}
return value as Record<string, unknown>;
}
function optionalString(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.trim() === '') {
throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a non-empty string.`);
}
return value.trim();
}
function stringList(value: unknown, label: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`${label} must be an array of store entry names.`,
);
}
return value.map((entry: unknown, index: number): string => {
if (typeof entry !== 'string' || !STORE_ENTRY.test(entry)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`${label}[${index}] must be a safe store entry name.`,
);
}
return entry;
});
}
/** Parse and strictly validate the frozen, user-facing per-agent profile schema. */
export function parseFleetAgentProfile(source: string): FleetAgentLaunchProfile {
let parsed: unknown;
try {
parsed = JSON.parse(source) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError('PROFILE_INVALID', `profile.json is not valid JSON: ${detail}`);
}
const raw = requiredObject(parsed, 'profile.json');
if (!Number.isSafeInteger(raw['schema'])) {
throw new FleetLaunchError('PROFILE_INVALID', 'profile.json schema is required and must be 1.');
}
if ((raw['schema'] as number) > FLEET_AGENT_PROFILE_SCHEMA) {
throw new FleetLaunchError(
'SCHEMA_TOO_NEW',
`SCHEMA_TOO_NEW: profile schema ${String(raw['schema'])} is newer than supported schema ${FLEET_AGENT_PROFILE_SCHEMA}; upgrade Mosaic before launching this agent.`,
);
}
if (raw['schema'] !== FLEET_AGENT_PROFILE_SCHEMA) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json schema ${String(raw['schema'])} is unsupported; expected schema 1.`,
);
}
const unknown = Object.keys(raw).filter((key: string): boolean => !PROFILE_KEYS.includes(key));
if (unknown.length > 0) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`unknown profile key "${unknown[0]}" (schema ${String(raw['schema'])})`,
);
}
if (typeof raw['harness'] !== 'string' || !RUNTIMES.includes(raw['harness'] as RuntimeName)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json harness is required and must be one of: ${RUNTIMES.join(', ')}.`,
);
}
const bundle = optionalString(raw['bundle'], 'profile.json bundle') ?? 'primary';
if (!BUNDLE_NAME.test(bundle)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
'profile.json bundle must be a safe bundle name.',
);
}
const overlay = optionalString(raw['overlay'], 'profile.json overlay');
if (overlay !== undefined && (isAbsolute(overlay) || overlay.split(/[\\/]/u).includes('..'))) {
throw new FleetLaunchError(
'PROFILE_INVALID',
'profile.json overlay must remain inside the agent directory.',
);
}
const rawEnv = raw['env'] === undefined ? {} : requiredObject(raw['env'], 'profile.json env');
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(rawEnv)) {
if (!ENV_NAME.test(key) || typeof value !== 'string') {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json env entry "${key}" must have a valid name and string value.`,
);
}
env[key] = value;
}
const model = optionalString(raw['model'], 'profile.json model');
return {
schema: 1,
harness: raw['harness'] as RuntimeName,
bundle,
...(model === undefined ? {} : { model }),
...(overlay === undefined ? {} : { overlay }),
plugins: stringList(raw['plugins'], 'profile.json plugins'),
skills: stringList(raw['skills'], 'profile.json skills'),
env,
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function cloneValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneValue);
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]),
);
}
return value;
}
function mergeObject(
lower: Record<string, unknown>,
higher: Record<string, unknown>,
): Record<string, unknown> {
const result = cloneValue(lower) as Record<string, unknown>;
for (const [key, highValue] of Object.entries(higher)) {
if (highValue === null) {
delete result[key];
continue;
}
const lowValue = result[key];
result[key] =
isPlainObject(lowValue) && isPlainObject(highValue)
? mergeObject(lowValue, highValue)
: cloneValue(highValue);
}
return result;
}
/** Deep object merge. Scalars and arrays replace; null in a higher layer deletes. */
export function deepMergeSettings(
...layers: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> {
return layers.reduce<Record<string, unknown>>(
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)),
{},
);
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function assertRealDirectory(path: string, label: string): void {
const info = lstatIfPresent(path);
if (!info?.isDirectory() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} must be a real, non-symlink directory: ${path}`,
);
}
}
function assertContained(root: string, candidate: string, label: string): void {
const rel = relative(resolve(root), resolve(candidate));
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} resolves outside ${root}: ${candidate}`,
);
}
}
function readSettingsLayer(
name: SettingsLayer['name'],
path: string,
required: boolean,
): SettingsLayer {
const info = lstatIfPresent(path);
if (!info) {
if (required) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`required ${name} settings missing: ${path}`,
);
}
return { name, path, present: false, value: {} };
}
if (!info.isFile() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings must be a real, non-symlink JSON file: ${path}`,
);
}
let value: unknown;
try {
value = JSON.parse(readFileSync(path, 'utf8')) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings are invalid JSON: ${detail}`,
);
}
if (!isPlainObject(value)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings must contain a JSON object.`,
);
}
return { name, path, present: true, value };
}
function accountEmail(bundleDir: string): string | undefined {
const path = join(bundleDir, 'account.json');
const info = lstatIfPresent(path);
if (!info?.isFile() || info.isSymbolicLink()) return undefined;
try {
const account = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
const oauth = isPlainObject(account['oauthAccount']) ? account['oauthAccount'] : undefined;
for (const value of [oauth?.['emailAddress'], account['emailAddress'], account['email']]) {
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
} catch {
return undefined;
}
return undefined;
}
function resolveCredential(
profile: FleetAgentLaunchProfile,
userHome: string,
seatHome: string,
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
const authRoot = join(userHome, 'auth', profile.harness);
assertRealDirectory(authRoot, `${profile.harness} auth root`);
const bundlePath = join(authRoot, profile.bundle);
const bundleInfo = lstatIfPresent(bundlePath);
if (!bundleInfo) {
throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle not found: ${bundlePath}`);
}
if (bundleInfo.isSymbolicLink() && profile.bundle !== 'primary') {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`only the primary bundle may be an alias symlink: ${bundlePath}`,
);
}
let resolvedBundleDir: string;
try {
resolvedBundleDir = realpathSync(bundlePath);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle cannot resolve: ${detail}`);
}
assertContained(realpathSync(authRoot), resolvedBundleDir, 'credential bundle');
assertRealDirectory(resolvedBundleDir, 'resolved credential bundle');
const credentialTarget = join(resolvedBundleDir, CREDENTIAL_FILES[profile.harness]);
const credentialInfo = lstatIfPresent(credentialTarget);
if (!credentialInfo?.isFile() || credentialInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`bundle credential must be a real, non-symlink credential file: ${credentialTarget}`,
);
}
const resolvedCredential = realpathSync(credentialTarget);
assertContained(realpathSync(authRoot), resolvedCredential, 'bundle credential');
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const seatInfo = lstatIfPresent(credentialLink);
if (seatInfo && !seatInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'FIRST_AUTH_REFUSAL',
`first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`,
);
}
const resolvedName = basename(resolvedBundleDir);
const email = accountEmail(resolvedBundleDir);
const display =
profile.bundle === resolvedName
? `${resolvedName}${email ? ` (${email})` : ''}`
: `${profile.bundle} -> ${resolvedName}${email ? ` (${email})` : ''}`;
return {
bundle: {
requested: profile.bundle,
resolved: resolvedName,
...(email === undefined ? {} : { email }),
display,
},
credential: { link: credentialLink, target: resolvedCredential },
};
}
function resolveManagedLinks(
kind: PlannedLink['kind'],
names: readonly string[],
userHome: string,
seatHome: string,
): { installs: PlannedLink[]; prune: string[] } {
const plural = kind === 'plugin' ? 'plugins' : 'skills';
const storeRoot = join(userHome, plural);
const installRoot = join(seatHome, plural);
if (names.length > 0) assertRealDirectory(storeRoot, `${kind} store root`);
const installRootInfo = lstatIfPresent(installRoot);
if (installRootInfo?.isSymbolicLink() || (installRootInfo && !installRootInfo.isDirectory())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${kind} install root must be a real directory (the historical whole-store symlink requires explicit migration): ${installRoot}`,
);
}
const installs: PlannedLink[] = names.map((name: string): PlannedLink => {
const target = join(storeRoot, name);
const targetInfo = lstatIfPresent(target);
if (!targetInfo?.isDirectory() || targetInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${kind} store entry must be a real, non-symlink directory: ${target}`,
);
}
assertContained(realpathSync(storeRoot), realpathSync(target), `${kind} store entry`);
const link = join(installRoot, name);
const linkInfo = lstatIfPresent(link);
if (linkInfo && !linkInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real ${kind} directory occupies managed symlink path ${link}; refusing to delete it.`,
);
}
return { kind, name, link, target: realpathSync(target) };
});
const desired = new Set(names);
const prune: string[] = [];
if (installRootInfo?.isDirectory()) {
for (const entry of readdirSync(installRoot, { withFileTypes: true })) {
const path = join(installRoot, entry.name);
if (desired.has(entry.name)) continue;
if (!entry.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real ${kind} entry occupies managed install root ${path}; refusing to prune it.`,
);
}
prune.push(path);
}
}
return { installs, prune };
}
function buildArgv(
profile: FleetAgentLaunchProfile,
seatHome: string,
passthrough: string[],
): string[] {
const argv: string[] = [profile.harness];
if (profile.model) argv.push('--model', profile.model);
if (profile.harness === 'pi') {
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
}
argv.push(...passthrough);
return argv;
}
/** Resolve and validate the complete launch without changing the filesystem. */
export function resolveFleetLaunchComposition(
name: string,
roots: FleetLaunchRoots,
passthrough: string[] = [],
): FleetLaunchComposition {
if (!AGENT_NAME.test(name)) {
throw new FleetLaunchError('PROFILE_INVALID', `invalid fleet agent name: ${name}`);
}
const agentDir = join(roots.userHome, 'fleet', 'agents', name);
assertRealDirectory(agentDir, 'fleet agent directory');
const profilePath = join(agentDir, 'profile.json');
const profileInfo = lstatIfPresent(profilePath);
if (!profileInfo?.isFile() || profileInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`agent profile must be a real, non-symlink file: ${profilePath}`,
);
}
const profile = parseFleetAgentProfile(readFileSync(profilePath, 'utf8'));
const context: FleetHarnessContext = { agentDir };
const seatHome = harnessHome(profile.harness, context);
const seatHomeInfo = lstatIfPresent(seatHome);
if (seatHomeInfo && (!seatHomeInfo.isDirectory() || seatHomeInfo.isSymbolicLink())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`agent harness home must be a real, non-symlink directory: ${seatHome}`,
);
}
const settingsOutput = join(seatHome, 'settings.json');
const settingsSnapshot = join(agentDir, 'settings.generated.json');
for (const [label, path] of [
['generated settings', settingsOutput],
['generated settings snapshot', settingsSnapshot],
] as const) {
const info = lstatIfPresent(path);
if (info && (!info.isFile() || info.isSymbolicLink())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} path must be a real file or absent: ${path}`,
);
}
}
const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json');
assertContained(agentDir, overlayPath, 'agent overlay');
const layers: SettingsLayer[] = [
readSettingsLayer(
'system',
join(roots.systemHome, 'framework', 'runtime', profile.harness, 'settings.json'),
true,
),
readSettingsLayer(
'user',
join(roots.userHome, 'config', profile.harness, 'settings.json'),
false,
),
profile.overlay === undefined
? { name: 'agent', path: overlayPath, present: false, value: {} }
: readSettingsLayer('agent', overlayPath, false),
];
const merged = deepMergeSettings(...layers.map((layer) => layer.value));
const credential = resolveCredential(profile, roots.userHome, seatHome);
const plugins = resolveManagedLinks('plugin', profile.plugins, roots.userHome, seatHome);
const skills = resolveManagedLinks('skill', profile.skills, roots.userHome, seatHome);
const homeEnvName: Record<RuntimeName, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
const env: Record<string, string> = {
...profile.env,
[homeEnvName[profile.harness]]: seatHome,
MOSAIC_AGENT_NAME: name,
};
return {
name,
profilePath,
profile,
agentDir,
seatHome,
settings: {
layers,
merged,
output: settingsOutput,
snapshot: settingsSnapshot,
},
...credential,
installs: [...plugins.installs, ...skills.installs],
prune: [...plugins.prune, ...skills.prune],
env,
argv: buildArgv(profile, seatHome, passthrough),
};
}
function ensureSymlink(link: string, target: string): void {
const info = lstatIfPresent(link);
if (info?.isSymbolicLink()) {
const current = resolve(dirname(link), readlinkSync(link));
if (current === target) return;
rmSync(link);
} else if (info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object occupies managed symlink path ${link}; refusing to delete it.`,
);
}
mkdirSync(dirname(link), { recursive: true });
symlinkSync(target, link, 'file');
}
function canonicalJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalJson);
if (!isPlainObject(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort((left, right) => left.localeCompare(right, 'en'))
.map((key) => [key, canonicalJson(value[key])]),
);
}
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
mkdirSync(plan.seatHome, { recursive: true });
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
ensureSymlink(plan.credential.link, plan.credential.target);
for (const path of plan.prune) {
const info = lstatIfPresent(path);
if (info?.isSymbolicLink()) rmSync(path);
else if (info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object replaced managed symlink before prune: ${path}`,
);
}
}
for (const install of plan.installs) ensureSymlink(install.link, install.target);
}
/** Stable, auditable text representation used by --dry-run and snapshot tests. */
export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
const lines = [
`mosaic fleet launch ${plan.name} --dry-run`,
`profile: ${plan.profilePath} (schema ${plan.profile.schema})`,
`harness: ${plan.profile.harness}`,
`seat-home: ${plan.seatHome}`,
'settings sources:',
];
for (const layer of plan.settings.layers) {
lines.push(` ${layer.name}: ${layer.path}${layer.present ? '' : ' (absent)'}`);
}
lines.push(` output: ${plan.settings.output}`);
lines.push(` snapshot: ${plan.settings.snapshot}`);
lines.push('merged settings:');
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
lines.push(`bundle: ${plan.bundle.display}`);
lines.push('symlinks:');
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
for (const install of plan.installs) {
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
}
for (const path of plan.prune) lines.push(` prune: ${path}`);
lines.push('declared env:');
for (const key of Object.keys(plan.env).sort()) lines.push(` ${key}=${plan.env[key]}`);
lines.push(`argv: ${JSON.stringify(plan.argv)}`);
return lines.join('\n');
}
/** Register `mosaic fleet launch <name> [--dry-run]` on the fleet control plane. */
export function registerFleetLaunchCommand(
fleetCommand: Command,
systemHomeFor: () => string,
deps: FleetLaunchCommandDeps = {},
): Command {
return fleetCommand
.command('launch <name>')
.description('Compose and launch one per-agent harness home')
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
try {
const userHome =
deps.userHome ?? process.env['MOSAIC_USER_HOME'] ?? join(homedir(), '.mosaic');
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { agentDir: plan.agentDir });
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
});
}