714 lines
24 KiB
TypeScript
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`);
|
|
}
|
|
});
|
|
}
|