Found by rehearsing the full install on a greenfield Debian 13 VM (mosaic-sbx-dev) rather than on a host that already had a working Mosaic tree. Each one is invisible on a developer machine and fatal on a new host. 1. Required system settings layer. The framework ships runtime/<harness>/ for claude, codex, opencode and pi but a settings.json only for claude, so requiring the file made every pi, codex and opencode seat refuse to compose. The system layer is now optional; what must exist is the harness runtime directory, which is the thing that actually proves the framework is installed and carries that harness. 2. Required mcpServers in canonical Claude settings. The shipped settings.json has no such key, so `fleet agent new` refused to scaffold any Claude seat. Absent now means the same as empty. A present but wrong-typed value is still an error. 3. Never-enrolled hosts were told their auth directory "must be a real, non-symlink directory", which reads as a tampering report when the real situation is that nobody has logged in yet. Absent and wrong-shaped are now separate messages, and the absent one names `mosaic auth enroll`. 4. A fleet seat whose host had no system SOUL.md reached checkSoul(), which spawns the interactive `mosaic wizard` with inherited stdio. On a detached tmux seat that parks the pane on a menu with nobody at it: the session is live, the systemd unit reports fine, and no agent ever starts. A seat's identity is its own SOUL.md, written by `fleet agent new`, so the fleet path checks that and fails loudly instead. Each fix has a regression test verified red against the unfixed source. The launch.spec.ts seat fixtures gained a SOUL.md they always should have had -- without it those tests were satisfied by whatever SOUL.md the developer's real ~/.config/mosaic happened to contain. Full suite before and after: the same 5 pre-existing failures in mutator-gate.acceptance.spec.ts and install-ordering-guard.spec.ts, 1585 -> 1591 passing. typecheck and eslint clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
1043 lines
37 KiB
TypeScript
1043 lines
37 KiB
TypeScript
import {
|
|
closeSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
writeSync,
|
|
type Stats,
|
|
} from 'node:fs';
|
|
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';
|
|
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
|
|
import {
|
|
CREDENTIAL_DIR_ENV as CREDENTIAL_DIR_ENV_BY_HARNESS,
|
|
CREDENTIAL_FILE_NAMES,
|
|
} from '../fleet/credential-sharing.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_]*$/;
|
|
|
|
// Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart.
|
|
const CREDENTIAL_FILES: Record<RuntimeName, string> = CREDENTIAL_FILE_NAMES;
|
|
const CREDENTIAL_DIR_ENV: Partial<Record<RuntimeName, string>> = CREDENTIAL_DIR_ENV_BY_HARNESS;
|
|
|
|
export type FleetLaunchErrorCode =
|
|
| 'SCHEMA_TOO_NEW'
|
|
| 'PROFILE_INVALID'
|
|
| 'AGENT_NOT_SCAFFOLDED'
|
|
| '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;
|
|
}
|
|
|
|
interface ManagedLinkManifest {
|
|
readonly links: Record<string, string>;
|
|
}
|
|
|
|
interface ManagedLinkState {
|
|
readonly path: string;
|
|
readonly links: Map<string, string>;
|
|
readonly existed: boolean;
|
|
}
|
|
|
|
export interface FleetLaunchComposition {
|
|
readonly name: string;
|
|
readonly profilePath: string;
|
|
readonly profile: FleetAgentLaunchProfile;
|
|
readonly agentDir: string;
|
|
readonly systemHome: 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: {
|
|
/**
|
|
* The seat-local managed link to the bundle credential. Absent for harnesses
|
|
* that reach the shared bundle by environment instead (see CREDENTIAL_DIR_ENV).
|
|
*/
|
|
readonly link?: string;
|
|
readonly target: string;
|
|
/** Resolved bundle directory holding the credential file. */
|
|
readonly dir: string;
|
|
};
|
|
readonly managedLinks: ManagedLinkState;
|
|
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,
|
|
dangerous: boolean,
|
|
) => 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. Objects merge recursively; scalar and array conflicts
|
|
* resolve last-layer-wins; null in a higher layer deletes the key.
|
|
*/
|
|
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, absentHint?: string): void {
|
|
const info = lstatIfPresent(path);
|
|
// Absent and wrong-shaped are different problems and want different words. A host that has
|
|
// simply never enrolled a bundle was being told its auth directory "must be a real,
|
|
// non-symlink directory", which reads as a tampering report rather than "log in first".
|
|
if (!info) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
absentHint
|
|
? `${label} does not exist: ${path} — ${absentHint}`
|
|
: `${label} does not exist: ${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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Proves the framework is installed and knows this harness. This is the check the required
|
|
* system settings layer used to stand in for, moved to the thing that is actually always
|
|
* present: the runtime directory. A missing one means an uninstalled framework or a harness
|
|
* the install does not carry, and both are worth failing on before a seat is composed.
|
|
*/
|
|
function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void {
|
|
const runtimeDir = join(systemHome, 'runtime', harness);
|
|
if (!lstatIfPresent(runtimeDir)?.isDirectory()) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
_managedLinks: ManagedLinkState,
|
|
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
|
|
assertRealDirectory(userHome, 'user Mosaic root');
|
|
const realUserHome = realpathSync(userHome);
|
|
const authDirectory = join(userHome, 'auth');
|
|
const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`;
|
|
assertRealDirectory(authDirectory, 'auth directory', enrollHint);
|
|
const authRoot = join(authDirectory, profile.harness);
|
|
assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint);
|
|
const resolvedAuthRoot = realpathSync(authRoot);
|
|
assertContained(realUserHome, resolvedAuthRoot, `${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(resolvedAuthRoot, 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}`,
|
|
);
|
|
}
|
|
if ((credentialInfo.mode & 0o077) !== 0) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`bundle credential file must not grant group or other permissions: ${credentialTarget}`,
|
|
);
|
|
}
|
|
// Fleet launches and credential bundles share one operating-system user; ownership validation is deferred.
|
|
const resolvedCredential = realpathSync(credentialTarget);
|
|
assertContained(resolvedAuthRoot, 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.`,
|
|
);
|
|
}
|
|
// Environment-shared harnesses never read the seat-local path, so no link is
|
|
// planned for it. A leftover link from an earlier scaffold is inert: the harness
|
|
// resolves its credential directory from the environment instead.
|
|
const sharesByEnv = CREDENTIAL_DIR_ENV[profile.harness] !== undefined;
|
|
|
|
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: {
|
|
...(sharesByEnv ? {} : { link: credentialLink }),
|
|
target: resolvedCredential,
|
|
dir: resolvedBundleDir,
|
|
},
|
|
};
|
|
}
|
|
|
|
function currentLinkTarget(link: string): string {
|
|
return resolve(dirname(link), readlinkSync(link));
|
|
}
|
|
|
|
function resolveManagedLinks(
|
|
kind: PlannedLink['kind'],
|
|
names: readonly string[],
|
|
userHome: string,
|
|
seatHome: string,
|
|
managedLinks: ManagedLinkState,
|
|
): { 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 (!entry.isSymbolicLink()) {
|
|
// The harness writes its own metadata files (e.g. installed_plugins.json)
|
|
// beside the managed links; only a real directory is an unmanaged entry
|
|
// the pruner would orphan.
|
|
if (entry.isDirectory()) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`real ${kind} directory occupies managed install root ${path}; refusing to prune it.`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
const currentTarget = currentLinkTarget(path);
|
|
const recordedTarget = managedLinks.links.get(path);
|
|
if (recordedTarget === undefined) {
|
|
if (
|
|
!managedLinks.existed &&
|
|
(() => {
|
|
try {
|
|
assertContained(
|
|
realpathSync(storeRoot),
|
|
realpathSync(currentTarget),
|
|
`${kind} migration`,
|
|
);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})()
|
|
) {
|
|
// A pre-manifest seat may adopt only links to the central Mosaic store; foreign links refuse.
|
|
managedLinks.links.set(path, currentTarget);
|
|
}
|
|
} else if (recordedTarget !== currentTarget) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed ${kind} symlink target changed since composition: ${path}`,
|
|
);
|
|
}
|
|
if (!desired.has(entry.name)) prune.push(path);
|
|
}
|
|
}
|
|
return { installs, prune };
|
|
}
|
|
|
|
function readManagedLinkState(
|
|
seatHome: string,
|
|
profile: FleetAgentLaunchProfile,
|
|
userHome: string,
|
|
): ManagedLinkState {
|
|
const path = join(seatHome, '.mosaic-managed-links.json');
|
|
const info = lstatIfPresent(path);
|
|
if (!info) return { path, links: new Map(), existed: false };
|
|
if (!info.isFile() || info.isSymbolicLink()) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest must be a real, non-symlink JSON file: ${path}`,
|
|
);
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
} catch (error: unknown) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest is invalid JSON: ${detail}`,
|
|
);
|
|
}
|
|
if (!isPlainObject(parsed) || !isPlainObject(parsed['links'])) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest has invalid shape: ${path}`,
|
|
);
|
|
}
|
|
const links = new Map<string, string>();
|
|
for (const [link, target] of Object.entries(parsed['links'])) {
|
|
if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest has invalid entry: ${path}`,
|
|
);
|
|
}
|
|
const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]);
|
|
const pluginRoot = join(seatHome, 'plugins');
|
|
const skillRoot = join(seatHome, 'skills');
|
|
const authRoot = join(userHome, 'auth', profile.harness);
|
|
const inRoot = (root: string, candidate: string): boolean => {
|
|
const rel = relative(resolve(root), resolve(candidate));
|
|
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
};
|
|
const valid =
|
|
(link === credential && inRoot(authRoot, target)) ||
|
|
(inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) ||
|
|
(inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target));
|
|
if (!valid) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest entry escapes an approved seat/store root: ${path}`,
|
|
);
|
|
}
|
|
links.set(link, target);
|
|
}
|
|
return { path, links, existed: true };
|
|
}
|
|
|
|
interface PreparedManagedLinkManifest {
|
|
readonly path: string;
|
|
readonly descriptor: number;
|
|
}
|
|
|
|
function prepareManagedLinkManifest(managedLinks: ManagedLinkState): PreparedManagedLinkManifest {
|
|
const path = `${managedLinks.path}.tmp`;
|
|
try {
|
|
return { path, descriptor: openSync(path, 'wx', 0o600) };
|
|
} catch (error: unknown) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`managed link manifest temporary file cannot be created exclusively: ${detail}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeManagedLinkState(
|
|
managedLinks: ManagedLinkState,
|
|
prepared: PreparedManagedLinkManifest,
|
|
): void {
|
|
const entries = [...managedLinks.links.entries()].sort(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
);
|
|
const manifest: ManagedLinkManifest = { links: Object.fromEntries(entries) };
|
|
writeSync(prepared.descriptor, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
}
|
|
|
|
function buildArgv(
|
|
profile: FleetAgentLaunchProfile,
|
|
seatHome: string,
|
|
passthrough: string[],
|
|
): string[] {
|
|
const argv: string[] = [profile.harness];
|
|
// A caller-supplied --model replaces the profile's rather than being appended after
|
|
// it. The fleet roster carries a model per seat and is the surface operators edit, so
|
|
// it has to win; emitting both flags would leave that to each harness's arg parser.
|
|
if (profile.model && !passthrough.includes('--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);
|
|
if (lstatIfPresent(agentDir) === undefined) {
|
|
throw new FleetLaunchError(
|
|
'AGENT_NOT_SCAFFOLDED',
|
|
`no such fleet agent '${name}' — run: mosaic fleet agent new ${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');
|
|
// The framework ships a runtime directory per harness but a settings.json only where it
|
|
// has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made
|
|
// every pi, codex and opencode seat unlaunchable on a clean install. The install is what
|
|
// has to be present; an absent base layer just means the harness has no system settings.
|
|
assertHarnessRuntimeInstalled(roots.systemHome, profile.harness);
|
|
const layers: SettingsLayer[] = [
|
|
readSettingsLayer(
|
|
'system',
|
|
join(roots.systemHome, 'runtime', profile.harness, 'settings.json'),
|
|
false,
|
|
),
|
|
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 managedLinks = readManagedLinkState(seatHome, profile, roots.userHome);
|
|
const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks);
|
|
const plugins = resolveManagedLinks(
|
|
'plugin',
|
|
profile.plugins,
|
|
roots.userHome,
|
|
seatHome,
|
|
managedLinks,
|
|
);
|
|
const skills = resolveManagedLinks(
|
|
'skill',
|
|
profile.skills,
|
|
roots.userHome,
|
|
seatHome,
|
|
managedLinks,
|
|
);
|
|
const homeEnvName: Record<RuntimeName, string> = {
|
|
claude: 'CLAUDE_CONFIG_DIR',
|
|
pi: 'PI_CODING_AGENT_DIR',
|
|
codex: 'CODEX_HOME',
|
|
opencode: 'XDG_CONFIG_HOME',
|
|
};
|
|
const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness];
|
|
const env: Record<string, string> = {
|
|
...profile.env,
|
|
[homeEnvName[profile.harness]]: seatHome,
|
|
// Only ever an absolute bundle path. Claude reads an empty value as ~/.claude,
|
|
// which is the operator's own account, so an empty value is never exported.
|
|
...(credentialDirEnvName === undefined
|
|
? {}
|
|
: { [credentialDirEnvName]: credential.credential.dir }),
|
|
MOSAIC_AGENT_NAME: name,
|
|
};
|
|
return {
|
|
name,
|
|
profilePath,
|
|
systemHome: roots.systemHome,
|
|
profile,
|
|
agentDir,
|
|
seatHome,
|
|
settings: {
|
|
layers,
|
|
merged,
|
|
output: settingsOutput,
|
|
snapshot: settingsSnapshot,
|
|
},
|
|
...credential,
|
|
managedLinks,
|
|
installs: [...plugins.installs, ...skills.installs],
|
|
prune: [...plugins.prune, ...skills.prune],
|
|
env,
|
|
argv: buildArgv(profile, seatHome, passthrough),
|
|
};
|
|
}
|
|
|
|
function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkState): void {
|
|
const info = lstatIfPresent(link);
|
|
if (info?.isSymbolicLink()) {
|
|
const current = currentLinkTarget(link);
|
|
if (managedLinks.links.get(link) !== current) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`unrecorded or retargeted symlink occupies managed path: ${link}`,
|
|
);
|
|
}
|
|
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');
|
|
managedLinks.links.set(link, target);
|
|
}
|
|
|
|
function assertManagedLinkMutationAllowed(
|
|
link: string,
|
|
target: string | undefined,
|
|
managedLinks: ManagedLinkState,
|
|
): void {
|
|
const info = lstatIfPresent(link);
|
|
if (!info) return;
|
|
if (!info.isSymbolicLink()) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`real object occupies managed symlink path ${link}; refusing to delete it.`,
|
|
);
|
|
}
|
|
const current = currentLinkTarget(link);
|
|
if (managedLinks.links.get(link) !== current) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`unrecorded or retargeted symlink occupies managed path: ${link}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// All link-state checks must complete before the first filesystem mutation.
|
|
// This makes a late foreign/retargeted link refusal leave the seat untouched.
|
|
if (plan.credential.link !== undefined) {
|
|
assertManagedLinkMutationAllowed(
|
|
plan.credential.link,
|
|
plan.credential.target,
|
|
plan.managedLinks,
|
|
);
|
|
}
|
|
for (const path of plan.prune)
|
|
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
|
|
for (const install of plan.installs) {
|
|
assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks);
|
|
}
|
|
|
|
mkdirSync(plan.seatHome, { recursive: true });
|
|
const preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
|
|
let descriptorOpen = true;
|
|
let committedManifest = false;
|
|
try {
|
|
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 });
|
|
if (plan.credential.link !== undefined) {
|
|
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
|
|
}
|
|
for (const path of plan.prune) {
|
|
const info = lstatIfPresent(path);
|
|
if (info?.isSymbolicLink()) {
|
|
const current = currentLinkTarget(path);
|
|
if (plan.managedLinks.links.get(path) !== current) {
|
|
throw new FleetLaunchError(
|
|
'COMPOSITION_FAILED',
|
|
`unrecorded or retargeted symlink cannot be pruned: ${path}`,
|
|
);
|
|
}
|
|
rmSync(path);
|
|
plan.managedLinks.links.delete(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, plan.managedLinks);
|
|
}
|
|
writeManagedLinkState(plan.managedLinks, preparedManifest);
|
|
closeSync(preparedManifest.descriptor);
|
|
descriptorOpen = false;
|
|
renameSync(preparedManifest.path, plan.managedLinks.path);
|
|
committedManifest = true;
|
|
} finally {
|
|
if (!committedManifest) {
|
|
if (descriptorOpen) closeSync(preparedManifest.descriptor);
|
|
rmSync(preparedManifest.path, { force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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(`credential: ${plan.credential.target}`);
|
|
lines.push('symlinks:');
|
|
// Environment-shared harnesses have no credential symlink; the exported
|
|
// credential-directory variable below is what points them at the bundle.
|
|
if (plan.credential.link !== undefined) {
|
|
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')
|
|
.option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does')
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.action(
|
|
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
|
|
try {
|
|
const userHome = deps.userHome ?? defaultFleetDataHome();
|
|
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;
|
|
// Dangerous mode is the caller's to ask for, not the seat's to assume. An
|
|
// unattended tmux seat needs it -- a permission prompt with nobody at the pane
|
|
// is a hung agent -- so the roster launcher passes the flag explicitly and it
|
|
// stays visible in the process table rather than hiding in a profile default.
|
|
launcher(
|
|
plan.profile.harness,
|
|
plan.argv.slice(1),
|
|
plan.env,
|
|
{ agentDir: plan.agentDir, mosaicHome: plan.systemHome },
|
|
opts.dangerous === true,
|
|
);
|
|
} 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`);
|
|
}
|
|
},
|
|
);
|
|
}
|