diff --git a/REPORT-T1.md b/REPORT-T1.md new file mode 100644 index 00000000..5ef8a7c2 --- /dev/null +++ b/REPORT-T1.md @@ -0,0 +1,94 @@ +# T1 report: canonical ungated Claude base and lease overlay + +## Changed + +- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`. +- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries. +- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`. +- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`. + +`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change. + +## Lease-hook enumeration + +The actual template has six lease hook entries, matching fred's refined boundary: + +1. `PreToolUse` matcher `.*`: `mutator-gate.py` +2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py` +3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py` +4. `PreCompact`: `revoke-lease.py --reason pre-compact` +5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact` +6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation` + +There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects. + +## Tests and checks + +`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully. + +Red-first run before artifacts existed: + +```text +RUN v2.1.9 .../packages/mosaic +❯ src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed) +× keeps every lease command out of the ungated base + → mutator-gate: expected true to be false +× reconstructs the pre-split gated hooks while retaining the canonical MCP correction + → ENOENT: .../lease-overlay.json +× ships sequential-thinking in the base + → expected undefined to deeply equal { 'sequential-thinking': ... } +× limits the overlay to lease hook entries + → ENOENT: .../lease-overlay.json +``` + +Final focused acceptance run: + +```text +RUN v2.1.9 .../packages/mosaic +✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms +Test Files 1 passed (1) +Tests 4 passed (4) +``` + +`pnpm --filter @mosaicstack/mosaic lint` passed: + +```text +> @mosaicstack/mosaic@0.0.49 lint +> eslint src +``` + +`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2. + +A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated: + +```text +pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts +❯ src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed) +× non-dangerous parser residual is denied by the global all-tools hook without a lease + → expected all-tools mutator-gate command in settings.json +× Claude and Pi compaction observer wiring is complete and fail-closed + → expected PreCompact/SessionStart revoke-lease hooks in settings.json +``` + +The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated. + +## Consumers found + +Direct `runtime/claude/settings.json` path consumers found by the required repository grep: + +- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`. +- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks. +- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base. +- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring. +- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring. +- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base. +- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks. +- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep. + +I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1. + +## Ambiguity handled + +The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key. + +The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work. 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/REPORT-T3.md b/REPORT-T3.md new file mode 100644 index 00000000..6fb2ddd1 --- /dev/null +++ b/REPORT-T3.md @@ -0,0 +1,46 @@ +# T3 report: `mosaic fleet agent new` + +## Changed + +- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`. + - Creates user-owned seats at `~/.mosaic/fleet/agents/` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`). + - Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`. + - Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`. + - Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed. + - Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface. + - Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths. +- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`. +- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison. + +## Reconciliation + +`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged. + +No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch ` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message. + +## Validation + +```text +$ pnpm install --frozen-lockfile +Done in 4.1s using pnpm v10.6.2 + +$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts +✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms +Test Files 1 passed (1) +Tests 13 passed (13) + +$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts +(exit 0) + +$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts +All matched files use Prettier code style! + +$ git diff --check +(exit 0) +``` + +`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`. + +## Skipped ambiguity + +The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner. diff --git a/packages/mosaic/framework/runtime/claude/lease-overlay.json b/packages/mosaic/framework/runtime/claude/lease-overlay.json new file mode 100644 index 00000000..c2deaa11 --- /dev/null +++ b/packages/mosaic/framework/runtime/claude/lease-overlay.json @@ -0,0 +1,70 @@ +{ + "hooks": { + "PreCompact": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact" + } + ] + }, + { + "matcher": "resume|clear", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "^/mosaic-promote$", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py", + "timeout": 15 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py", + "timeout": 3 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0e6dcec1..bf79b5ba 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -1,60 +1,7 @@ { "model": "opus", "hooks": { - "PreCompact": [ - { - "matcher": ".*", - "hooks": [ - { - "type": "command", - "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact" - } - ] - } - ], - "SessionStart": [ - { - "matcher": "compact", - "hooks": [ - { - "type": "command", - "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact" - } - ] - }, - { - "matcher": "resume|clear", - "hooks": [ - { - "type": "command", - "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "^/mosaic-promote$", - "hooks": [ - { - "type": "command", - "command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py", - "timeout": 15 - } - ] - } - ], "PreToolUse": [ - { - "matcher": ".*", - "hooks": [ - { - "type": "command", - "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py", - "timeout": 3 - } - ] - }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ @@ -91,11 +38,6 @@ "Stop": [ { "hooks": [ - { - "type": "command", - "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status", - "timeout": 15 - }, { "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", @@ -315,5 +257,11 @@ "cpan", "nohup" ], - "enableAllMcpTools": true + "enableAllMcpTools": true, + "mcpServers": { + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } } diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts new file mode 100644 index 00000000..97da8aa3 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -0,0 +1,189 @@ +import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetAgentScaffoldCommand } from './fleet-agent-scaffold-command.js'; + +let root: string | undefined; + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function fleetDataHome(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-agent-new-')); + return join(root, '.mosaic'); +} + +function program(dataHome: string): Command { + const result = new Command(); + result.exitOverride(); + const fleet = result.command('fleet'); + registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome }); + return result; +} + +async function files(rootDir: string, prefix = ''): Promise { + const result: string[] = []; + for (const entry of await readdir(join(rootDir, prefix), { withFileTypes: true })) { + const path = join(prefix, entry.name); + if (entry.isDirectory()) result.push(...(await files(rootDir, path))); + else result.push(path); + } + return result.sort(); +} + +describe('mosaic fleet agent new', (): void => { + it('creates the exact authored user-data scaffold under a temp ~/.mosaic root', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + const agent = join(dataHome, 'fleet', 'agents', 'mira'); + + expect(await files(agent)).toEqual([ + '.claude/.claude.json', + '.claude/.credentials.json', + '.claude/CLAUDE.md', + 'SOUL.md', + 'overlay.json', + 'profile.json', + ]); + expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toEqual({ + schema: 1, + harness: 'claude', + bundle: 'primary', + overlay: 'overlay.json', + env: { MOSAIC_AGENT_NAME: 'mira' }, + }); + expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity'); + expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual( + `${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`, + ); + expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe( + join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'), + ); + }); + + it('creates a Pi home without Claude onboarding state', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + 'pi-seat', + '--harness', + 'pi', + ]); + expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([ + '.pi/AGENTS.md', + '.pi/auth.json', + 'SOUL.md', + 'overlay.json', + 'profile.json', + ]); + }); + + it('round-trips quotes, backticks, and shell-looking input literally', async (): Promise => { + const dataHome = await fleetDataHome(); + const name = 'seat"`$(literal)`'; + const bundle = 'bundle"`$(literal)`'; + const model = 'model"`$(literal)`'; + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + name, + '--harness', + 'pi', + '--bundle', + bundle, + '--model', + model, + ]); + const agent = join(dataHome, 'fleet', 'agents', name); + expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toMatchObject({ + harness: 'pi', + bundle, + model, + env: { MOSAIC_AGENT_NAME: name }, + }); + expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain(`You are ${name},`); + expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe( + join(dataHome, 'auth', 'pi', bundle, 'auth.json'), + ); + }); + + it.each(['', '../outside', '/absolute', 'a/b', 'a\\b'])( + 'rejects unsafe agent name %j with a non-zero outcome', + async (name: string): Promise => { + const dataHome = await fleetDataHome(); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', name]); + } catch { + // Commander rejects a missing positional before the action. That is also + // a non-zero CLI failure; all other unsafe names reach the scaffold. + process.exitCode = 1; + } + expect(process.exitCode).toBe(1); + if (name !== '') + expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request')); + }, + ); + + it.each([ + ['--harness', 'codex'], + ['--bundle', '../outside'], + ['--model', ''], + ])( + 'returns non-zero for invalid %s input', + async (option: string, value: string): Promise => { + const dataHome = await fleetDataHome(); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + 'mira', + option, + value, + ]); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request')); + }, + ); + + it('is idempotent for byte-identical content and refuses a changed user file', async (): Promise => { + const dataHome = await fleetDataHome(); + const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']; + await program(dataHome).parseAsync(command); + await program(dataHome).parseAsync(command); + expect(process.exitCode).toBeUndefined(); + + const soul = join(dataHome, 'fleet', 'agents', 'mira', 'SOUL.md'); + await writeFile(soul, '# user-owned change\n'); + const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + await program(dataHome).parseAsync(command); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('SOUL.md')); + expect(await readFile(soul, 'utf8')).toBe('# user-owned change\n'); + }); + + it('does not follow a managed credential link while comparing existing content', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json'); + expect((await lstat(credential)).isSymbolicLink()).toBe(true); + await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts new file mode 100644 index 00000000..64743237 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts @@ -0,0 +1,56 @@ +import type { Command } from 'commander'; +import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agent-scaffold.js'; + +export interface FleetAgentScaffoldCommandDeps { + /** Test seam for the user-owned ~/.mosaic root. */ + readonly fleetDataHome?: string; +} + +interface NewAgentOptions { + readonly harness?: string; + readonly bundle?: string; + readonly model?: string; +} + +/** Registers the user-data seat scaffolder, distinct from roster-v2 CRUD. */ +export function registerFleetAgentScaffoldCommand( + fleetCommand: Command, + deps: FleetAgentScaffoldCommandDeps = {}, +): void { + const agent = fleetCommand + .command('agent') + .description('Manage user-owned fleet agent harness homes'); + + agent + .command('new ') + .description('Create an additive-or-refuse fleet agent harness home') + .option('--harness ', 'Harness: claude or pi', 'claude') + .option('--bundle ', 'Auth bundle selector', 'primary') + .option('--model ', 'Optional harness-native model') + .action(async (name: string, options: NewAgentOptions): Promise => { + try { + const result = await scaffoldFleetAgent({ + name, + harness: options.harness, + bundle: options.bundle, + model: options.model, + ...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }), + }); + console.log( + result.idempotent + ? `Fleet agent "${name}" already matches the scaffold.` + : `Created fleet agent "${name}" at ${result.agentDir}.`, + ); + if (!result.credentialTargetExists) { + console.log( + `Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`, + ); + } + } catch (error: unknown) { + process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof FleetAgentScaffoldError ? error.code : 'scaffold-failed'; + process.stderr.write(`mosaic fleet agent new failed (${code}): ${message}\n`); + } + }); +} 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..39007d66 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -0,0 +1,440 @@ +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, 'runtime', 'claude'), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(namedBundleDir, { recursive: true }); + writeFileSync(join(systemHome, '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('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => { + const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] }; + const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] }; + const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] }; + expect( + deepMergeSettings( + { hooks: { Stop: [qaStop], PreToolUse: [qaPre] } }, + { hooks: { Stop: [leaseStop] } }, + ), + ).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } }); + }); + + it('still deletes a whole hook event via the null tombstone', () => { + expect( + deepMergeSettings( + { hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } }, + { hooks: { Stop: null } }, + ), + ).toEqual({ hooks: {} }); + }); + + it('keeps replace semantics for arrays outside the top-level hooks object', () => { + expect( + deepMergeSettings( + { plugins: ['base'], nested: { hooks: { Stop: ['base'] } } }, + { plugins: ['user'], nested: { hooks: { Stop: ['user'] } } }, + ), + ).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } }); + }); + + 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('unscaffolded agent names', () => { + it('points an unscaffolded name at mosaic fleet agent new', () => { + const fx = fixture(); + try { + resolveFleetLaunchComposition('ghost', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + throw new Error('expected resolution to fail'); + } catch (error: unknown) { + const launchError = error as FleetLaunchError; + expect(launchError.code).toBe('AGENT_NOT_SCAFFOLDED'); + expect(launchError.message).toContain("no such fleet agent 'ghost'"); + expect(launchError.message).toContain('mosaic fleet agent new ghost'); + } + }); +}); + +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('tolerates harness metadata files in the install root and still refuses real directories', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: [] }); + const pluginHome = join(fx.agentDir, '.claude', 'plugins'); + mkdirSync(pluginHome, { recursive: true }); + writeFileSync(join(pluginHome, 'installed_plugins.json'), '{}\n'); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + expect(plan.prune).toEqual([]); + expect(readFileSync(join(pluginHome, 'installed_plugins.json'), 'utf8')).toBe('{}\n'); + + mkdirSync(join(pluginHome, 'stray-plugin'), { recursive: true }); + expect(() => + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }), + ).toThrowError(/real plugin directory occupies managed install root.*refusing to prune/i); + }); + + 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, '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/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..42ebbe01 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -0,0 +1,744 @@ +import { + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, + type Stats, +} from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import type { Command } from 'commander'; +import { + harnessHome, + launchFleetRuntime, + type FleetHarnessContext, + type RuntimeName, +} from './launch.js'; +import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; + +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' + | 'AGENT_NOT_SCAFFOLDED' + | 'COMPOSITION_FAILED' + | 'FIRST_AUTH_REFUSAL'; + +export class FleetLaunchError extends Error { + constructor( + readonly code: FleetLaunchErrorCode, + message: string, + ) { + super(message); + this.name = 'FleetLaunchError'; + } +} + +export interface FleetAgentLaunchProfile { + readonly schema: 1; + readonly harness: RuntimeName; + readonly bundle: string; + readonly model?: string; + readonly overlay?: string; + readonly plugins: readonly string[]; + readonly skills: readonly string[]; + readonly env: Readonly>; +} + +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; +} + +type MergeContext = 'root' | 'hooks' | 'nested'; + +function mergeObject( + lower: Record, + higher: Record, + context: MergeContext = 'nested', +): 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]; + if (context === 'hooks' && Array.isArray(lowValue) && Array.isArray(highValue)) { + result[key] = [...(lowValue as unknown[]), ...(cloneValue(highValue) as unknown[])]; + continue; + } + result[key] = + isPlainObject(lowValue) && isPlainObject(highValue) + ? mergeObject( + lowValue, + highValue, + context === 'root' && key === 'hooks' ? 'hooks' : 'nested', + ) + : cloneValue(highValue); + } + return result; +} + +/** + * Deep object merge. Scalars and arrays replace; null in a higher layer + * deletes. Exception: hook event arrays directly under the top-level `hooks` + * key concatenate (base entries first), so an overlay ADDS gating without + * erasing the base QA hooks that share an event — replacing them would make + * the gap-7 base/overlay split unimplementable without duplicating base + * hooks inside the lease overlay. Removing an event entirely still works via + * the null tombstone. + */ +export function deepMergeSettings( + ...layers: ReadonlyArray | undefined> +): Record { + return layers.reduce>( + (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')), + {}, + ); +} + +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()) { + // The harness writes its own metadata files (e.g. installed_plugins.json) + // beside the managed links; only a real directory is an unmanaged entry + // the pruner would orphan. + if (entry.isDirectory()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real ${kind} directory occupies managed install root ${path}; refusing to prune it.`, + ); + } + continue; + } + 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); + if (lstatIfPresent(agentDir) === undefined) { + throw new FleetLaunchError( + 'AGENT_NOT_SCAFFOLDED', + `no such fleet agent '${name}' — run: mosaic fleet agent new ${name}`, + ); + } + assertRealDirectory(agentDir, 'fleet agent directory'); + const profilePath = join(agentDir, 'profile.json'); + const profileInfo = lstatIfPresent(profilePath); + if (!profileInfo?.isFile() || profileInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `agent profile must be a real, non-symlink file: ${profilePath}`, + ); + } + const profile = parseFleetAgentProfile(readFileSync(profilePath, 'utf8')); + const context: FleetHarnessContext = { agentDir }; + const seatHome = harnessHome(profile.harness, context); + const seatHomeInfo = lstatIfPresent(seatHome); + if (seatHomeInfo && (!seatHomeInfo.isDirectory() || seatHomeInfo.isSymbolicLink())) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `agent harness home must be a real, non-symlink directory: ${seatHome}`, + ); + } + const settingsOutput = join(seatHome, 'settings.json'); + const settingsSnapshot = join(agentDir, 'settings.generated.json'); + for (const [label, path] of [ + ['generated settings', settingsOutput], + ['generated settings snapshot', settingsSnapshot], + ] as const) { + const info = lstatIfPresent(path); + if (info && (!info.isFile() || info.isSymbolicLink())) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${label} path must be a real file or absent: ${path}`, + ); + } + } + const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json'); + assertContained(agentDir, overlayPath, 'agent overlay'); + const layers: SettingsLayer[] = [ + readSettingsLayer( + 'system', + join(roots.systemHome, '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 ?? defaultFleetDataHome(); + const passthrough = command.args.slice(1); + const plan = resolveFleetLaunchComposition( + name, + { systemHome: systemHomeFor(), userHome }, + passthrough, + ); + if (opts.dryRun === true) { + process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`); + return; + } + applyFleetLaunchComposition(plan); + console.log(`[mosaic] bundle: ${plan.bundle.display}`); + const launcher = deps.launcher ?? launchFleetRuntime; + 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..c6e6b516 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -82,6 +82,7 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', + 'agent', 'apply', 'backlog', 'create', @@ -91,6 +92,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..03b840f9 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -38,6 +38,10 @@ import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, } from './fleet-agent-crud-command.js'; +import { + registerFleetAgentScaffoldCommand, + type FleetAgentScaffoldCommandDeps, +} from './fleet-agent-scaffold-command.js'; import { registerFleetMigrationCommand, type FleetMigrationCommandDeps, @@ -63,6 +67,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 +102,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. @@ -104,6 +113,8 @@ export interface FleetCommandDeps { */ isStdinTTY?: boolean; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; + /** Test-only user-data root for `fleet agent new` (production: ~/.mosaic). */ + fleetDataHome?: FleetAgentScaffoldCommandDeps['fleetDataHome']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; migrationDeps?: Omit; } @@ -2041,6 +2052,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); @@ -2054,6 +2073,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // profile. DRY-RUN by default; --write persists under the same --mosaic-home. registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); + // `fleet agent new` owns user-data harness homes under ~/.mosaic. The + // existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there + // is one `agent` namespace but deliberately separate state authorities. + registerFleetAgentScaffoldCommand(cmd, { fleetDataHome: deps.fleetDataHome }); // Roster-v2 desired-state mutations belong directly to the fleet control // plane; they do not share the root `mosaic agent` gateway-backed surface. registerFleetAgentCrudCommands(cmd, deps); 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 { diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts new file mode 100644 index 00000000..fbb4dbaa --- /dev/null +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -0,0 +1,270 @@ +import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +export type FleetAgentHarness = 'claude' | 'pi'; + +export interface FleetAgentScaffoldOptions { + readonly dataHome?: string; + readonly name: string; + readonly harness?: string; + readonly bundle?: string; + readonly model?: string; +} + +export interface FleetAgentScaffoldResult { + readonly agentDir: string; + readonly profile: Readonly>; + readonly idempotent: boolean; + readonly credentialTarget: string; + readonly credentialTargetExists: boolean; +} + +export class FleetAgentScaffoldError extends Error { + readonly code: 'invalid-request' | 'agent-exists-different'; + + constructor(code: FleetAgentScaffoldError['code'], message: string) { + super(message); + this.name = 'FleetAgentScaffoldError'; + this.code = code; + } +} + +/** User-owned data root, deliberately distinct from the update-owned mosaic home. */ +export function defaultFleetDataHome(): string { + return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic'); +} + +/** + * Materialize one fleet seat from authored, deterministic template content. + * Settings composition intentionally does not happen here: launch owns the + * three-layer settings merge and writes the generated settings.json then. + */ +export async function scaffoldFleetAgent( + options: FleetAgentScaffoldOptions, +): Promise { + const name = requireSafeName(options.name); + const harness = requireHarness(options.harness ?? 'claude'); + const bundle = requireBundle(options.bundle ?? 'primary'); + const model = optionalNonEmpty(options.model, '--model'); + const dataHome = resolve(options.dataHome ?? defaultFleetDataHome()); + const agentDir = join(dataHome, 'fleet', 'agents', name); + const homeName = harness === 'claude' ? '.claude' : '.pi'; + const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; + const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName); + const profile: Record = { + schema: 1, + harness, + bundle, + overlay: 'overlay.json', + ...(model === undefined ? {} : { model }), + env: { MOSAIC_AGENT_NAME: name }, + }; + const entries: [string, ExpectedFile][] = [ + ['profile.json', { type: 'file', content: json(profile) }], + ['SOUL.md', { type: 'file', content: soul(name) }], + ['overlay.json', { type: 'file', content: '{}\n' }], + [ + join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'), + { type: 'file', content: identityBootstrap(name) }, + ], + [join(homeName, credentialName), { type: 'symlink', target: credentialTarget }], + ]; + if (harness === 'claude') { + entries.push([ + join(homeName, '.claude.json'), + { type: 'file', content: json(onboardingState()) }, + ]); + } + const files = new Map(entries); + + const differences = await findDifferences(agentDir, files); + if (differences.length > 0) { + throw new FleetAgentScaffoldError( + 'agent-exists-different', + `Agent "${name}" already exists with different user-owned file(s): ${differences.join(', ')}. Refusing to overwrite.`, + ); + } + + const idempotent = await pathExists(agentDir); + if (!idempotent) { + for (const [file, expected] of files) { + const path = join(agentDir, file); + await mkdir(join(path, '..'), { recursive: true, mode: 0o700 }); + if (expected.type === 'file') { + await writeFile(path, expected.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + } else { + // A dangling link is intentional before enrollment. It makes absent auth + // visible at launch instead of silently selecting another account. + await symlink(expected.target, path); + } + } + } + + return { + agentDir, + profile, + idempotent, + credentialTarget, + credentialTargetExists: await pathExists(credentialTarget), + }; +} + +type ExpectedFile = + | { readonly type: 'file'; readonly content: string } + | { readonly type: 'symlink'; readonly target: string }; + +async function findDifferences( + agentDir: string, + expected: ReadonlyMap, +): Promise { + let root; + try { + root = await lstat(agentDir); + } catch (error: unknown) { + if (isMissing(error)) return []; + throw error; + } + if (!root.isDirectory() || root.isSymbolicLink()) return ['.']; + + const actual = await listRelativeEntries(agentDir); + const expectedDirs = new Set(); + for (const path of expected.keys()) { + const parent = relative('.', join(path, '..')); + if (parent !== '') expectedDirs.add(parent); + } + const paths = new Set([ + ...expected.keys(), + ...actual.filter((path: string): boolean => !expectedDirs.has(path)), + ]); + const differences: string[] = []; + for (const path of [...paths].sort()) { + const required = expected.get(path); + if (!required) { + differences.push(path); + continue; + } + try { + const info = await lstat(join(agentDir, path)); + if (required.type === 'file') { + if ( + !info.isFile() || + info.isSymbolicLink() || + (await readFile(join(agentDir, path), 'utf8')) !== required.content + ) { + differences.push(path); + } + } else if ( + !info.isSymbolicLink() || + (await readlink(join(agentDir, path))) !== required.target + ) { + differences.push(path); + } + } catch (error: unknown) { + if (isMissing(error)) differences.push(path); + else throw error; + } + } + return differences; +} + +async function listRelativeEntries(root: string, prefix = ''): Promise { + const result: string[] = []; + for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { + const path = join(prefix, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + result.push(path, ...(await listRelativeEntries(root, path))); + } else { + result.push(path); + } + } + return result; +} + +function requireSafeName(value: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value === '.' || + value === '..' || + value.includes('/') || + value.includes('\\') || + value.includes('\0') || + isAbsolute(value) + ) { + throw new FleetAgentScaffoldError( + 'invalid-request', + 'Agent name must be one non-empty path component (not absolute or traversal).', + ); + } + return value; +} + +function requireHarness(value: string): FleetAgentHarness { + if (value === 'claude' || value === 'pi') return value; + throw new FleetAgentScaffoldError('invalid-request', '--harness must be claude or pi.'); +} + +function requireBundle(value: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value === '.' || + value === '..' || + value.includes('/') || + value.includes('\\') || + value.includes('\0') || + isAbsolute(value) + ) { + throw new FleetAgentScaffoldError( + 'invalid-request', + '--bundle must be one non-empty auth-bundle path component.', + ); + } + return value; +} + +function optionalNonEmpty(value: string | undefined, option: string): string | undefined { + if (value === undefined) return undefined; + if (value.length === 0 || value.includes('\0')) { + throw new FleetAgentScaffoldError('invalid-request', `${option} must be a non-empty string.`); + } + return value; +} + +function onboardingState(): Record { + return { hasCompletedOnboarding: true, theme: 'dark' }; +} + +function soul(name: string): string { + return `# SOUL\n\n## Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nRole: _Describe this seat's role._\n`; +} + +/** Identity is materialized by value so restricted harness modes never need to read SOUL.md. */ +function identityBootstrap(name: string): string { + return `# Mosaic Fleet Agent Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nYour mechanical identity is ${name} (MOSAIC_AGENT_NAME). Keep this identity when working in repositories with other personas.\n`; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error: unknown) { + if (isMissing(error)) return false; + throw error; + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +/** Guardrail kept explicit for callers that construct paths from untrusted text. */ +export function isContainedInFleetDataHome(dataHome: string, path: string): boolean { + const rel = relative(resolve(dataHome), resolve(path)); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +} diff --git a/packages/mosaic/src/lease-broker/promotion_trigger_unittest.py b/packages/mosaic/src/lease-broker/promotion_trigger_unittest.py index 3f5deff1..0026f457 100644 --- a/packages/mosaic/src/lease-broker/promotion_trigger_unittest.py +++ b/packages/mosaic/src/lease-broker/promotion_trigger_unittest.py @@ -23,6 +23,7 @@ COMPLETE_PATH = TOOLS / "promote-complete.py" OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py" RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py" CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json" +CLAUDE_LEASE_OVERLAY = FRAMEWORK / "runtime/claude/lease-overlay.json" CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md" SESSION_ID = "a" * 64 CHALLENGE = "b" * 64 @@ -569,8 +570,15 @@ class PromotionCompleteTest(PromotionHookFixture): class PromotionTemplateWiringTest(unittest.TestCase): def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None: - settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8")) - hooks = settings["hooks"] + base = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8")) + overlay = json.loads(CLAUDE_LEASE_OVERLAY.read_text(encoding="utf-8")) + # A launched seat composes base + lease overlay; hook event arrays + # concatenate with base entries first, so wiring is asserted against + # the composed view rather than either file alone. + hooks: dict[str, list] = {} + for layer in (base["hooks"], overlay["hooks"]): + for event, groups in layer.items(): + hooks.setdefault(event, []).extend(groups) submit_commands = [ hook["command"] for group in hooks["UserPromptSubmit"] diff --git a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts index f2f48e2e..dbc27b41 100644 --- a/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts +++ b/packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts @@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { afterEach, describe, expect, test } from 'vitest'; import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js'; +import { deepMergeSettings } from '../commands/fleet-launch-command.js'; import { observeAndPromoteReceiptChallenge, requestBrokerReply, @@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py') const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py'); const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); +const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json'); + +// The gated seat contract is the COMPOSITION of the ungated base and the +// lease overlay (gap-7 split); assertions about lease wiring must read that +// composed view, produced by the same merge the launcher uses. +async function readGatedClaudeSettings(): Promise> { + const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record; + const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record< + string, + unknown + >; + return deepMergeSettings(base, overlay); +} const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts'); const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh'); @@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => { expect(parserResult.status).toBe(0); expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 }); - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> }; }; const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*'); @@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => { }); test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => { - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: Record }>>; }; expect( @@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese await promote(socket, sessionId, pending.receipt_challenge!); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0); - const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { + const settings = (await readGatedClaudeSettings()) as unknown as { hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> }; }; expect( diff --git a/packages/mosaic/src/runtime/claude-settings-base.spec.ts b/packages/mosaic/src/runtime/claude-settings-base.spec.ts new file mode 100644 index 00000000..71cf7c95 --- /dev/null +++ b/packages/mosaic/src/runtime/claude-settings-base.spec.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +type JsonObject = { [key: string]: Json }; + +const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url)); +const basePath = `${frameworkRoot}runtime/claude/settings.json`; +const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`; +const gatedFixturePath = fileURLToPath( + new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url), +); + +function readJson(path: string): JsonObject { + return JSON.parse(readFileSync(path, 'utf8')) as JsonObject; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// This fixture contract composes hook event arrays additively. It is deliberately +// limited to verifying that the split is lossless; production launch merge +// semantics remain owned by W-F1. +function deepMerge(base: Json, overlay: Json): Json { + if (Array.isArray(base) && Array.isArray(overlay)) { + const merged = [...base]; + for (const entry of overlay) { + if (!isObject(entry) || !Array.isArray(entry['hooks'])) { + merged.push(entry); + continue; + } + const matchingIndex = merged.findIndex( + (candidate) => + isObject(candidate) && + Array.isArray(candidate['hooks']) && + candidate['matcher'] === entry['matcher'], + ); + if (matchingIndex === -1) merged.push(entry); + else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry); + } + return merged; + } + if (isObject(base) && isObject(overlay)) { + const merged: JsonObject = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + merged[key] = key in merged ? deepMerge(merged[key]!, value) : value; + } + return merged; + } + return overlay; +} + +function normalize(value: Json): Json { + if (Array.isArray(value)) { + return value + .map(normalize) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); + } + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, normalize(nested)]), + ); + } + return value; +} + +function hookCommands(settings: JsonObject): string[] { + const hooks = settings['hooks']; + if (!isObject(hooks)) return []; + + return Object.values(hooks).flatMap((event) => { + if (!Array.isArray(event)) return []; + return event.flatMap((entry) => { + if (!isObject(entry) || !Array.isArray(entry['hooks'])) return []; + return entry['hooks'].flatMap((hook) => + isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [], + ); + }); + }); +} + +const sequentialThinking: JsonObject = { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], +}; + +describe('canonical Claude base and lease-promotion overlay', () => { + it('keeps every lease command out of the ungated base', () => { + const base = readJson(basePath); + const commands = hookCommands(base); + + for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) { + expect( + commands.some((command) => command.includes(marker)), + marker, + ).toBe(false); + } + }); + + it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => { + const base = readJson(basePath); + const overlay = readJson(overlayPath); + const preSplit = readJson(gatedFixturePath); + const expected: JsonObject = { + ...preSplit, + mcpServers: { 'sequential-thinking': sequentialThinking }, + }; + + expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected)); + }); + + it('ships sequential-thinking in the base', () => { + const base = readJson(basePath); + expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking }); + }); + + it('limits the overlay to lease hook entries', () => { + const overlay = readJson(overlayPath); + expect(Object.keys(overlay)).toEqual(['hooks']); + + const commands = hookCommands(overlay); + expect(commands).toHaveLength(6); + for (const command of commands) { + expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/); + } + }); +}); diff --git a/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json b/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json new file mode 100644 index 00000000..0e6dcec1 --- /dev/null +++ b/packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json @@ -0,0 +1,319 @@ +{ + "model": "opus", + "hooks": { + "PreCompact": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact" + } + ] + }, + { + "matcher": "resume|clear", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "^/mosaic-promote$", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py", + "timeout": 15 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py", + "timeout": 3 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/qa-hook-stdin.sh", + "timeout": 60 + } + ] + }, + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/typecheck-hook.sh", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status", + "timeout": 15 + }, + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", + "timeout": 15 + } + ] + } + ] + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true, + "feature-dev@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "pr-review-toolkit@claude-plugins-official": true + }, + "skipDangerousModePermissionPrompt": true, + "allowedCommands": [ + "npm", + "npm install", + "npm run", + "npm test", + "npm build", + "npm start", + "npm run dev", + "npm run build", + "npm run lint", + "npm run typecheck", + "npm run test:ci", + "npm run test:e2e", + "npm run test:unit", + "npm run test:integration", + "npm run test:cov", + "npm run test:security", + "npm run security:scan", + "npm run security:audit", + "npm run performance:benchmark", + "npm run build:dev", + "npm run build:prod", + "npm run test", + "npm run test:watch", + "npm run migrate", + "npm run migrate:rollback", + "npm run db:seed", + "npm run db:reset", + "node", + "yarn", + "pnpm", + "npx", + "npx tsc", + "npx eslint", + "npx prettier", + "npx jest", + "npx vitest", + "git", + "git add", + "git commit", + "git push", + "git pull", + "git status", + "git diff", + "git log", + "git branch", + "git checkout", + "git merge", + "git init", + "git remote", + "git fetch", + "git reset", + "git rebase", + "git stash", + "git tag", + "git show", + "git config", + "gh", + "gh issue", + "gh pr", + "gh repo", + "gh api", + "docker", + "docker build", + "docker run", + "docker ps", + "docker logs", + "docker exec", + "docker stop", + "docker start", + "docker pull", + "docker push", + "docker-compose", + "docker-compose up", + "docker-compose down", + "docker-compose build", + "docker-compose logs", + "docker-compose ps", + "docker-compose exec", + "kubectl", + "kubectl get", + "kubectl describe", + "kubectl logs", + "kubectl apply", + "kubectl delete", + "kubectl port-forward", + "mkdir", + "touch", + "chmod", + "chown", + "ls", + "cd", + "pwd", + "cp", + "mv", + "rm", + "cat", + "echo", + "head", + "tail", + "grep", + "grep -E", + "grep -r", + "find", + "find -name", + "find -type", + "find -path", + "find -exec", + "find . -type f", + "find . -type d", + "wc", + "sort", + "uniq", + "curl", + "wget", + "ping", + "netstat", + "ss", + "lsof", + "psql", + "pg_dump", + "pg_restore", + "sqlite3", + "jest", + "vitest", + "playwright", + "cypress", + "artillery", + "lighthouse", + "tsc", + "eslint", + "prettier", + "snyk", + "semgrep", + "tar", + "gzip", + "unzip", + "zip", + "which", + "whoami", + "id", + "env", + "export", + "source", + "sleep", + "date", + "uptime", + "df", + "du", + "free", + "top", + "htop", + "ps", + "tree", + "jq", + "sed", + "awk", + "xargs", + "tee", + "test", + "true", + "false", + "basename", + "dirname", + "realpath", + "readlink", + "stat", + "file", + "make", + "cmake", + "gcc", + "g++", + "clang", + "python", + "python3", + "pip", + "pip3", + "pip install", + "poetry", + "pipenv", + "go", + "go build", + "go test", + "go run", + "go mod", + "cargo", + "rustc", + "ruby", + "gem", + "bundle", + "rake", + "java", + "javac", + "mvn", + "gradle", + "dotnet", + "msbuild", + "php", + "composer", + "perl", + "cpan", + "nohup" + ], + "enableAllMcpTools": true +}