feat(fleet): compose and launch profile-backed seats

This commit is contained in:
Jason Woltje
2026-08-13 11:30:53 -05:00
parent 216cd72226
commit 378c227cbb
6 changed files with 1340 additions and 36 deletions
+102
View File
@@ -0,0 +1,102 @@
# REPORT-T2
Date: 2026-08-13 11:29 CDT
Branch: `feat/wf-fleet-t2-launch`
Base: `216cd722`
Issue: #1209
## What changed
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
- required `schema` and `harness`
- default bundle `primary`
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
- unknown-key refusal naming the key
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
## Reconciliation decisions and contradictions
### Prominent contradiction: roster registries do not contain the frozen launch schema
The existing code has two other profile/registry concepts:
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
### Historical whole-store plugin link
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
### Existing `FleetProfile` name
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
## Ambiguities and bounded choices
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
## Test run
Dependency install and build:
```text
$ pnpm install --frozen-lockfile
Scope: all 28 workspace projects
Lockfile is up to date, resolution step is skipped
Done in 4.7s using pnpm v10.6.2
$ pnpm --filter @mosaicstack/mosaic... build
Scope: 13 of 28 workspace projects
packages/mosaic build: Done
```
Focused and touched integration tests:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
Test Files 3 passed (3)
Tests 256 passed (256)
```
Typecheck and lint:
```text
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
(exit 0)
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
(exit 0)
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
Checking formatting...
All matched files use Prettier code style!
```
Package-wide Vitest result:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run
Test Files 1 failed | 83 passed (84)
Tests 4 failed | 1535 passed (1539)
```
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
@@ -0,0 +1,370 @@
import {
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyFleetLaunchComposition,
deepMergeSettings,
FleetLaunchError,
formatFleetLaunchDryRun,
parseFleetAgentProfile,
registerFleetLaunchCommand,
resolveFleetLaunchComposition,
} from './fleet-launch-command.js';
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
root: string;
systemHome: string;
userHome: string;
agentDir: string;
namedBundleDir: string;
} {
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
roots.push(root);
const systemHome = join(root, 'system');
const userHome = join(root, 'user');
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
mkdirSync(join(systemHome, 'framework', 'runtime', 'claude'), { recursive: true });
mkdirSync(agentDir, { recursive: true });
mkdirSync(namedBundleDir, { recursive: true });
writeFileSync(join(systemHome, 'framework', 'runtime', 'claude', 'settings.json'), '{}\n');
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
writeFileSync(
join(namedBundleDir, 'account.json'),
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
);
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
return { root, systemHome, userHome, agentDir, namedBundleDir };
}
describe('fleet launch profile schema 1', () => {
it('rejects an unknown key and names it', () => {
expect(() =>
parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
).toThrowError(/unknown profile key "pluigns"/);
});
it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
try {
parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
throw new Error('expected parse to fail');
} catch (error) {
expect(error).toBeInstanceOf(FleetLaunchError);
expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
expect((error as Error).message).toMatch(/upgrade Mosaic/i);
}
});
});
describe('three-layer settings merge', () => {
it('keeps base-only settings', () => {
expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
});
it('uses the last layer for scalar conflicts', () => {
expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
});
it('replaces arrays instead of appending', () => {
expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
hooks: ['user'],
});
});
it('uses null as a key-deleting tombstone', () => {
expect(
deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
).toEqual({ nested: { keep: true } });
});
it('deep-merges all three layers in precedence order', () => {
expect(
deepMergeSettings(
{ nested: { system: true, shared: 'system' }, list: [1] },
{ nested: { user: true, shared: 'user' }, list: [2] },
{ nested: { agent: true, shared: 'agent' }, list: [3] },
),
).toEqual({
nested: { system: true, user: true, agent: true, shared: 'agent' },
list: [3],
});
});
});
describe('profile-selected overlay', () => {
it('defaults to no overlay when the optional profile field is omitted', () => {
const fx = fixture();
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.settings.merged).toEqual({});
expect(plan.settings.layers[2]?.present).toBe(false);
});
});
describe('A3 credential validation', () => {
it('refuses a symlinked bundle credential file', () => {
const fx = fixture();
rmSync(join(fx.namedBundleDir, '.credentials.json'));
const outside = join(fx.root, 'outside-credentials.json');
writeFileSync(outside, '{}\n');
symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/real, non-symlink credential file/);
});
it('accepts a real credential file contained in the harness auth root', () => {
const fx = fixture();
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
expect(plan.bundle.display).toBe('primary -> fred_example.com ([email protected])');
});
it('refuses first-auth state when a real file occupies the seat link', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
mkdirSync(seatHome, { recursive: true });
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
});
});
describe('managed plugin and skill links', () => {
it('installs listed entries and prunes only stale managed symlinks', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
plugins: ['keep'],
skills: ['mosaic-tools'],
});
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
mkdirSync(pluginHome, { recursive: true });
symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
applyFleetLaunchComposition(plan);
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true);
expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe(
true,
);
});
it('surfaces a real directory at a managed link path without deleting it', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
mkdirSync(occupied, { recursive: true });
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/real plugin directory.*refusing to delete/i);
expect(lstatSync(occupied).isDirectory()).toBe(true);
});
});
describe('fleet launch command outcomes', () => {
it('--dry-run prints without writing or invoking the launcher', () => {
const fx = fixture();
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
try {
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
expect(stdout).toHaveBeenCalledWith(
expect.stringContaining('mosaic fleet launch fred --dry-run'),
);
expect(launcher).not.toHaveBeenCalled();
expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
} finally {
stdout.mockRestore();
}
});
it('applies the plan and invokes the existing launch seam with declared values', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
model: 'opus',
env: { SEAT_FLAG: 'yes' },
});
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(launcher).toHaveBeenCalledWith(
'claude',
['--model', 'opus'],
{
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
MOSAIC_AGENT_NAME: 'fred',
SEAT_FLAG: 'yes',
},
{ agentDir: fx.agentDir },
);
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
true,
);
});
it('sets a non-zero exit code and never invokes the launcher', () => {
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const priorExitCode = process.exitCode;
process.exitCode = 0;
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
try {
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(process.exitCode).toBe(1);
expect(launcher).not.toHaveBeenCalled();
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
} finally {
process.exitCode = priorExitCode;
stderr.mockRestore();
}
});
});
describe('dry-run composition', () => {
it('renders a deterministic full composition and writes nothing', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
bundle: 'primary',
model: 'opus',
overlay: 'overlay.json',
plugins: ['code-review'],
skills: ['mosaic-tools'],
env: { SEAT_FLAG: 'yes' },
});
writeFileSync(
join(fx.systemHome, 'framework', 'runtime', 'claude', 'settings.json'),
'{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
);
mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
writeFileSync(
join(fx.userHome, 'config', 'claude', 'settings.json'),
'{"hooks":["user"],"nested":{"user":true}}\n',
);
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');
expect(output).toMatchInlineSnapshot(`
"mosaic fleet launch fred --dry-run
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
harness: claude
seat-home: <ROOT>/user/fleet/agents/fred/.claude
settings sources:
system: <ROOT>/system/framework/runtime/claude/settings.json
user: <ROOT>/user/config/claude/settings.json
agent: <ROOT>/user/fleet/agents/fred/overlay.json
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
merged settings:
{
"hooks": [
"user"
],
"nested": {
"agent": true,
"system": true,
"user": true
}
}
bundle: primary -> fred_example.com ([email protected])
symlinks:
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
declared env:
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
MOSAIC_AGENT_NAME=fred
SEAT_FLAG=yes
argv: ["claude","--model","opus"]"
`);
expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();
applyFleetLaunchComposition(plan);
expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
hooks: ['user'],
nested: { agent: true, system: true, user: true },
});
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
readFileSync(plan.settings.output, 'utf8'),
);
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
});
});
@@ -0,0 +1,713 @@
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`);
}
});
}
@@ -91,6 +91,7 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'launch',
'migrate-v1', 'migrate-v1',
'persona', 'persona',
'plan', 'plan',
+13
View File
@@ -63,6 +63,7 @@ import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js'; import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.js'; import { registerFleetProfileCommand } from './fleet-profiles.js';
import { registerFleetProvisionCommand } from './fleet-provision.js'; import { registerFleetProvisionCommand } from './fleet-provision.js';
import { registerFleetLaunchCommand, type FleetLaunchCommandDeps } from './fleet-launch-command.js';
/** /**
* A function that spawns a command with inherited stdio (TTY passthrough). * A function that spawns a command with inherited stdio (TTY passthrough).
@@ -97,6 +98,10 @@ export interface FleetCommandDeps {
*/ */
sleepFn?: SleepFn; sleepFn?: SleepFn;
mosaicHome?: string; mosaicHome?: string;
/** User-owned fleet/auth/config root. Defaults to ~/.mosaic. */
mosaicUserHome?: string;
/** Test/embedding seam for the final process-replacing fleet launch. */
fleetLauncher?: FleetLaunchCommandDeps['launcher'];
frameworkRoot?: string; frameworkRoot?: string;
/** /**
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY. * Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
@@ -2041,6 +2046,14 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// fleet/ directory as the roster and heartbeats. // fleet/ directory as the roster and heartbeats.
registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// User-facing per-agent profile.json is the launch-composition SSOT. It is
// intentionally independent of roster-v2, whose lifecycle/topology registry
// does not model auth bundles, overlays, plugins, skills, or seat env.
registerFleetLaunchCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome, {
...(deps.mosaicUserHome === undefined ? {} : { userHome: deps.mosaicUserHome }),
...(deps.fleetLauncher === undefined ? {} : { launcher: deps.fleetLauncher }),
});
// System-type profiles (H2): declarative persona roster + topology, resolved // System-type profiles (H2): declarative persona roster + topology, resolved
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag. // from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
+141 -36
View File
@@ -35,7 +35,12 @@ import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
/** Fleet context for the single harness-home resolution seam. */
export interface FleetHarnessContext {
readonly agentDir: string;
}
const RUNTIME_LABELS: Record<RuntimeName, string> = { const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code', claude: 'Claude Code',
@@ -64,19 +69,19 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME', opencode: 'XDG_CONFIG_HOME',
}; };
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */ /** Dedicated runtime home, optionally scoped to a user fleet agent. */
function harnessHome(runtime: RuntimeName): string { export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
return join(MOSAIC_HOME, `.${runtime}`); return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
} }
/** /**
* Env overlay pointing a runtime at its mosaic-owned home. The directory is * Env overlay pointing a runtime at its mosaic-owned home. The directory is
* created on demand so a first launch does not fail on a missing path. * created on demand so a first launch does not fail on a missing path.
*/ */
function harnessEnv(runtime: RuntimeName): Record<string, string> { function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
const key = HARNESS_HOME_ENV[runtime]; const key = HARNESS_HOME_ENV[runtime];
if (!key) return {}; if (!key) return {};
const home = harnessHome(runtime); const home = harnessHome(runtime, fleet);
mkdirSync(home, { recursive: true }); mkdirSync(home, { recursive: true });
return { [key]: home }; return { [key]: home };
} }
@@ -162,7 +167,13 @@ function redactArgv(argv: string[]): string[] {
); );
} }
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void { function recordLaunch(
runtime: RuntimeName,
cliArgs: string[],
yolo: boolean,
fleet?: FleetHarnessContext,
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try { try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 }); mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it // Correlation id for the lease.register half. Set into process.env so it
@@ -180,13 +191,13 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
mode: yolo ? 'yolo' : 'normal', mode: yolo ? 'yolo' : 'normal',
cwd: process.cwd(), cwd: process.cwd(),
cli_version: CLI_VERSION, cli_version: CLI_VERSION,
config_home: harnessHome(runtime), config_home: harnessHome(runtime, fleet),
config_home_isolated: true, config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null, config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs), argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime), normative_fragments: normativeFragmentDigests(runtime),
// names only — values are never recorded // names only — values are never recorded
mosaic_env_present: Object.keys(process.env) mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_')) .filter((k) => k.startsWith('MOSAIC_'))
.sort(), .sort(),
}; };
@@ -262,9 +273,9 @@ interface SettingsAudit {
warnings: string[]; warnings: string[];
} }
function auditClaudeSettings(): SettingsAudit { function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
const warnings: string[] = []; const warnings: string[] = [];
const settingsPath = join(harnessHome('claude'), 'settings.json'); const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
const settings = readJson(settingsPath); const settings = readJson(settingsPath);
if (!settings) { if (!settings) {
@@ -483,7 +494,11 @@ function buildPrdBlock(): string {
* `mosaicHome` is parameterized for testability; production callers use the * `mosaicHome` is parameterized for testability; production callers use the
* module-level default. * module-level default.
*/ */
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string { export function composeContract(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
env: NodeJS.ProcessEnv = process.env,
): string {
const runtimeContractPaths: Record<RuntimeName, string> = { const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'), claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'), codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
@@ -540,13 +555,13 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal); parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
} }
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']); const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) { if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
} }
const canonicalMember = fleetIdentity.identity?.member; const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) { if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass; const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) { if (ambientClass !== canonicalMember.className) {
throw new Error( throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`, `Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
@@ -583,13 +598,13 @@ For required push/merge/issue-close/release actions, execute without routine con
// Fleet launches derive every identity projection from the one canonical roster // Fleet launches derive every identity projection from the one canonical roster
// member resolved above. Non-fleet launches retain the legacy ambient persona // member resolved above. Non-fleet launches retain the legacy ambient persona
// and tool-policy behavior. // and tool-policy behavior.
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS']; const personaClass = canonicalMember?.className ?? env['MOSAIC_AGENT_CLASS'];
const persona = readPersonaContractBlock(mosaicHome, personaClass); const persona = readPersonaContractBlock(mosaicHome, personaClass);
if (persona) parts.push('\n\n' + persona); if (persona) parts.push('\n\n' + persona);
const toolPolicyName = canonicalMember const toolPolicyName = canonicalMember
? canonicalMember.toolPolicy ? canonicalMember.toolPolicy
: process.env['MOSAIC_AGENT_TOOL_POLICY']; : env['MOSAIC_AGENT_TOOL_POLICY'];
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName); const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
if (toolPolicy) parts.push('\n\n' + toolPolicy); if (toolPolicy) parts.push('\n\n' + toolPolicy);
@@ -613,8 +628,8 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
} }
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */ /** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
function buildRuntimePrompt(runtime: RuntimeName): string { function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string {
return composeContract(runtime); return composeContract(runtime, MOSAIC_HOME, env);
} }
// ─── Session lock ──────────────────────────────────────────────────────────── // ─── Session lock ────────────────────────────────────────────────────────────
@@ -695,8 +710,12 @@ function checkResumableSession(): void {
// ─── Write config for runtimes that read from fixed paths ──────────────────── // ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void { function ensureRuntimeConfig(
const prompt = buildRuntimePrompt(runtime); runtime: RuntimeName,
destPath: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const prompt = buildRuntimePrompt(runtime, env);
mkdirSync(dirname(destPath), { recursive: true }); mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath); const existing = readOptional(destPath);
if (existing !== prompt) { if (existing !== prompt) {
@@ -889,7 +908,38 @@ function getMissionPrompt(): string {
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`; return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
} }
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never { interface RuntimeLaunchContext {
readonly fleet?: FleetHarnessContext;
readonly declaredEnv?: Readonly<Record<string, string>>;
}
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of [
'PATH',
'HOME',
'USER',
'LOGNAME',
'SHELL',
'TERM',
'COLORTERM',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_RUNTIME_DIR',
]) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
return { ...env, ...declared };
}
function launchRuntime(
runtime: RuntimeName,
args: string[],
yolo: boolean,
context: RuntimeLaunchContext = {},
): never {
checkMosaicHome(); checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul(); checkSoul();
@@ -909,14 +959,32 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : ''; const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime); writeSessionLock(runtime);
const launchEnv =
context.declaredEnv === undefined ? process.env : minimalLaunchEnv(context.declaredEnv);
// A per-agent profile is the launch SSOT and intentionally does not require a
// second roster registry. Keep roster-v1 identity composition for legacy
// launches, but remove its identity keys from the contract-build environment
// for a profile-backed seat. The declared identity is still exported to the
// harness process below.
const contractEnv =
context.declaredEnv === undefined
? launchEnv
: Object.fromEntries(
Object.entries(launchEnv).filter(
([name]) =>
name !== 'MOSAIC_AGENT_NAME' &&
name !== 'MOSAIC_AGENT_CLASS' &&
name !== 'MOSAIC_AGENT_TOOL_POLICY',
),
);
switch (runtime) { switch (runtime) {
case 'claude': { case 'claude': {
// Audit Claude Code settings and warn about missing hooks/plugins // Audit Claude Code settings and warn about missing hooks/plugins
const settingsAudit = auditClaudeSettings(); const settingsAudit = auditClaudeSettings(context.fleet);
printSettingsWarnings(settingsAudit); printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude'); const prompt = buildRuntimePrompt('claude', contractEnv);
const cliArgs: string[] = []; const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt); cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
@@ -925,13 +993,20 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo); recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
break; break;
} }
case 'codex': { case 'codex': {
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md')); ensureRuntimeConfig(
'codex',
join(harnessHome('codex', context.fleet), 'instructions.md'),
contractEnv,
);
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : []; const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
cliArgs.push(missionPrompt); cliArgs.push(missionPrompt);
@@ -939,22 +1014,38 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo); recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') }); execRuntime('codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break; break;
} }
case 'opencode': { case 'opencode': {
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode. // opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md')); ensureRuntimeConfig(
'opencode',
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
contractEnv,
);
console.log(`[mosaic] Launching ${label}${modeStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo); recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') }); execRuntime('opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break; break;
} }
case 'pi': { case 'pi': {
const prompt = buildRuntimePrompt('pi'); const prompt = buildRuntimePrompt('pi', contractEnv);
const cliArgs = ['--append-system-prompt', prompt]; const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args)); cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension()); cliArgs.push(...discoverPiExtension());
@@ -964,8 +1055,11 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo); recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
execLeaseGatedRuntime('pi', cliArgs); if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
break; break;
} }
} }
@@ -993,6 +1087,7 @@ function execLeaseGatedRuntime(
args: string[], args: string[],
baseEnv: NodeJS.ProcessEnv = process.env, baseEnv: NodeJS.ProcessEnv = process.env,
dangerous = false, dangerous = false,
fleet?: FleetHarnessContext,
): void { ): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py'); const launcher = resolveTool('lease-broker', 'launch-runtime.py');
const dangerousArgs = dangerous ? ['--dangerous'] : []; const dangerousArgs = dangerous ? ['--dangerous'] : [];
@@ -1001,13 +1096,23 @@ function execLeaseGatedRuntime(
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{ {
...baseEnv, ...baseEnv,
...harnessEnv(runtime), ...harnessEnv(runtime, fleet),
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
}, },
); );
} }
/** Fleet entry point reusing the normative runtime launch and exec path. */
export function launchFleetRuntime(
runtime: RuntimeName,
args: string[],
declaredEnv: Readonly<Record<string, string>>,
fleet: FleetHarnessContext,
): never {
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
}
/** exec into the runtime, replacing the current process. */ /** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try { try {