From 378c227cbb50027eb68c47403c51d3e997e8368b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 13 Aug 2026 11:30:53 -0500 Subject: [PATCH] feat(fleet): compose and launch profile-backed seats --- REPORT-T2.md | 102 +++ .../src/commands/fleet-launch-command.spec.ts | 370 +++++++++ .../src/commands/fleet-launch-command.ts | 713 ++++++++++++++++++ packages/mosaic/src/commands/fleet.spec.ts | 1 + packages/mosaic/src/commands/fleet.ts | 13 + packages/mosaic/src/commands/launch.ts | 177 ++++- 6 files changed, 1340 insertions(+), 36 deletions(-) create mode 100644 REPORT-T2.md create mode 100644 packages/mosaic/src/commands/fleet-launch-command.spec.ts create mode 100644 packages/mosaic/src/commands/fleet-launch-command.ts diff --git a/REPORT-T2.md b/REPORT-T2.md new file mode 100644 index 00000000..f802fad5 --- /dev/null +++ b/REPORT-T2.md @@ -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 [--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//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 `/settings.json` and the future harvest comparison snapshot to `/settings.generated.json`. +- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com (fred@example.com)`. +- 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. diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts new file mode 100644 index 00000000..53891c67 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -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 = { 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":"fred@example.com"}}\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 (fred@example.com)'); + }); + + 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, ''); + + expect(output).toMatchInlineSnapshot(` + "mosaic fleet launch fred --dry-run + profile: /user/fleet/agents/fred/profile.json (schema 1) + harness: claude + seat-home: /user/fleet/agents/fred/.claude + settings sources: + system: /system/framework/runtime/claude/settings.json + user: /user/config/claude/settings.json + agent: /user/fleet/agents/fred/overlay.json + output: /user/fleet/agents/fred/.claude/settings.json + snapshot: /user/fleet/agents/fred/settings.generated.json + merged settings: + { + "hooks": [ + "user" + ], + "nested": { + "agent": true, + "system": true, + "user": true + } + } + bundle: primary -> fred_example.com (fred@example.com) + symlinks: + credentials: /user/fleet/agents/fred/.claude/.credentials.json -> /user/auth/claude/fred_example.com/.credentials.json + plugin code-review: /user/fleet/agents/fred/.claude/plugins/code-review -> /user/plugins/code-review + skill mosaic-tools: /user/fleet/agents/fred/.claude/skills/mosaic-tools -> /user/skills/mosaic-tools + declared env: + CLAUDE_CONFIG_DIR=/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); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts new file mode 100644 index 00000000..30e5f11a --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -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 = { + 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>; +} + +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; +} + +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; + 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>; + readonly argv: readonly string[]; +} + +export interface FleetLaunchCommandDeps { + readonly userHome?: string; + readonly launcher?: ( + runtime: RuntimeName, + args: string[], + declaredEnv: Readonly>, + context: FleetHarnessContext, + ) => void; +} + +function requiredObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a JSON object.`); + } + return value as Record; +} + +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 = {}; + 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 { + 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, + higher: Record, +): Record { + const result = cloneValue(lower) as Record; + 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 | undefined> +): Record { + return layers.reduce>( + (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; + 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 { + 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 = { + claude: 'CLAUDE_CONFIG_DIR', + pi: 'PI_CODING_AGENT_DIR', + codex: 'CODEX_HOME', + opencode: 'XDG_CONFIG_HOME', + }; + const env: Record = { + ...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 [--dry-run]` on the fleet control plane. */ +export function registerFleetLaunchCommand( + fleetCommand: Command, + systemHomeFor: () => string, + deps: FleetLaunchCommandDeps = {}, +): Command { + return fleetCommand + .command('launch ') + .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`); + } + }); +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 9aaaf055..b18d98be 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -91,6 +91,7 @@ describe('registerFleetCommand', () => { 'init', 'install', 'install-systemd', + 'launch', 'migrate-v1', 'persona', 'plan', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..73e1f568 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -63,6 +63,7 @@ import { registerFleetBacklogCommand } from './fleet-backlog.js'; import { registerFleetPersonaCommand } from './fleet-personas.js'; import { registerFleetProfileCommand } from './fleet-profiles.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). @@ -97,6 +98,10 @@ export interface FleetCommandDeps { */ sleepFn?: SleepFn; 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; /** * 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. 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 // from /fleet/profiles/*.yaml using the same --mosaic-home flag. registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index c4d8f3d2..0466a0f3 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -35,7 +35,12 @@ import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); 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 = { claude: 'Claude Code', @@ -64,19 +69,19 @@ const HARNESS_HOME_ENV: Record = { opencode: 'XDG_CONFIG_HOME', }; -/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/. */ -function harnessHome(runtime: RuntimeName): string { - return join(MOSAIC_HOME, `.${runtime}`); +/** Dedicated runtime home, optionally scoped to a user fleet agent. */ +export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string { + return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`); } /** * 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. */ -function harnessEnv(runtime: RuntimeName): Record { +function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record { const key = HARNESS_HOME_ENV[runtime]; if (!key) return {}; - const home = harnessHome(runtime); + const home = harnessHome(runtime, fleet); mkdirSync(home, { recursive: true }); 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 { mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 }); // 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', cwd: process.cwd(), cli_version: CLI_VERSION, - config_home: harnessHome(runtime), + config_home: harnessHome(runtime, fleet), config_home_isolated: true, config_home_env: HARNESS_HOME_ENV[runtime] ?? null, argv: redactArgv(cliArgs), normative_fragments: normativeFragmentDigests(runtime), // names only — values are never recorded - mosaic_env_present: Object.keys(process.env) + mosaic_env_present: Object.keys(launchEnv) .filter((k) => k.startsWith('MOSAIC_')) .sort(), }; @@ -262,9 +273,9 @@ interface SettingsAudit { warnings: string[]; } -function auditClaudeSettings(): SettingsAudit { +function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit { const warnings: string[] = []; - const settingsPath = join(harnessHome('claude'), 'settings.json'); + const settingsPath = join(harnessHome('claude', fleet), 'settings.json'); const settings = readJson(settingsPath); if (!settings) { @@ -483,7 +494,11 @@ function buildPrdBlock(): string { * `mosaicHome` is parameterized for testability; production callers use the * 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 = { claude: join(mosaicHome, 'runtime', 'claude', '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); } - const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']); + const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']); if (!fleetIdentity.ok) { throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); } const canonicalMember = fleetIdentity.identity?.member; - if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) { - const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass; + if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) { + const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass; if (ambientClass !== canonicalMember.className) { throw new Error( `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 // member resolved above. Non-fleet launches retain the legacy ambient persona // 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); if (persona) parts.push('\n\n' + persona); const toolPolicyName = canonicalMember ? canonicalMember.toolPolicy - : process.env['MOSAIC_AGENT_TOOL_POLICY']; + : env['MOSAIC_AGENT_TOOL_POLICY']; const toolPolicy = readFleetToolPolicyBlock(toolPolicyName); 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. */ -function buildRuntimePrompt(runtime: RuntimeName): string { - return composeContract(runtime); +function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string { + return composeContract(runtime, MOSAIC_HOME, env); } // ─── Session lock ──────────────────────────────────────────────────────────── @@ -695,8 +710,12 @@ function checkResumableSession(): void { // ─── Write config for runtimes that read from fixed paths ──────────────────── -function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void { - const prompt = buildRuntimePrompt(runtime); +function ensureRuntimeConfig( + runtime: RuntimeName, + destPath: string, + env: NodeJS.ProcessEnv = process.env, +): void { + const prompt = buildRuntimePrompt(runtime, env); mkdirSync(dirname(destPath), { recursive: true }); const existing = readOptional(destPath); if (existing !== prompt) { @@ -889,7 +908,38 @@ function getMissionPrompt(): string { 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>; +} + +function minimalLaunchEnv(declared: Readonly>): 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(); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); checkSoul(); @@ -909,14 +959,32 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev const missionStr = hasMissionNoArgs ? ' (active mission detected)' : ''; 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) { case 'claude': { // Audit Claude Code settings and warn about missing hooks/plugins - const settingsAudit = auditClaudeSettings(); + const settingsAudit = auditClaudeSettings(context.fleet); printSettingsWarnings(settingsAudit); - const prompt = buildRuntimePrompt('claude'); + const prompt = buildRuntimePrompt('claude', contractEnv); const cliArgs: string[] = []; cliArgs.push('--append-system-prompt', prompt); if (hasMissionNoArgs) { @@ -925,13 +993,20 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('claude', cliArgs, yolo); - execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); + recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv); + if (process.env['MOSAIC_LAUNCH_ID']) { + launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID']; + } + execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet); break; } 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'] : []; if (hasMissionNoArgs) { cliArgs.push(missionPrompt); @@ -939,22 +1014,38 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('codex', cliArgs, yolo); - execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') }); + recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv); + execRuntime('codex', cliArgs, { + ...launchEnv, + ...harnessEnv('codex', context.fleet), + ...(process.env['MOSAIC_LAUNCH_ID'] + ? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] } + : {}), + }); break; } case '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}...`); - recordLaunch('opencode', args, yolo); - execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') }); + recordLaunch('opencode', args, yolo, context.fleet, launchEnv); + execRuntime('opencode', args, { + ...launchEnv, + ...harnessEnv('opencode', context.fleet), + ...(process.env['MOSAIC_LAUNCH_ID'] + ? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] } + : {}), + }); break; } case 'pi': { - const prompt = buildRuntimePrompt('pi'); + const prompt = buildRuntimePrompt('pi', contractEnv); const cliArgs = ['--append-system-prompt', prompt]; cliArgs.push(...buildPiSkillArgs(args)); cliArgs.push(...discoverPiExtension()); @@ -964,8 +1055,11 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev cliArgs.push(...args); } console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); - recordLaunch('pi', cliArgs, yolo); - execLeaseGatedRuntime('pi', cliArgs); + recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv); + if (process.env['MOSAIC_LAUNCH_ID']) { + launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID']; + } + execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet); break; } } @@ -993,6 +1087,7 @@ function execLeaseGatedRuntime( args: string[], baseEnv: NodeJS.ProcessEnv = process.env, dangerous = false, + fleet?: FleetHarnessContext, ): void { const launcher = resolveTool('lease-broker', 'launch-runtime.py'); const dangerousArgs = dangerous ? ['--dangerous'] : []; @@ -1001,13 +1096,23 @@ function execLeaseGatedRuntime( [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], { ...baseEnv, - ...harnessEnv(runtime), + ...harnessEnv(runtime, fleet), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), 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>, + fleet: FleetHarnessContext, +): never { + return launchRuntime(runtime, args, false, { fleet, declaredEnv }); +} + /** exec into the runtime, replacing the current process. */ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { try {