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..edd34d77 --- /dev/null +++ b/packages/mosaic/framework/runtime/claude/lease-overlay.json @@ -0,0 +1,89 @@ +{ + "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": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh", + "timeout": 10 + } + ] + }, + { + "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": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", + "timeout": 15 + } + ] + }, + { + "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 e1d81471..0b893ba0 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": [ @@ -101,11 +48,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", @@ -325,5 +267,11 @@ "cpan", "nohup" ], - "enableAllMcpTools": true + "enableAllMcpTools": true, + "mcpServers": { + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } } diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking index ef1a6d0d..4fb2e4e8 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking +++ b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking @@ -5,6 +5,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MODE="apply" RUNTIME="all" STRICT_CHECK=0 +CLAUDE_CONFIG_DIR="" PKG="@modelcontextprotocol/server-sequential-thinking" @@ -29,6 +30,14 @@ while [[ $# -gt 0 ]]; do STRICT_CHECK=1 shift ;; + --claude-config-dir) + if [[ $# -lt 2 ]]; then + err "--claude-config-dir requires an absolute seat config directory" + exit 2 + fi + CLAUDE_CONFIG_DIR="$2" + shift 2 + ;; *) err "Unknown argument: $1" exit 2 @@ -67,11 +76,19 @@ warm_package() { } check_claude_config() { - python3 - <<'PY' + CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".claude" / "settings.json" -if not p.exists(): +# Claude reads MCP definitions from .claude.json, not settings.json. The +# settings.json fallback preserves legacy operator flows until their config is migrated. +config_dir = os.environ.get("CLAUDE_CONFIG_DIR") +p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" +if not p.exists() and not config_dir: + p = Path.home() / ".claude" / "settings.json" +# Only explicit fleet seats require a private, non-symlink config. Operator +# config remains compatible with pre-existing permission conventions. +if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0): raise SystemExit(1) try: data = json.loads(p.read_text(encoding="utf-8")) @@ -92,10 +109,15 @@ PY } apply_claude_config() { - python3 - <<'PY' + CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".claude" / "settings.json" +# Claude reads MCP definitions from .claude.json for both operator and +# explicitly isolated fleet config dirs. The checker retains a settings.json +# fallback only to avoid breaking legacy operator configurations. +config_dir = os.environ.get("CLAUDE_CONFIG_DIR") +p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" p.parent.mkdir(parents=True, exist_ok=True) if p.exists(): try: @@ -117,7 +139,7 @@ PY } check_codex_config() { - local cfg="$HOME/.codex/config.toml" + local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" [[ -f "$cfg" ]] || return 1 grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \ grep -q '^command = "npx"' "$cfg" && \ @@ -125,7 +147,7 @@ check_codex_config() { } apply_codex_config() { - local cfg="$HOME/.codex/config.toml" + local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml" mkdir -p "$(dirname "$cfg")" [[ -f "$cfg" ]] || touch "$cfg" @@ -148,10 +170,11 @@ apply_codex_config() { } check_opencode_config() { - python3 - <<'PY' + XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".config" / "opencode" / "config.json" +p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" if not p.exists(): raise SystemExit(1) try: @@ -174,10 +197,11 @@ PY } apply_opencode_config() { - python3 - <<'PY' + XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".config" / "opencode" / "config.json" +p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json" p.parent.mkdir(parents=True, exist_ok=True) if p.exists(): try: diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 33618a66..96ea7c60 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -290,12 +290,24 @@ _build_runtime_bin_prefix() { MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin +# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings +# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes +# ~/.mosaic real for a roster-started pane instead of a directory nothing reads. +# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below, +# so `mosaic fleet launch` resolves the same root from HOME and the two agree. +FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME" +FLEET_SEAT=0 +[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1 + _ensure_claude_workdir_trusted() { local workdir="$1" + local claude_json="$2" local resolved resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" - local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" - claude_json="${claude_json:-$HOME/.claude.json}" + if [ -z "$claude_json" ]; then + claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" + claude_json="${claude_json:-$HOME/.claude.json}" + fi command -v python3 >/dev/null 2>&1 || return 1 MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile @@ -329,11 +341,23 @@ PY } if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then - _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ + # Trust belongs to the home the seat will actually run in. Writing it to the + # operator's ~/.claude.json would leave the seat prompting on its first turn. + SEAT_CLAUDE_JSON="" + if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then + SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json" + fi + _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \ echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 fi -LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +if [ "$FLEET_SEAT" = 1 ]; then + # --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it; + # the composition, not the roster, decides harness home, bundle and settings. + LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous) +else + LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +fi if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 378ad234..fba7cc40 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -551,4 +551,23 @@ if contains_literal "$stop_args" ambient-socket; then fail "exact stop trusted an ambient socket" fi +# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches +# through the composition instead of the operator's own home. --dangerous keeps the +# seat on the permissions footing `mosaic yolo` gave it. +: > "$TMUX_CALLS" +HOME_SEAT="$ROOT/seat" +write_generated "$HOME_SEAT" "coder-seat" +mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat" +printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \ + > "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json" +run_start "$HOME_SEAT" "coder-seat" +seat_args=$(tr '\0' '\n' < "$TMUX_CALLS") +echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch" +echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch" +echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat" +echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions" +if echo "$seat_args" | grep -qxF 'yolo'; then + fail "scaffolded seat still launched through mosaic yolo" +fi + echo 'ok - start-agent-session generated environment boundary' diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f38e64a7..46e3b570 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -25,6 +25,7 @@ import { registerLaunchCommands } from './commands/launch.js'; import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js'; import { registerAuthCommand } from './commands/auth.js'; +import { registerFleetAuthCommands } from './commands/fleet-auth-command.js'; import { registerFederationCommand } from './commands/federation.js'; import { registerGatewayCommand } from './commands/gateway.js'; import { @@ -350,7 +351,7 @@ sessionsCmd // ─── auth ──────────────────────────────────────────────────────────────── -registerAuthCommand(program); +registerFleetAuthCommands(registerAuthCommand(program)); // ─── gateway ────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/auth.ts b/packages/mosaic/src/commands/auth.ts index 6d190b7f..040ad797 100644 --- a/packages/mosaic/src/commands/auth.ts +++ b/packages/mosaic/src/commands/auth.ts @@ -139,10 +139,11 @@ function printUser(u: UserDto): void { * Keeping packages/auth as a pure server-side library avoids adding commander * and CLI tooling as dependencies there. */ -export function registerAuthCommand(parent: Command): void { +/** Returns the `auth` command so local (non-gateway) verbs can be attached to it. */ +export function registerAuthCommand(parent: Command): Command { const auth = parent .command('auth') - .description('Manage gateway authentication, users, SSO providers, and sessions') + .description('Manage authentication: local credential bundles, and gateway users and sessions') .configureHelp({ sortSubcommands: true }) .action(() => { auth.outputHelp(); @@ -328,4 +329,6 @@ export function registerAuthCommand(parent: Command): void { ); void opts; }); + + return auth; } diff --git a/packages/mosaic/src/commands/fleet-adopt-command.spec.ts b/packages/mosaic/src/commands/fleet-adopt-command.spec.ts new file mode 100644 index 00000000..ef8a05ce --- /dev/null +++ b/packages/mosaic/src/commands/fleet-adopt-command.spec.ts @@ -0,0 +1,196 @@ +import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetAdoptCommand } from './fleet-adopt-command.js'; + +let root: string | undefined; + +interface Harness { + readonly home: string; + readonly out: string[]; + readonly err: string[]; + readonly run: (argv: string[]) => Promise; +} + +beforeEach((): void => { + process.exitCode = undefined; +}); + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function harness(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-cmd-')); + const home = join(root, '.mosaic'); + const out: string[] = []; + const err: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => { + out.push(parts.map(String).join(' ')); + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => { + err.push(String(chunk)); + return true; + }); + const program = new Command(); + program.exitOverride(); + const fleet = program.command('fleet'); + registerFleetAdoptCommand(fleet, { fleetDataHome: home }); + return { + home, + out, + err, + run: async (argv: string[]): Promise => { + await program.parseAsync(['node', 'mosaic', 'fleet', 'adopt', ...argv]); + }, + }; +} + +function realAliasDirectory(home: string, harnessName: string): string { + const path = join(home, 'auth', harnessName, 'primary'); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, '.credentials.json'), '{"token":"kept"}'); + return path; +} + +function seat(home: string, name: string, profile: Record): void { + const dir = join(home, 'fleet', 'agents', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); +} + +function seatDirectory(home: string, agent: string, plural: string, name: string): string { + const path = join(home, 'fleet', 'agents', agent, '.claude', plural, name); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, 'marker.txt'), 'kept'); + return path; +} + +describe('mosaic fleet adopt', () => { + it('says there is nothing to adopt on a clean host', async () => { + const h = await harness(); + + await h.run([]); + + expect(h.out.join('\n')).toContain('Nothing to adopt'); + expect(process.exitCode).toBeUndefined(); + }); + + // A read-only listing that exits non-zero is one people stop running, so the scan reports + // and stays out of the way. + it('lists each finding with the command that resolves it, and exits zero', async () => { + const h = await harness(); + const path = realAliasDirectory(h.home, 'claude'); + + await h.run([]); + + const printed = h.out.join('\n'); + expect(printed).toContain(path); + expect(printed).toContain('resolve: mosaic fleet adopt bundle --harness claude --as '); + expect(printed).toContain('1 found, 0 needing a decision before adoption. Nothing was moved.'); + expect(process.exitCode).toBeUndefined(); + }); + + it('separates findings it can resolve from findings that need a decision first', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true }); + + await h.run([]); + + const printed = h.out.join('\n'); + expect(printed).toContain('blocked (destination occupied)'); + expect(printed).toContain('1 found, 1 needing a decision before adoption.'); + }); + + it('adopts a bundle and reports where the credentials went and what the alias points at', async () => { + const h = await harness(); + realAliasDirectory(h.home, 'claude'); + + await h.run(['bundle', '--harness', 'claude', '--as', 'jason_woltje.com']); + + const target = join(h.home, 'auth', 'claude', 'jason_woltje.com'); + expect(readFileSync(join(target, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}'); + const printed = h.out.join('\n'); + expect(printed).toContain(`bundle: ${target}`); + expect(printed).toContain('-> jason_woltje.com'); + // The name is the operator's claim about the account; only a listing shows what is in it. + expect(printed).toContain('mosaic auth list --harness claude'); + expect(process.exitCode).toBeUndefined(); + }); + + it('rejects an unknown harness instead of building a path out of it', async () => { + const h = await harness(); + + await h.run(['bundle', '--harness', 'nonsense', '--as', 'x']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('--harness must be one of: claude, codex, opencode, pi'); + }); + + it('exits non-zero and names the failure when there is nothing to adopt', async () => { + const h = await harness(); + + await h.run(['bundle', '--harness', 'pi', '--as', 'jason_woltje.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('mosaic fleet adopt bundle failed (nothing-to-adopt)'); + }); + + it('adopts a plugin into the store and says the next launch links it back', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(readFileSync(join(h.home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe('kept'); + expect(h.out.join('\n')).toContain('next launch links it back from the store'); + }); + + it('says plainly when no seat uses the adopted entry yet', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(h.out.join('\n')).toContain("is not listed in uc-e6-coder's profile"); + }); + + it('adopts a skill into the skill store, not the plugin store', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-rev', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-rev', 'skills', 'spec-audit'); + + await h.run(['skill', 'spec-audit', '--seat', 'uc-e6-rev']); + + expect(readFileSync(join(h.home, 'skills', 'spec-audit', 'marker.txt'), 'utf8')).toBe('kept'); + }); + + it('leaves an already-linked entry alone and exits non-zero', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(h.home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(h.home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('already a link into the store'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-adopt-command.ts b/packages/mosaic/src/commands/fleet-adopt-command.ts new file mode 100644 index 00000000..41efce5d --- /dev/null +++ b/packages/mosaic/src/commands/fleet-adopt-command.ts @@ -0,0 +1,137 @@ +/** + * `mosaic fleet adopt` -- resolve the real directories that sit where a managed link belongs. + * + * Launch refuses to delete anything an operator put on a managed path, which is right, but on + * its own it leaves the operator holding a composition error and no way forward. This command + * is the way forward: bare, it lists every such directory and the command that resolves it; + * with a verb, it moves one of them where it belongs. + * + * The bare scan reads only, and exits zero whatever it finds. It is meant to be safe to run + * out of curiosity, and a non-zero exit from a read-only listing would make it something + * people avoid running. + */ + +import type { Command } from 'commander'; +import { + AdoptionError, + type StoreKind, + promoteBundleAlias, + promoteStoreEntry, + scanAdoptions, +} from '../fleet/adoption.js'; +import type { CredentialHarness } from '../fleet/credential-sharing.js'; +import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; + +const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi']; + +export interface FleetAdoptCommandDeps { + /** Test seam for the user-owned ~/.mosaic root. */ + readonly fleetDataHome?: string; +} + +function requireHarness(value: string | undefined): CredentialHarness { + if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) { + throw new AdoptionError('invalid-request', `--harness must be one of: ${HARNESSES.join(', ')}`); + } + return value as CredentialHarness; +} + +function requireSeat(value: string | undefined): string { + if (value === undefined || value.trim() === '') { + throw new AdoptionError( + 'invalid-request', + 'give the seat this directory belongs to: --seat ', + ); + } + return value; +} + +function fail(error: unknown, verb: string): void { + process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof AdoptionError ? error.code : 'failed'; + process.stderr.write( + `mosaic fleet adopt${verb === '' ? '' : ` ${verb}`} failed (${code}): ${message}\n`, + ); +} + +/** Registers the adoption scan and its three promotion verbs. */ +export function registerFleetAdoptCommand( + fleetCommand: Command, + deps: FleetAdoptCommandDeps = {}, +): void { + const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome(); + + const adopt = fleetCommand + .command('adopt') + .description('Find and resolve real directories occupying paths the fleet manages with links') + .action((): void => { + try { + const findings = scanAdoptions(dataHome()); + if (findings.length === 0) { + console.log('Nothing to adopt: no real directory occupies a managed path.'); + return; + } + for (const finding of findings) { + console.log(finding.path); + console.log(` ${finding.reason}`); + console.log( + finding.blocked === undefined + ? ` resolve: ${finding.remedy}` + : ` blocked (${finding.blocked}): ${finding.remedy}`, + ); + } + const blocked = findings.filter((finding) => finding.blocked !== undefined).length; + console.log( + `\n${String(findings.length)} found, ${String(blocked)} needing a decision before adoption. Nothing was moved.`, + ); + } catch (error: unknown) { + fail(error, ''); + } + }); + + adopt + .command('bundle') + .description(`Adopt a real directory on the "primary" alias path as a named bundle`) + .requiredOption('--harness ', `Harness: ${HARNESSES.join(', ')}`) + .requiredOption('--as ', 'Account this directory holds, e.g. jason_woltje.com') + .action((options: { harness?: string; as: string }): void => { + try { + const result = promoteBundleAlias(dataHome(), requireHarness(options.harness), options.as); + console.log(`Adopted ${result.from}`); + console.log(` bundle: ${result.to}`); + console.log(` alias: ${result.alias} -> ${result.bundle}`); + console.log( + `\nCheck the account it actually holds before trusting the name:\n mosaic auth list --harness ${result.harness}`, + ); + } catch (error: unknown) { + fail(error, 'bundle'); + } + }); + + for (const store of ['plugin', 'skill'] as const) { + adopt + .command(`${store} `) + .description(`Move a real ${store} directory out of a seat and into the central store`) + .requiredOption('--seat ', 'Seat the directory currently sits in') + .action((name: string, options: { seat?: string }): void => { + try { + const result = promoteStoreEntry( + dataHome(), + requireSeat(options.seat), + store as StoreKind, + name, + ); + console.log(`Adopted ${result.from}`); + console.log(` store: ${result.to}`); + console.log( + result.listedInProfile + ? `\n"${result.name}" is listed in ${result.agent}'s profile, so its next launch links it back from the store.` + : `\n"${result.name}" is not listed in ${result.agent}'s profile, so no seat uses it yet. It is now vetted store content any seat can be given.`, + ); + } catch (error: unknown) { + fail(error, store); + } + }); + } +} 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..a276ab7e --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -0,0 +1,297 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { + lstat, + mkdtemp, + readFile, + readdir, + readlink, + rm, + symlink, + 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'); + const mosaicHome = join(root!, 'installed-mosaic'); + mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true }); + writeFileSync( + join(mosaicHome, 'runtime', 'claude', 'settings.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + registerFleetAgentScaffoldCommand(fleet, { + fleetDataHome: dataHome, + mosaicHomeFor: () => mosaicHome, + }); + 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'); + + // Claude reaches its bundle through CLAUDE_SECURESTORAGE_CONFIG_DIR at launch, + // so no credential link is planted in the seat home. + expect(await files(agent)).toEqual([ + '.claude/.claude.json', + '.claude/.mosaic-managed-links.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(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }); + expect( + JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')), + ).toEqual({ links: {} }); + }); + + it('plants a managed credential link for a harness that is not shared by environment', async (): Promise => { + const dataHome = await fleetDataHome(); + await program(dataHome).parseAsync([ + 'node', + 'mosaic', + 'fleet', + 'agent', + 'new', + 'pi-seat', + '--harness', + 'pi', + ]); + const agent = join(dataHome, 'fleet', 'agents', 'pi-seat'); + const credentialTarget = join(dataHome, 'auth', 'pi', 'primary', 'auth.json'); + + expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(credentialTarget); + expect( + JSON.parse(await readFile(join(agent, '.pi', '.mosaic-managed-links.json'), 'utf8')), + ).toEqual({ links: { [join(agent, '.pi', 'auth.json')]: credentialTarget } }); + }); + + 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/.mosaic-managed-links.json', + '.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(); + const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'pi-seat', '--harness', 'pi']; + await program(dataHome).parseAsync(command); + const credential = join(dataHome, 'fleet', 'agents', 'pi-seat', '.pi', 'auth.json'); + expect((await lstat(credential)).isSymbolicLink()).toBe(true); + await program(dataHome).parseAsync(command); + expect(process.exitCode).toBeUndefined(); + }); + + it('tolerates a credential link left by a scaffold that predates environment sharing', async (): Promise => { + const dataHome = await fleetDataHome(); + const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']; + await program(dataHome).parseAsync(command); + const seatHome = join(dataHome, 'fleet', 'agents', 'mira', '.claude'); + const credential = join(seatHome, '.credentials.json'); + const target = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'); + await symlink(target, credential); + await writeFile( + join(seatHome, '.mosaic-managed-links.json'), + `${JSON.stringify({ links: { [credential]: target } }, null, 2)}\n`, + ); + + await program(dataHome).parseAsync(command); + + expect(process.exitCode).toBeUndefined(); + expect((await lstat(credential)).isSymbolicLink()).toBe(true); + }); + + it('scaffolds against canonical settings that declare no mcpServers', async (): Promise => { + // The framework's shipped runtime/claude/settings.json has no mcpServers key, so + // requiring one refused to scaffold any Claude seat on a clean install. Measured on a + // greenfield Debian 13 VM against framework main. + const dataHome = await fleetDataHome(); + const command = program(dataHome); + const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json'); + await writeFile(settings, JSON.stringify({ model: 'opus', hooks: {} })); + + await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + + expect(process.exitCode).toBeUndefined(); + const claudeJson = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.claude.json'); + expect(JSON.parse(await readFile(claudeJson, 'utf8'))).toEqual({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: {}, + }); + }); + + it('still refuses canonical settings whose mcpServers is the wrong shape', async (): Promise => { + const dataHome = await fleetDataHome(); + const command = program(dataHome); + const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json'); + await writeFile(settings, JSON.stringify({ mcpServers: ['sequential-thinking'] })); + + await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']); + + expect(process.exitCode).toBe(1); + }); +}); 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..a0ce55f2 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts @@ -0,0 +1,59 @@ +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; + /** Resolves the active installed Mosaic root that owns the canonical runtime base. */ + readonly mosaicHomeFor?: () => 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 }), + ...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }), + }); + console.log( + result.idempotent + ? `Fleet agent "${name}" already matches the scaffold.` + : `Created fleet agent "${name}" at ${result.agentDir}.`, + ); + if (!result.credentialTargetExists) { + console.log( + `Notice: auth bundle "${result.profile['bundle']}" is not enrolled yet, so no credential exists at ${result.credentialTarget}. The seat will refuse to launch until it does.`, + ); + } + } 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-auth-command.spec.ts b/packages/mosaic/src/commands/fleet-auth-command.spec.ts new file mode 100644 index 00000000..141be03d --- /dev/null +++ b/packages/mosaic/src/commands/fleet-auth-command.spec.ts @@ -0,0 +1,365 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm } 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 { registerFleetAuthCommands, type FleetAuthCommandDeps } from './fleet-auth-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; +}); + +interface Harness { + readonly home: string; + readonly out: string[]; + readonly err: string[]; + readonly logins: Array<{ command: string; args: readonly string[]; env: Record }>; + run: (argv: string[]) => Promise; +} + +async function harness( + overrides: Omit = {}, +): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-auth-cmd-')); + const home = join(root, '.mosaic'); + const out: string[] = []; + const err: string[] = []; + const logins: Harness['logins'] = []; + + vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => { + out.push(parts.map(String).join(' ')); + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => { + err.push(String(chunk)); + return true; + }); + + // Every login is recorded regardless of which behaviour the test supplied, so a test can + // assert on what the harness was actually handed as well as on what it wrote. + const inner = overrides.runLogin ?? ((): number => 0); + const program = new Command(); + program.exitOverride(); + const auth = program.command('auth'); + registerFleetAuthCommands(auth, { + ...overrides, + fleetDataHome: home, + runLogin: (command, args, env): number | null => { + logins.push({ command, args, env: { ...env } }); + return inner(command, args, env); + }, + }); + + return { + home, + out, + err, + logins, + run: async (argv: string[]): Promise => { + await program.parseAsync(['node', 'mosaic', 'auth', ...argv]); + }, + }; +} + +/** + * A login that behaves: writes the credential where the harness would write it, using only the + * environment it was handed — the same way a real harness finds its home. + */ +function goodLogin(email?: string, status = 0): NonNullable { + return (command, _args, env): number => { + const dir = + command === 'claude' + ? (env['CLAUDE_SECURESTORAGE_CONFIG_DIR'] ?? '') + : (env['PI_CODING_AGENT_DIR'] ?? env['CODEX_HOME'] ?? env['XDG_CONFIG_HOME'] ?? ''); + writeFileSync(join(dir, command === 'claude' ? '.credentials.json' : 'auth.json'), '{}', { + mode: 0o600, + }); + if (email !== undefined) { + writeFileSync( + join(dir, command === 'claude' ? '.claude.json' : 'auth.json'), + JSON.stringify( + command === 'claude' ? { oauthAccount: { emailAddress: email } } : { account: { email } }, + ), + ); + } + return status; + }; +} + +function scaffoldSeat( + home: string, + name: string, + profile: Record = { schema: 1, harness: 'claude', bundle: 'primary' }, +): string { + const dir = join(home, 'fleet', 'agents', name); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'profile.json'); + writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`); + return path; +} + +describe('mosaic auth enroll', () => { + it('runs the harness login against the bundle directory and reports what landed', async () => { + const h = await harness({ runLogin: goodLogin('jason@woltje.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + + expect(process.exitCode).toBeUndefined(); + const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com'); + expect(h.out.join('\n')).toContain(bundleDir); + expect(h.out.join('\n')).toContain('account: jason@woltje.com'); + const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record< + string, + unknown + >; + expect(recorded['emailAddress']).toBe('jason@woltje.com'); + }); + + it('hands the harness its own home and credential directory, never an empty value', async () => { + const h = await harness({ runLogin: goodLogin() }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + + const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com'); + expect(h.logins).toEqual([ + { + command: 'claude', + args: [], + // An empty CLAUDE_SECURESTORAGE_CONFIG_DIR is not "unset" -- Claude resolves it to + // ~/.claude, the operator's own account -- so exporting one would quietly log the + // operator in over their own credentials instead of enrolling the seat's. + env: { CLAUDE_CONFIG_DIR: bundleDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: bundleDir }, + }, + ]); + }); + + it('forwards login arguments to the harness', async () => { + const h = await harness({ runLogin: goodLogin() }); + await h.run([ + 'enroll', + '--harness', + 'pi', + '--bundle', + 'jason_woltje.com', + '--login-arg', + '/login', + ]); + + expect(h.logins[0]?.args).toEqual(['/login']); + expect(h.logins[0]?.env).toEqual({ + PI_CODING_AGENT_DIR: join(h.home, 'auth', 'pi', 'jason_woltje.com'), + }); + }); + + it('exits non-zero when the account that logged in is not the account the bundle claims', async () => { + const h = await harness({ runLogin: goodLogin('author@example.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('author@example.com'); + expect(h.err.join('')).toContain('one principal wearing two names'); + }); + + it('fails clearly when the harness is not installed', async () => { + const h = await harness({ runLogin: (): null => null }); + await h.run(['enroll', '--harness', 'pi', '--bundle', 'someone_example.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('could not start "pi"'); + }); + + it('reports a login that wrote nothing rather than calling the bundle enrolled', async () => { + const h = await harness({ runLogin: (): number => 0 }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('login left no credential'); + expect(h.err.join('')).toContain('nothing was assigned'); + }); + + it('still checks the bundle when the harness exits non-zero on quit', async () => { + // Several harnesses exit non-zero on a normal quit after a successful login. The + // credential on disk is the fact that matters, not the exit status. + const h = await harness({ runLogin: goodLogin('jason@woltje.com', 130) }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + + expect(process.exitCode).toBeUndefined(); + expect(h.out.join('\n')).toContain('account: jason@woltje.com'); + }); + + it('creates the directory and stops when the operator will run the login themselves', async () => { + const h = await harness(); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com', '--no-login']); + + expect(h.logins).toHaveLength(0); + expect(process.exitCode).toBeUndefined(); + expect(h.out.join('\n')).toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR='); + }); + + it('rejects a harness it does not know', async () => { + const h = await harness(); + await h.run(['enroll', '--harness', 'emacs', '--bundle', 'x']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('--harness must be one of'); + }); +}); + +describe('mosaic auth assign', () => { + it('pegs a seat to a bundle and leaves every other profile field alone', async () => { + const h = await harness(); + const path = scaffoldSeat(h.home, 'uc-e6-rev', { + schema: 1, + harness: 'claude', + bundle: 'primary', + model: 'opus', + overlay: 'overlay.json', + env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' }, + }); + + await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']); + + const written = JSON.parse(await readFile(path, 'utf8')) as Record; + expect(written).toEqual({ + schema: 1, + harness: 'claude', + bundle: 'reviewer_example.com', + model: 'opus', + overlay: 'overlay.json', + env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' }, + }); + expect(h.out.join('\n')).toContain('uc-e6-rev: primary -> reviewer_example.com'); + }); + + it('says the bundle is not enrolled, because the seat will refuse to launch until it is', async () => { + const h = await harness(); + scaffoldSeat(h.home, 'uc-e6-rev'); + + await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']); + + expect(h.out.join('\n')).toContain('is not enrolled for claude'); + }); + + it('is quiet about enrolment when the bundle really is enrolled', async () => { + const h = await harness({ runLogin: goodLogin('reviewer@example.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']); + scaffoldSeat(h.home, 'uc-e6-rev'); + h.out.length = 0; + + await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']); + + expect(h.out.join('\n')).not.toContain('is not enrolled'); + }); + + it('reports an unchanged seat instead of rewriting it', async () => { + const h = await harness(); + scaffoldSeat(h.home, 'seat', { schema: 1, harness: 'pi', bundle: 'held_example.com' }); + + await h.run(['assign', 'seat', '--bundle', 'held_example.com']); + + expect(h.out.join('\n')).toContain('seat: already held_example.com (pi)'); + }); + + it('assigns every scaffolded seat with --all', async () => { + const h = await harness(); + scaffoldSeat(h.home, 'a'); + scaffoldSeat(h.home, 'b', { schema: 1, harness: 'pi', bundle: 'primary' }); + + await h.run(['assign', '--all', '--bundle', 'shared_example.com']); + + for (const name of ['a', 'b']) { + const written = JSON.parse( + await readFile(join(h.home, 'fleet', 'agents', name, 'profile.json'), 'utf8'), + ) as Record; + expect(written['bundle']).toBe('shared_example.com'); + } + }); + + it('refuses an ambiguous target rather than guessing', async () => { + const h = await harness(); + scaffoldSeat(h.home, 'a'); + + await h.run(['assign', 'a', '--all', '--bundle', 'x']); + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('exactly one of'); + + process.exitCode = undefined; + h.err.length = 0; + await h.run(['assign', '--bundle', 'x']); + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('exactly one of'); + }); + + it('names the seat that does not exist', async () => { + const h = await harness(); + await h.run(['assign', 'ghost', '--bundle', 'x']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('no such fleet agent'); + expect(h.err.join('')).toContain('mosaic fleet agent new ghost'); + }); + + it('refuses to rewrite a profile that is already invalid', async () => { + const h = await harness(); + // Re-serializing a broken profile would produce a file that looks repaired and still + // fails at launch, with the original damage no longer visible. + scaffoldSeat(h.home, 'broken', { schema: 1, harness: 'claude', nonsense: true }); + + await h.run(['assign', 'broken', '--bundle', 'x']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('unknown profile key "nonsense"'); + }); +}); + +describe('mosaic auth list', () => { + it('says where bundles would live on a host that has none', async () => { + const h = await harness(); + await h.run(['list']); + + expect(h.out.join('\n')).toContain(join(h.home, 'auth')); + expect(h.out.join('\n')).toContain('mosaic auth enroll'); + }); + + it('shows each bundle with its enrolment state and account', async () => { + const h = await harness({ runLogin: goodLogin('jason@woltje.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + h.out.length = 0; + + await h.run(['list', '--harness', 'claude']); + + const text = h.out.join('\n'); + expect(text).toContain('jason_woltje.com'); + expect(text).toContain('enrolled'); + expect(text).toContain('jason@woltje.com'); + }); +}); + +describe('mosaic auth default', () => { + it('moves the primary alias to a bundle', async () => { + const h = await harness({ runLogin: goodLogin('jason@woltje.com') }); + await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']); + h.out.length = 0; + + await h.run(['default', 'jason_woltje.com', '--harness', 'claude']); + + expect(process.exitCode).toBeUndefined(); + expect(h.out.join('\n')).toContain('primary -> jason_woltje.com'); + h.out.length = 0; + await h.run(['list', '--harness', 'claude']); + expect(h.out.join('\n')).toContain('primary -> jason_woltje.com'); + }); + + it('refuses a bundle that was never enrolled', async () => { + const h = await harness(); + mkdirSync(join(h.home, 'auth', 'claude'), { recursive: true }); + + await h.run(['default', 'missing_example.com', '--harness', 'claude']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('no such bundle'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-auth-command.ts b/packages/mosaic/src/commands/fleet-auth-command.ts new file mode 100644 index 00000000..1bada992 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-auth-command.ts @@ -0,0 +1,326 @@ +/** + * `mosaic auth enroll | assign | list | default` -- the operator surface for credential bundles. + * + * These are local commands. They never talk to the gateway, unlike the rest of `mosaic auth`, + * and they work on a host where the gateway is down. What they do is give one host more than + * one account per harness and let each seat be pegged to one of them. + * + * Enroll does not reimplement any harness's login. It creates a private bundle directory, + * points the harness's own home at it by environment, and runs the harness. Whatever the + * harness writes is then checked: credential present, owner-only, and the account it belongs + * to recorded. Logging into the wrong account is the failure this catches -- it is otherwise + * silent, and it collapses two principals back into one. + */ + +import { spawnSync } from 'node:child_process'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Command } from 'commander'; +import { + AuthBundleError, + PRIMARY_ALIAS, + completeEnrollment, + listBundles, + prepareEnrollment, + setDefaultBundle, +} from '../fleet/auth-bundles.js'; +import type { CredentialHarness } from '../fleet/credential-sharing.js'; +import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; +import { FleetLaunchError, parseFleetAgentProfile } from './fleet-launch-command.js'; + +const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi']; + +export interface FleetAuthCommandDeps { + /** Test seam for the user-owned ~/.mosaic root. */ + readonly fleetDataHome?: string; + /** + * Test seam for running the harness login. Returns the harness's exit status; `null` means + * the harness could not be started at all. + */ + readonly runLogin?: ( + command: string, + args: readonly string[], + env: Readonly>, + ) => number | null; +} + +function requireHarness(value: string | undefined): CredentialHarness { + if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) { + throw new AuthBundleError( + 'invalid-request', + `--harness must be one of: ${HARNESSES.join(', ')}`, + ); + } + return value as CredentialHarness; +} + +function defaultRunLogin( + command: string, + args: readonly string[], + env: Readonly>, +): number | null { + const result = spawnSync(command, [...args], { + stdio: 'inherit', + env: { ...process.env, ...env }, + }); + if (result.error !== undefined) return null; + return result.status; +} + +function fail(error: unknown, verb: string): void { + process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + const code = + error instanceof AuthBundleError + ? error.code + : error instanceof FleetLaunchError + ? error.code + : 'failed'; + process.stderr.write(`mosaic auth ${verb} failed (${code}): ${message}\n`); +} + +// ─── assign ────────────────────────────────────────────────────────────────── + +interface AssignOutcome { + readonly agent: string; + readonly harness: CredentialHarness; + readonly from: string; + readonly to: string; + readonly changed: boolean; +} + +function agentsRoot(dataHome: string): string { + return join(dataHome, 'fleet', 'agents'); +} + +function listAgents(dataHome: string): string[] { + try { + return readdirSync(agentsRoot(dataHome), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +/** + * Rewrite one seat's `bundle`, leaving every other field byte-identical where possible. + * + * The profile is re-parsed before writing rather than patched blind: an already-invalid + * profile should be reported as invalid here, not silently re-serialized into something that + * looks fine and still fails at launch. + */ +function assignOne(dataHome: string, agent: string, bundle: string): AssignOutcome { + const path = join(agentsRoot(dataHome), agent, 'profile.json'); + let source: string; + try { + source = readFileSync(path, 'utf8'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new AuthBundleError( + 'invalid-request', + `no such fleet agent: ${path} — scaffold it first: mosaic fleet agent new ${agent}`, + ); + } + throw error; + } + const profile = parseFleetAgentProfile(source); + const harness = profile.harness as CredentialHarness; + const raw = JSON.parse(source) as Record; + const from = profile.bundle; + if (from === bundle) return { agent, harness, from, to: bundle, changed: false }; + raw['bundle'] = bundle; + writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`); + return { agent, harness, from, to: bundle, changed: true }; +} + +// ─── registration ──────────────────────────────────────────────────────────── + +/** Adds the local bundle verbs onto the existing `mosaic auth` command. */ +export function registerFleetAuthCommands( + authCommand: Command, + deps: FleetAuthCommandDeps = {}, +): void { + const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome(); + const runLogin = deps.runLogin ?? defaultRunLogin; + + authCommand + .command('enroll') + .description('Enrol a credential bundle by running a harness login into a private directory') + .requiredOption('--harness ', `Harness: ${HARNESSES.join(', ')}`) + .requiredOption('--bundle ', 'Bundle name, normally the account email with @ as _') + .option('--login-arg ', 'Arguments to pass to the harness login invocation') + .option('--no-login', 'Only create the bundle directory; run the login yourself') + .action( + (options: { + harness?: string; + bundle: string; + loginArg?: string[]; + login?: boolean; + }): void => { + try { + const harness = requireHarness(options.harness); + const plan = prepareEnrollment(dataHome(), harness, options.bundle); + + console.log(`Bundle directory: ${plan.bundleDir}`); + if (plan.hadCredential) { + console.log('A credential is already present. Logging in again replaces it.'); + } + for (const [key, value] of Object.entries(plan.env)) { + console.log(` ${key}=${value}`); + } + + if (options.login === false) { + console.log( + `\nRun the ${harness} login with the environment above, then verify with:\n mosaic auth list --harness ${harness}`, + ); + return; + } + + console.log( + `\nStarting ${harness} against that directory. Complete the login inside it, then exit.`, + ); + const status = runLogin(harness, options.loginArg ?? [], plan.env); + if (status === null) { + throw new AuthBundleError( + 'invalid-request', + `could not start "${harness}" — is it installed and on PATH?`, + ); + } + // A non-zero login is reported but still checked: some harnesses exit non-zero on + // a normal quit after a successful login, and the credential on disk is the fact + // that matters, not the exit status. + if (status !== 0) { + console.log(`\nNote: ${harness} exited ${String(status)}. Checking the bundle anyway.`); + } + + const result = completeEnrollment(plan); + console.log(`\nEnrolled ${harness} bundle "${result.bundle}".`); + console.log(` credential: ${result.credentialPath}`); + if (result.tightened) { + console.log(' permissions: tightened to owner-only'); + } + if (result.email !== undefined) { + console.log(` account: ${result.email}`); + } else { + console.log( + ' account: could not be determined from what the harness wrote; the bundle name is not verified against the logged-in account', + ); + } + if (result.identityMismatch !== undefined) { + process.exitCode = 1; + process.stderr.write( + `\nWARNING: this bundle is named "${result.bundle}" but the account that logged in is "${result.email ?? 'unknown'}", which implies "${result.identityMismatch}".\n` + + 'Two seats pointed at bundles that hold the same account are one principal wearing two names. Re-enrol under the right name, or delete this bundle.\n', + ); + return; + } + console.log( + `\nAssign it to a seat with:\n mosaic auth assign --bundle ${result.bundle}`, + ); + } catch (error: unknown) { + fail(error, 'enroll'); + } + }, + ); + + authCommand + .command('assign [agent]') + .description('Peg a fleet seat to a credential bundle') + .requiredOption('--bundle ', 'Bundle name to assign') + .option('--all', 'Assign every scaffolded seat') + .action((agent: string | undefined, options: { bundle: string; all?: boolean }): void => { + try { + const home = dataHome(); + if ((agent === undefined) === (options.all !== true)) { + throw new AuthBundleError( + 'invalid-request', + 'give exactly one of: an agent name, or --all', + ); + } + const targets = options.all === true ? listAgents(home) : [agent as string]; + if (targets.length === 0) { + console.log('No scaffolded fleet agents found; nothing to assign.'); + return; + } + // Assignment does not require the bundle to be enrolled -- scaffolding a seat before + // its account exists is a normal order of operations -- but an unenrolled bundle is + // worth saying out loud, because the seat will refuse to launch until it is. The + // check is per harness: the same bundle name under a different harness is a + // different bundle. + const unenrolled = new Set(); + for (const target of targets) { + const outcome = assignOne(home, target, options.bundle); + console.log( + outcome.changed + ? `${outcome.agent}: ${outcome.from} -> ${outcome.to} (${outcome.harness})` + : `${outcome.agent}: already ${outcome.to} (${outcome.harness})`, + ); + const enrolled = listBundles(home, outcome.harness).some( + (entry) => entry.name === options.bundle && entry.enrolled, + ); + if (!enrolled) unenrolled.add(outcome.harness); + } + for (const harness of unenrolled) { + console.log( + `\nNotice: "${options.bundle}" is not enrolled for ${harness}, so those seats will refuse to launch until it is.\n mosaic auth enroll --harness ${harness} --bundle ${options.bundle}`, + ); + } + } catch (error: unknown) { + fail(error, 'assign'); + } + }); + + authCommand + .command('list') + .description('List local credential bundles and which accounts they hold') + .option('--harness ', `Limit to one harness: ${HARNESSES.join(', ')}`) + .action((options: { harness?: string }): void => { + try { + const home = dataHome(); + const harnesses = + options.harness === undefined ? HARNESSES : [requireHarness(options.harness)]; + let found = 0; + for (const harness of harnesses) { + const bundles = listBundles(home, harness); + if (bundles.length === 0) continue; + found += bundles.length; + console.log(`${harness}:`); + for (const bundle of bundles) { + const parts = [ + bundle.alias ? `${bundle.name} -> ${bundle.target ?? '(dangling)'}` : bundle.name, + bundle.enrolled ? 'enrolled' : 'NOT ENROLLED', + ]; + if (bundle.email !== undefined) parts.push(bundle.email); + console.log(` ${parts.join(' ')}`); + } + } + if (found === 0) { + console.log( + `No credential bundles under ${join(home, 'auth')}.\nEnrol one with: mosaic auth enroll --harness --bundle `, + ); + } + } catch (error: unknown) { + fail(error, 'list'); + } + }); + + authCommand + .command('default ') + .description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`) + .requiredOption('--harness ', `Harness: ${HARNESSES.join(', ')}`) + .action((bundle: string, options: { harness?: string }): void => { + try { + const harness = requireHarness(options.harness); + const alias = setDefaultBundle(dataHome(), harness, bundle); + console.log(`${alias} -> ${bundle}`); + console.log( + `Seats with "bundle": "${PRIMARY_ALIAS}" now use ${bundle} at their next launch. Seats pinned to a named bundle are unaffected.`, + ); + } catch (error: unknown) { + fail(error, 'default'); + } + }); +} 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..63534bdc --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -0,0 +1,892 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + 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; + credentialName: string; +} { + const harness = String(profile.harness ?? 'claude'); + const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; + 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', harness, 'fred_example.com'); + mkdirSync(join(systemHome, 'runtime', harness), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(namedBundleDir, { recursive: true }); + writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n'); + writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); + writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 }); + writeFileSync( + join(namedBundleDir, 'account.json'), + '{"oauthAccount":{"emailAddress":"fred@example.com"}}\n', + ); + symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir'); + return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName }; +} + +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('replaces a hook event array wholesale with the higher layer', () => { + 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: [leaseStop], PreToolUse: [qaPre] } }); + }); + + it('reconstructs every gated hook event from the base and lease overlay', () => { + const fx = fixture({ schema: 1, harness: 'claude', overlay: 'lease-overlay.json' }); + const frameworkRuntime = join(process.cwd(), 'framework', 'runtime', 'claude'); + writeFileSync( + join(fx.systemHome, 'runtime', 'claude', 'settings.json'), + readFileSync(join(frameworkRuntime, 'settings.json'), 'utf8'), + ); + writeFileSync( + join(fx.agentDir, 'lease-overlay.json'), + readFileSync(join(frameworkRuntime, 'lease-overlay.json'), 'utf8'), + ); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(plan.settings.merged['hooks']).toEqual({ + PreToolUse: [ + { + matcher: 'Write|Edit|MultiEdit', + hooks: [ + { + type: 'command', + command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh', + timeout: 10, + }, + ], + }, + { + 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, + }, + ], + }, + ], + 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: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh', + timeout: 15, + }, + ], + }, + { + 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, + }, + ], + }, + ], + 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, + }, + ], + }, + ], + }); + }); + + 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('replaces an allowedCommands-shaped non-hook array wholesale', () => { + expect( + deepMergeSettings( + { allowedCommands: ['pnpm', 'git'], nested: { hooks: { Stop: ['base'] } } }, + { allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } }, + ), + ).toEqual({ allowedCommands: ['node'], 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('system settings layer on a real install', () => { + it('composes a harness whose runtime ships no settings.json', () => { + // Measured on a greenfield Debian 13 VM against framework main: the install ships + // runtime// for claude, codex, opencode and pi but a settings.json only for + // claude. Requiring the file made every pi seat unlaunchable. + const fx = fixture({ schema: 1, harness: 'pi' }); + rmSync(join(fx.systemHome, 'runtime', 'pi', 'settings.json')); + writeFileSync(join(fx.systemHome, 'runtime', 'pi', 'RUNTIME.md'), '# pi\n'); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + expect(plan.settings.layers[0]?.present).toBe(false); + expect(plan.settings.merged).toEqual({}); + }); + + it('still refuses a harness the framework does not carry', () => { + const fx = fixture({ schema: 1, harness: 'pi' }); + rmSync(join(fx.systemHome, 'runtime', 'pi'), { recursive: true }); + + try { + resolveFleetLaunchComposition('fred', { + 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('COMPOSITION_FAILED'); + expect(launchError.message).toMatch(/harness runtime is not installed/); + } + }); +}); + +describe('never-enrolled hosts', () => { + it('names the enroll command instead of reporting a shape violation', () => { + // A host that has simply never logged in has no ~/.mosaic/auth at all. Reusing the + // wrong-shape wording there told the operator their auth directory "must be a real, + // non-symlink directory", which reads as tampering rather than "enroll a bundle". + const fx = fixture(); + rmSync(join(fx.userHome, 'auth'), { recursive: true }); + + try { + resolveFleetLaunchComposition('fred', { + 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('COMPOSITION_FAILED'); + expect(launchError.message).toMatch(/does not exist/); + expect(launchError.message).toMatch(/mosaic auth enroll/); + expect(launchError.message).not.toMatch(/non-symlink/); + } + }); +}); + +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('refuses an auth ancestor symlink that relocates the credential trust root', () => { + const fx = fixture(); + rmSync(join(fx.userHome, 'auth'), { recursive: true, force: true }); + const outsideAuth = join(fx.root, 'outside-auth'); + const outsideBundle = join(outsideAuth, 'claude', 'fred_example.com'); + mkdirSync(outsideBundle, { recursive: true }); + writeFileSync(join(outsideBundle, '.credentials.json'), '{}\n', { mode: 0o600 }); + writeFileSync( + join(outsideBundle, 'account.json'), + '{"oauthAccount":{"emailAddress":"fred@example.com"}}\n', + ); + symlinkSync('fred_example.com', join(outsideAuth, 'claude', 'primary'), 'dir'); + symlinkSync(outsideAuth, join(fx.userHome, 'auth'), 'dir'); + + expect(() => + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }), + ).toThrowError(/auth directory must be a real, non-symlink directory/); + }); + + it('refuses a group- or world-readable credential file', () => { + const fx = fixture(); + chmodSync(join(fx.namedBundleDir, '.credentials.json'), 0o644); + + expect(() => + resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }), + ).toThrowError(/credential file must not grant group or other permissions/); + }); + + it('accepts a real private 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); + }); + + it('points Claude at the resolved bundle directory and plans no credential link', () => { + const fx = fixture(); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(plan.credential.link).toBeUndefined(); + expect(plan.credential.dir).toBe(fx.namedBundleDir); + // An empty value resolves to ~/.claude, which is the operator's own account, + // so the exported value must always be the absolute bundle path. + expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toBe(fx.namedBundleDir); + expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).not.toBe(''); + }); + + it('keeps the managed credential link for a harness with no credential-directory variable', () => { + const fx = fixture({ schema: 1, harness: 'pi' }); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(plan.credential.link).toBe(join(fx.agentDir, '.pi', 'auth.json')); + expect(plan.credential.target).toBe(join(fx.namedBundleDir, 'auth.json')); + expect(Object.keys(plan.env)).not.toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR'); + }); +}); + +describe('managed plugin and skill links', () => { + it('refuses an unrecorded foreign symlink without mutating it', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: [] }); + const pluginHome = join(fx.agentDir, '.claude', 'plugins'); + const foreign = join(fx.root, 'foreign-plugin'); + mkdirSync(pluginHome, { recursive: true }); + mkdirSync(foreign, { recursive: true }); + symlinkSync(foreign, join(pluginHome, 'foreign'), 'dir'); + + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign); + }); + + it('performs no writes when a late foreign install link is refused', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); + const target = join(fx.userHome, 'plugins', 'keep'); + const link = join(fx.agentDir, '.claude', 'plugins', 'keep'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(link, '..'), { recursive: true }); + writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n'); + symlinkSync(target, link, 'dir'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const snapshot = join(fx.agentDir, 'settings.generated.json'); + const temp = join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(existsSync(snapshot)).toBe(false); + expect(existsSync(temp)).toBe(false); + expect(readlinkSync(link)).toBe(target); + }); + + it('prunes a recorded matching stale symlink', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); + mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":[]}\n', + ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const pluginHome = join(fx.agentDir, '.claude', 'plugins'); + expect(plan.prune).toEqual([join(pluginHome, 'old')]); + applyFleetLaunchComposition(plan); + + expect(() => lstatSync(join(pluginHome, 'old'))).toThrow(); + }); + + it('refuses a recorded link retargeted after composition and leaves it intact', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] }); + const managedTarget = join(fx.userHome, 'plugins', 'old'); + const foreignTarget = join(fx.root, 'foreign-plugin'); + mkdirSync(managedTarget, { recursive: true }); + mkdirSync(foreignTarget, { recursive: true }); + const initial = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + applyFleetLaunchComposition(initial); + + writeFileSync( + join(fx.agentDir, 'profile.json'), + '{"schema":1,"harness":"claude","plugins":[]}\n', + ); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + const link = join(fx.agentDir, '.claude', 'plugins', 'old'); + rmSync(link); + symlinkSync(foreignTarget, link, 'dir'); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(link)).toBe(foreignTarget); + }); + + it('refuses a tampered manifest entry outside this seat and leaves it intact', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + const seatHome = join(fx.agentDir, '.claude'); + mkdirSync(seatHome, { recursive: true }); + const manifest = join(seatHome, '.mosaic-managed-links.json'); + const crossSeat = join(fx.userHome, 'fleet', 'agents', 'other', '.claude', 'plugins', 'keep'); + writeFileSync( + manifest, + JSON.stringify({ links: { [crossSeat]: join(fx.userHome, 'plugins', 'keep') } }), + ); + + expect(() => + resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }), + ).toThrowError(/escapes an approved seat\/store root/); + expect(readFileSync(manifest, 'utf8')).toContain(crossSeat); + }); + + it('refuses a symlinked manifest temporary path without modifying its target', () => { + const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] }); + const target = join(fx.userHome, 'plugins', 'keep'); + const sentinel = join(fx.root, 'sentinel.json'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(fx.agentDir, '.claude'), { recursive: true }); + writeFileSync(sentinel, 'unchanged\n', { mode: 0o600 }); + symlinkSync(sentinel, join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'), 'file'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError(/cannot be created exclusively/); + expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n'); + }); + + // Credential links exist only for harnesses that are not pointed at their bundle + // by environment, so the containment rules are exercised on one of those. + it('refuses an exact-target unrecorded credential symlink', () => { + const fx = fixture({ schema: 1, harness: 'pi' }); + const seatHome = join(fx.agentDir, '.pi'); + const link = join(seatHome, fx.credentialName); + mkdirSync(seatHome, { recursive: true }); + symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName)); + }); + + it.each(['plugins', 'skills'] as const)( + 'refuses an exact-target unrecorded %s symlink', + (kind) => { + const fx = fixture({ schema: 1, harness: 'claude', [kind]: ['keep'] }); + const target = join(fx.userHome, kind, 'keep'); + const link = join(fx.agentDir, '.claude', kind, 'keep'); + mkdirSync(target, { recursive: true }); + mkdirSync(join(link, '..'), { recursive: true }); + writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n'); + symlinkSync(target, link, 'dir'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readlinkSync(link)).toBe(target); + }, + ); + + it('refuses an unrecorded mismatched credential symlink', () => { + const fx = fixture({ schema: 1, harness: 'pi' }); + const seatHome = join(fx.agentDir, '.pi'); + const foreignCredential = join(fx.root, 'foreign-credential.json'); + mkdirSync(seatHome, { recursive: true }); + writeFileSync(foreignCredential, '{}\n', { mode: 0o600 }); + symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file'); + const plan = resolveFleetLaunchComposition('fred', { + systemHome: fx.systemHome, + userHome: fx.userHome, + }); + + expect(() => applyFleetLaunchComposition(plan)).toThrowError( + /unrecorded or retargeted symlink/, + ); + expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n'); + }); + + 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'), + CLAUDE_SECURESTORAGE_CONFIG_DIR: fx.namedBundleDir, + MOSAIC_AGENT_NAME: 'fred', + SEAT_FLAG: 'yes', + }, + { agentDir: fx.agentDir, mosaicHome: fx.systemHome }, + false, + ); + // The bundle is reached by environment, so nothing is planted at the seat path. + expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false); + }); + + it('asks for dangerous permissions only when the caller does', () => { + const fx = fixture({ schema: 1, harness: 'claude' }); + 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', '--dangerous']); + + expect(launcher).toHaveBeenCalledWith('claude', [], expect.anything(), expect.anything(), true); + }); + + it('lets a caller-supplied --model replace the profile model instead of duplicating it', () => { + const fx = fixture({ schema: 1, harness: 'claude', model: 'opus' }); + 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', '--model', 'sonnet']); + + expect(launcher).toHaveBeenCalledWith( + 'claude', + ['--model', 'sonnet'], + expect.anything(), + expect.anything(), + false, + ); + }); + + 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) + credential: /user/auth/claude/fred_example.com/.credentials.json + symlinks: + 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 + CLAUDE_SECURESTORAGE_CONFIG_DIR=/user/auth/claude/fred_example.com + 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(plan.credential.link).toBeUndefined(); + expect(plan.credential.dir).toBe(fx.namedBundleDir); + }); +}); 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..0392119f --- /dev/null +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -0,0 +1,1042 @@ +import { + closeSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, + writeSync, + type Stats, +} from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import type { Command } from 'commander'; +import { + harnessHome, + launchFleetRuntime, + type FleetHarnessContext, + type RuntimeName, +} from './launch.js'; +import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; +import { + CREDENTIAL_DIR_ENV as CREDENTIAL_DIR_ENV_BY_HARNESS, + CREDENTIAL_FILE_NAMES, +} from '../fleet/credential-sharing.js'; + +export const FLEET_AGENT_PROFILE_SCHEMA = 1; +const PROFILE_KEYS = [ + 'schema', + 'harness', + 'bundle', + 'model', + 'overlay', + 'plugins', + 'skills', + 'env', +]; +const RUNTIMES: readonly RuntimeName[] = ['claude', 'codex', 'opencode', 'pi']; +const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/; +const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart. +const CREDENTIAL_FILES: Record = CREDENTIAL_FILE_NAMES; +const CREDENTIAL_DIR_ENV: Partial> = CREDENTIAL_DIR_ENV_BY_HARNESS; + +export type FleetLaunchErrorCode = + | 'SCHEMA_TOO_NEW' + | 'PROFILE_INVALID' + | 'AGENT_NOT_SCAFFOLDED' + | 'COMPOSITION_FAILED' + | 'FIRST_AUTH_REFUSAL'; + +export class FleetLaunchError extends Error { + constructor( + readonly code: FleetLaunchErrorCode, + message: string, + ) { + super(message); + this.name = 'FleetLaunchError'; + } +} + +export interface FleetAgentLaunchProfile { + readonly schema: 1; + readonly harness: RuntimeName; + readonly bundle: string; + readonly model?: string; + readonly overlay?: string; + readonly plugins: readonly string[]; + readonly skills: readonly string[]; + readonly env: Readonly>; +} + +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; +} + +interface ManagedLinkManifest { + readonly links: Record; +} + +interface ManagedLinkState { + readonly path: string; + readonly links: Map; + readonly existed: boolean; +} + +export interface FleetLaunchComposition { + readonly name: string; + readonly profilePath: string; + readonly profile: FleetAgentLaunchProfile; + readonly agentDir: string; + readonly systemHome: string; + readonly seatHome: string; + readonly settings: { + readonly layers: readonly SettingsLayer[]; + readonly merged: Record; + readonly output: string; + readonly snapshot: string; + }; + readonly bundle: { + readonly requested: string; + readonly resolved: string; + readonly email?: string; + readonly display: string; + }; + readonly credential: { + /** + * The seat-local managed link to the bundle credential. Absent for harnesses + * that reach the shared bundle by environment instead (see CREDENTIAL_DIR_ENV). + */ + readonly link?: string; + readonly target: string; + /** Resolved bundle directory holding the credential file. */ + readonly dir: string; + }; + readonly managedLinks: ManagedLinkState; + readonly installs: readonly PlannedLink[]; + readonly prune: readonly string[]; + readonly env: Readonly>; + readonly argv: readonly string[]; +} + +export interface FleetLaunchCommandDeps { + readonly userHome?: string; + readonly launcher?: ( + runtime: RuntimeName, + args: string[], + declaredEnv: Readonly>, + context: FleetHarnessContext, + dangerous: boolean, + ) => void; +} + +function requiredObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a JSON object.`); + } + return value as Record; +} + +function optionalString(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim() === '') { + throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a non-empty string.`); + } + return value.trim(); +} + +function stringList(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `${label} must be an array of store entry names.`, + ); + } + return value.map((entry: unknown, index: number): string => { + if (typeof entry !== 'string' || !STORE_ENTRY.test(entry)) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `${label}[${index}] must be a safe store entry name.`, + ); + } + return entry; + }); +} + +/** Parse and strictly validate the frozen, user-facing per-agent profile schema. */ +export function parseFleetAgentProfile(source: string): FleetAgentLaunchProfile { + let parsed: unknown; + try { + parsed = JSON.parse(source) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError('PROFILE_INVALID', `profile.json is not valid JSON: ${detail}`); + } + const raw = requiredObject(parsed, 'profile.json'); + if (!Number.isSafeInteger(raw['schema'])) { + throw new FleetLaunchError('PROFILE_INVALID', 'profile.json schema is required and must be 1.'); + } + if ((raw['schema'] as number) > FLEET_AGENT_PROFILE_SCHEMA) { + throw new FleetLaunchError( + 'SCHEMA_TOO_NEW', + `SCHEMA_TOO_NEW: profile schema ${String(raw['schema'])} is newer than supported schema ${FLEET_AGENT_PROFILE_SCHEMA}; upgrade Mosaic before launching this agent.`, + ); + } + if (raw['schema'] !== FLEET_AGENT_PROFILE_SCHEMA) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `profile.json schema ${String(raw['schema'])} is unsupported; expected schema 1.`, + ); + } + const unknown = Object.keys(raw).filter((key: string): boolean => !PROFILE_KEYS.includes(key)); + if (unknown.length > 0) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `unknown profile key "${unknown[0]}" (schema ${String(raw['schema'])})`, + ); + } + if (typeof raw['harness'] !== 'string' || !RUNTIMES.includes(raw['harness'] as RuntimeName)) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `profile.json harness is required and must be one of: ${RUNTIMES.join(', ')}.`, + ); + } + const bundle = optionalString(raw['bundle'], 'profile.json bundle') ?? 'primary'; + if (!BUNDLE_NAME.test(bundle)) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + 'profile.json bundle must be a safe bundle name.', + ); + } + const overlay = optionalString(raw['overlay'], 'profile.json overlay'); + if (overlay !== undefined && (isAbsolute(overlay) || overlay.split(/[\\/]/u).includes('..'))) { + throw new FleetLaunchError( + 'PROFILE_INVALID', + 'profile.json overlay must remain inside the agent directory.', + ); + } + const rawEnv = raw['env'] === undefined ? {} : requiredObject(raw['env'], 'profile.json env'); + const env: Record = {}; + for (const [key, value] of Object.entries(rawEnv)) { + if (!ENV_NAME.test(key) || typeof value !== 'string') { + throw new FleetLaunchError( + 'PROFILE_INVALID', + `profile.json env entry "${key}" must have a valid name and string value.`, + ); + } + env[key] = value; + } + const model = optionalString(raw['model'], 'profile.json model'); + return { + schema: 1, + harness: raw['harness'] as RuntimeName, + bundle, + ...(model === undefined ? {} : { model }), + ...(overlay === undefined ? {} : { overlay }), + plugins: stringList(raw['plugins'], 'profile.json plugins'), + skills: stringList(raw['skills'], 'profile.json skills'), + env, + }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cloneValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cloneValue); + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]), + ); + } + return value; +} + +function mergeObject( + lower: Record, + higher: Record, +): Record { + const result = cloneValue(lower) as Record; + for (const [key, highValue] of Object.entries(higher)) { + if (highValue === null) { + delete result[key]; + continue; + } + const lowValue = result[key]; + result[key] = + isPlainObject(lowValue) && isPlainObject(highValue) + ? mergeObject(lowValue, highValue) + : cloneValue(highValue); + } + return result; +} + +/** + * Deep object merge. Objects merge recursively; scalar and array conflicts + * resolve last-layer-wins; null in a higher layer deletes the key. + */ +export function deepMergeSettings( + ...layers: ReadonlyArray | undefined> +): Record { + return layers.reduce>( + (merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer)), + {}, + ); +} + +function lstatIfPresent(path: string): Stats | undefined { + try { + return lstatSync(path); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function assertRealDirectory(path: string, label: string, absentHint?: string): void { + const info = lstatIfPresent(path); + // Absent and wrong-shaped are different problems and want different words. A host that has + // simply never enrolled a bundle was being told its auth directory "must be a real, + // non-symlink directory", which reads as a tampering report rather than "log in first". + if (!info) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + absentHint + ? `${label} does not exist: ${path} — ${absentHint}` + : `${label} does not exist: ${path}`, + ); + } + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${label} must be a real, non-symlink directory: ${path}`, + ); + } +} + +function assertContained(root: string, candidate: string, label: string): void { + const rel = relative(resolve(root), resolve(candidate)); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${label} resolves outside ${root}: ${candidate}`, + ); + } +} + +/** + * Proves the framework is installed and knows this harness. This is the check the required + * system settings layer used to stand in for, moved to the thing that is actually always + * present: the runtime directory. A missing one means an uninstalled framework or a harness + * the install does not carry, and both are worth failing on before a seat is composed. + */ +function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void { + const runtimeDir = join(systemHome, 'runtime', harness); + if (!lstatIfPresent(runtimeDir)?.isDirectory()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`, + ); + } +} + +function readSettingsLayer( + name: SettingsLayer['name'], + path: string, + required: boolean, +): SettingsLayer { + const info = lstatIfPresent(path); + if (!info) { + if (required) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `required ${name} settings missing: ${path}`, + ); + } + return { name, path, present: false, value: {} }; + } + if (!info.isFile() || info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${name} settings must be a real, non-symlink JSON file: ${path}`, + ); + } + let value: unknown; + try { + value = JSON.parse(readFileSync(path, 'utf8')) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${name} settings are invalid JSON: ${detail}`, + ); + } + if (!isPlainObject(value)) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${name} settings must contain a JSON object.`, + ); + } + return { name, path, present: true, value }; +} + +function accountEmail(bundleDir: string): string | undefined { + const path = join(bundleDir, 'account.json'); + const info = lstatIfPresent(path); + if (!info?.isFile() || info.isSymbolicLink()) return undefined; + try { + const account = JSON.parse(readFileSync(path, 'utf8')) as Record; + const oauth = isPlainObject(account['oauthAccount']) ? account['oauthAccount'] : undefined; + for (const value of [oauth?.['emailAddress'], account['emailAddress'], account['email']]) { + if (typeof value === 'string' && value.trim() !== '') return value.trim(); + } + } catch { + return undefined; + } + return undefined; +} + +function resolveCredential( + profile: FleetAgentLaunchProfile, + userHome: string, + seatHome: string, + _managedLinks: ManagedLinkState, +): Pick { + assertRealDirectory(userHome, 'user Mosaic root'); + const realUserHome = realpathSync(userHome); + const authDirectory = join(userHome, 'auth'); + const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`; + assertRealDirectory(authDirectory, 'auth directory', enrollHint); + const authRoot = join(authDirectory, profile.harness); + assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint); + const resolvedAuthRoot = realpathSync(authRoot); + assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`); + + const bundlePath = join(authRoot, profile.bundle); + const bundleInfo = lstatIfPresent(bundlePath); + if (!bundleInfo) { + throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle not found: ${bundlePath}`); + } + if (bundleInfo.isSymbolicLink() && profile.bundle !== 'primary') { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `only the primary bundle may be an alias symlink: ${bundlePath}`, + ); + } + let resolvedBundleDir: string; + try { + resolvedBundleDir = realpathSync(bundlePath); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle cannot resolve: ${detail}`); + } + assertContained(resolvedAuthRoot, resolvedBundleDir, 'credential bundle'); + assertRealDirectory(resolvedBundleDir, 'resolved credential bundle'); + + const credentialTarget = join(resolvedBundleDir, CREDENTIAL_FILES[profile.harness]); + const credentialInfo = lstatIfPresent(credentialTarget); + if (!credentialInfo?.isFile() || credentialInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `bundle credential must be a real, non-symlink credential file: ${credentialTarget}`, + ); + } + if ((credentialInfo.mode & 0o077) !== 0) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `bundle credential file must not grant group or other permissions: ${credentialTarget}`, + ); + } + // Fleet launches and credential bundles share one operating-system user; ownership validation is deferred. + const resolvedCredential = realpathSync(credentialTarget); + assertContained(resolvedAuthRoot, resolvedCredential, 'bundle credential'); + + const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]); + const seatInfo = lstatIfPresent(credentialLink); + if (seatInfo && !seatInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'FIRST_AUTH_REFUSAL', + `first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`, + ); + } + // Environment-shared harnesses never read the seat-local path, so no link is + // planned for it. A leftover link from an earlier scaffold is inert: the harness + // resolves its credential directory from the environment instead. + const sharesByEnv = CREDENTIAL_DIR_ENV[profile.harness] !== undefined; + + const resolvedName = basename(resolvedBundleDir); + const email = accountEmail(resolvedBundleDir); + const display = + profile.bundle === resolvedName + ? `${resolvedName}${email ? ` (${email})` : ''}` + : `${profile.bundle} -> ${resolvedName}${email ? ` (${email})` : ''}`; + return { + bundle: { + requested: profile.bundle, + resolved: resolvedName, + ...(email === undefined ? {} : { email }), + display, + }, + credential: { + ...(sharesByEnv ? {} : { link: credentialLink }), + target: resolvedCredential, + dir: resolvedBundleDir, + }, + }; +} + +function currentLinkTarget(link: string): string { + return resolve(dirname(link), readlinkSync(link)); +} + +function resolveManagedLinks( + kind: PlannedLink['kind'], + names: readonly string[], + userHome: string, + seatHome: string, + managedLinks: ManagedLinkState, +): { installs: PlannedLink[]; prune: string[] } { + const plural = kind === 'plugin' ? 'plugins' : 'skills'; + const storeRoot = join(userHome, plural); + const installRoot = join(seatHome, plural); + if (names.length > 0) assertRealDirectory(storeRoot, `${kind} store root`); + + const installRootInfo = lstatIfPresent(installRoot); + if (installRootInfo?.isSymbolicLink() || (installRootInfo && !installRootInfo.isDirectory())) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${kind} install root must be a real directory (the historical whole-store symlink requires explicit migration): ${installRoot}`, + ); + } + + const installs: PlannedLink[] = names.map((name: string): PlannedLink => { + const target = join(storeRoot, name); + const targetInfo = lstatIfPresent(target); + if (!targetInfo?.isDirectory() || targetInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${kind} store entry must be a real, non-symlink directory: ${target}`, + ); + } + assertContained(realpathSync(storeRoot), realpathSync(target), `${kind} store entry`); + const link = join(installRoot, name); + const linkInfo = lstatIfPresent(link); + if (linkInfo && !linkInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real ${kind} directory occupies managed symlink path ${link}; refusing to delete it.`, + ); + } + return { kind, name, link, target: realpathSync(target) }; + }); + + const desired = new Set(names); + const prune: string[] = []; + if (installRootInfo?.isDirectory()) { + for (const entry of readdirSync(installRoot, { withFileTypes: true })) { + const path = join(installRoot, entry.name); + if (!entry.isSymbolicLink()) { + // The harness writes its own metadata files (e.g. installed_plugins.json) + // beside the managed links; only a real directory is an unmanaged entry + // the pruner would orphan. + if (entry.isDirectory()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real ${kind} directory occupies managed install root ${path}; refusing to prune it.`, + ); + } + continue; + } + const currentTarget = currentLinkTarget(path); + const recordedTarget = managedLinks.links.get(path); + if (recordedTarget === undefined) { + if ( + !managedLinks.existed && + (() => { + try { + assertContained( + realpathSync(storeRoot), + realpathSync(currentTarget), + `${kind} migration`, + ); + return true; + } catch { + return false; + } + })() + ) { + // A pre-manifest seat may adopt only links to the central Mosaic store; foreign links refuse. + managedLinks.links.set(path, currentTarget); + } + } else if (recordedTarget !== currentTarget) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed ${kind} symlink target changed since composition: ${path}`, + ); + } + if (!desired.has(entry.name)) prune.push(path); + } + } + return { installs, prune }; +} + +function readManagedLinkState( + seatHome: string, + profile: FleetAgentLaunchProfile, + userHome: string, +): ManagedLinkState { + const path = join(seatHome, '.mosaic-managed-links.json'); + const info = lstatIfPresent(path); + if (!info) return { path, links: new Map(), existed: false }; + if (!info.isFile() || info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest must be a real, non-symlink JSON file: ${path}`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest is invalid JSON: ${detail}`, + ); + } + if (!isPlainObject(parsed) || !isPlainObject(parsed['links'])) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest has invalid shape: ${path}`, + ); + } + const links = new Map(); + for (const [link, target] of Object.entries(parsed['links'])) { + if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest has invalid entry: ${path}`, + ); + } + const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]); + const pluginRoot = join(seatHome, 'plugins'); + const skillRoot = join(seatHome, 'skills'); + const authRoot = join(userHome, 'auth', profile.harness); + const inRoot = (root: string, candidate: string): boolean => { + const rel = relative(resolve(root), resolve(candidate)); + return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); + }; + const valid = + (link === credential && inRoot(authRoot, target)) || + (inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) || + (inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target)); + if (!valid) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest entry escapes an approved seat/store root: ${path}`, + ); + } + links.set(link, target); + } + return { path, links, existed: true }; +} + +interface PreparedManagedLinkManifest { + readonly path: string; + readonly descriptor: number; +} + +function prepareManagedLinkManifest(managedLinks: ManagedLinkState): PreparedManagedLinkManifest { + const path = `${managedLinks.path}.tmp`; + try { + return { path, descriptor: openSync(path, 'wx', 0o600) }; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `managed link manifest temporary file cannot be created exclusively: ${detail}`, + ); + } +} + +function writeManagedLinkState( + managedLinks: ManagedLinkState, + prepared: PreparedManagedLinkManifest, +): void { + const entries = [...managedLinks.links.entries()].sort(([left], [right]) => + left.localeCompare(right), + ); + const manifest: ManagedLinkManifest = { links: Object.fromEntries(entries) }; + writeSync(prepared.descriptor, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function buildArgv( + profile: FleetAgentLaunchProfile, + seatHome: string, + passthrough: string[], +): string[] { + const argv: string[] = [profile.harness]; + // A caller-supplied --model replaces the profile's rather than being appended after + // it. The fleet roster carries a model per seat and is the surface operators edit, so + // it has to win; emitting both flags would leave that to each harness's arg parser. + if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model); + if (profile.harness === 'pi') { + for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill)); + } + argv.push(...passthrough); + return argv; +} + +/** Resolve and validate the complete launch without changing the filesystem. */ +export function resolveFleetLaunchComposition( + name: string, + roots: FleetLaunchRoots, + passthrough: string[] = [], +): FleetLaunchComposition { + if (!AGENT_NAME.test(name)) { + throw new FleetLaunchError('PROFILE_INVALID', `invalid fleet agent name: ${name}`); + } + const agentDir = join(roots.userHome, 'fleet', 'agents', name); + if (lstatIfPresent(agentDir) === undefined) { + throw new FleetLaunchError( + 'AGENT_NOT_SCAFFOLDED', + `no such fleet agent '${name}' — run: mosaic fleet agent new ${name}`, + ); + } + assertRealDirectory(agentDir, 'fleet agent directory'); + const profilePath = join(agentDir, 'profile.json'); + const profileInfo = lstatIfPresent(profilePath); + if (!profileInfo?.isFile() || profileInfo.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `agent profile must be a real, non-symlink file: ${profilePath}`, + ); + } + const profile = parseFleetAgentProfile(readFileSync(profilePath, 'utf8')); + const context: FleetHarnessContext = { agentDir }; + const seatHome = harnessHome(profile.harness, context); + const seatHomeInfo = lstatIfPresent(seatHome); + if (seatHomeInfo && (!seatHomeInfo.isDirectory() || seatHomeInfo.isSymbolicLink())) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `agent harness home must be a real, non-symlink directory: ${seatHome}`, + ); + } + const settingsOutput = join(seatHome, 'settings.json'); + const settingsSnapshot = join(agentDir, 'settings.generated.json'); + for (const [label, path] of [ + ['generated settings', settingsOutput], + ['generated settings snapshot', settingsSnapshot], + ] as const) { + const info = lstatIfPresent(path); + if (info && (!info.isFile() || info.isSymbolicLink())) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `${label} path must be a real file or absent: ${path}`, + ); + } + } + const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json'); + assertContained(agentDir, overlayPath, 'agent overlay'); + // The framework ships a runtime directory per harness but a settings.json only where it + // has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made + // every pi, codex and opencode seat unlaunchable on a clean install. The install is what + // has to be present; an absent base layer just means the harness has no system settings. + assertHarnessRuntimeInstalled(roots.systemHome, profile.harness); + const layers: SettingsLayer[] = [ + readSettingsLayer( + 'system', + join(roots.systemHome, 'runtime', profile.harness, 'settings.json'), + false, + ), + readSettingsLayer( + 'user', + join(roots.userHome, 'config', profile.harness, 'settings.json'), + false, + ), + profile.overlay === undefined + ? { name: 'agent', path: overlayPath, present: false, value: {} } + : readSettingsLayer('agent', overlayPath, false), + ]; + const merged = deepMergeSettings(...layers.map((layer) => layer.value)); + const managedLinks = readManagedLinkState(seatHome, profile, roots.userHome); + const credential = resolveCredential(profile, roots.userHome, seatHome, managedLinks); + const plugins = resolveManagedLinks( + 'plugin', + profile.plugins, + roots.userHome, + seatHome, + managedLinks, + ); + const skills = resolveManagedLinks( + 'skill', + profile.skills, + roots.userHome, + seatHome, + managedLinks, + ); + const homeEnvName: Record = { + claude: 'CLAUDE_CONFIG_DIR', + pi: 'PI_CODING_AGENT_DIR', + codex: 'CODEX_HOME', + opencode: 'XDG_CONFIG_HOME', + }; + const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness]; + const env: Record = { + ...profile.env, + [homeEnvName[profile.harness]]: seatHome, + // Only ever an absolute bundle path. Claude reads an empty value as ~/.claude, + // which is the operator's own account, so an empty value is never exported. + ...(credentialDirEnvName === undefined + ? {} + : { [credentialDirEnvName]: credential.credential.dir }), + MOSAIC_AGENT_NAME: name, + }; + return { + name, + profilePath, + systemHome: roots.systemHome, + profile, + agentDir, + seatHome, + settings: { + layers, + merged, + output: settingsOutput, + snapshot: settingsSnapshot, + }, + ...credential, + managedLinks, + installs: [...plugins.installs, ...skills.installs], + prune: [...plugins.prune, ...skills.prune], + env, + argv: buildArgv(profile, seatHome, passthrough), + }; +} + +function ensureSymlink(link: string, target: string, managedLinks: ManagedLinkState): void { + const info = lstatIfPresent(link); + if (info?.isSymbolicLink()) { + const current = currentLinkTarget(link); + if (managedLinks.links.get(link) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink occupies managed path: ${link}`, + ); + } + rmSync(link); + } else if (info) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object occupies managed symlink path ${link}; refusing to delete it.`, + ); + } + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(target, link, 'file'); + managedLinks.links.set(link, target); +} + +function assertManagedLinkMutationAllowed( + link: string, + target: string | undefined, + managedLinks: ManagedLinkState, +): void { + const info = lstatIfPresent(link); + if (!info) return; + if (!info.isSymbolicLink()) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object occupies managed symlink path ${link}; refusing to delete it.`, + ); + } + const current = currentLinkTarget(link); + if (managedLinks.links.get(link) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink occupies managed path: ${link}`, + ); + } +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + if (!isPlainObject(value)) return value; + return Object.fromEntries( + Object.keys(value) + .sort((left, right) => left.localeCompare(right, 'en')) + .map((key) => [key, canonicalJson(value[key])]), + ); +} + +/** Apply a previously resolved plan. No caller should apply a dry-run plan. */ +export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void { + // All link-state checks must complete before the first filesystem mutation. + // This makes a late foreign/retargeted link refusal leave the seat untouched. + if (plan.credential.link !== undefined) { + assertManagedLinkMutationAllowed( + plan.credential.link, + plan.credential.target, + plan.managedLinks, + ); + } + for (const path of plan.prune) + assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks); + for (const install of plan.installs) { + assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks); + } + + mkdirSync(plan.seatHome, { recursive: true }); + const preparedManifest = prepareManagedLinkManifest(plan.managedLinks); + let descriptorOpen = true; + let committedManifest = false; + try { + const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`; + writeFileSync(plan.settings.output, settings, { mode: 0o600 }); + writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 }); + if (plan.credential.link !== undefined) { + ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks); + } + for (const path of plan.prune) { + const info = lstatIfPresent(path); + if (info?.isSymbolicLink()) { + const current = currentLinkTarget(path); + if (plan.managedLinks.links.get(path) !== current) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `unrecorded or retargeted symlink cannot be pruned: ${path}`, + ); + } + rmSync(path); + plan.managedLinks.links.delete(path); + } else if (info) { + throw new FleetLaunchError( + 'COMPOSITION_FAILED', + `real object replaced managed symlink before prune: ${path}`, + ); + } + } + for (const install of plan.installs) { + ensureSymlink(install.link, install.target, plan.managedLinks); + } + writeManagedLinkState(plan.managedLinks, preparedManifest); + closeSync(preparedManifest.descriptor); + descriptorOpen = false; + renameSync(preparedManifest.path, plan.managedLinks.path); + committedManifest = true; + } finally { + if (!committedManifest) { + if (descriptorOpen) closeSync(preparedManifest.descriptor); + rmSync(preparedManifest.path, { force: true }); + } + } +} + +/** Stable, auditable text representation used by --dry-run and snapshot tests. */ +export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string { + const lines = [ + `mosaic fleet launch ${plan.name} --dry-run`, + `profile: ${plan.profilePath} (schema ${plan.profile.schema})`, + `harness: ${plan.profile.harness}`, + `seat-home: ${plan.seatHome}`, + 'settings sources:', + ]; + for (const layer of plan.settings.layers) { + lines.push(` ${layer.name}: ${layer.path}${layer.present ? '' : ' (absent)'}`); + } + lines.push(` output: ${plan.settings.output}`); + lines.push(` snapshot: ${plan.settings.snapshot}`); + lines.push('merged settings:'); + lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2)); + lines.push(`bundle: ${plan.bundle.display}`); + lines.push(`credential: ${plan.credential.target}`); + lines.push('symlinks:'); + // Environment-shared harnesses have no credential symlink; the exported + // credential-directory variable below is what points them at the bundle. + if (plan.credential.link !== undefined) { + lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`); + } + for (const install of plan.installs) { + lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`); + } + for (const path of plan.prune) lines.push(` prune: ${path}`); + lines.push('declared env:'); + for (const key of Object.keys(plan.env).sort()) lines.push(` ${key}=${plan.env[key]}`); + lines.push(`argv: ${JSON.stringify(plan.argv)}`); + return lines.join('\n'); +} + +/** Register `mosaic fleet launch [--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') + .option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does') + .allowUnknownOption(true) + .allowExcessArguments(true) + .action( + (name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => { + try { + const userHome = deps.userHome ?? defaultFleetDataHome(); + const passthrough = command.args.slice(1); + const plan = resolveFleetLaunchComposition( + name, + { systemHome: systemHomeFor(), userHome }, + passthrough, + ); + if (opts.dryRun === true) { + process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`); + return; + } + applyFleetLaunchComposition(plan); + console.log(`[mosaic] bundle: ${plan.bundle.display}`); + const launcher = deps.launcher ?? launchFleetRuntime; + // Dangerous mode is the caller's to ask for, not the seat's to assume. An + // unattended tmux seat needs it -- a permission prompt with nobody at the pane + // is a hung agent -- so the roster launcher passes the flag explicitly and it + // stays visible in the process table rather than hiding in a profile default. + launcher( + plan.profile.harness, + plan.argv.slice(1), + plan.env, + { agentDir: plan.agentDir, mosaicHome: plan.systemHome }, + opts.dangerous === true, + ); + } catch (error: unknown) { + process.exitCode = 1; + const code = error instanceof FleetLaunchError ? `${error.code}: ` : ''; + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`); + } + }, + ); +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index f0a468da..bf932c98 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -82,6 +82,8 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', + 'adopt', + 'agent', 'apply', 'backlog', 'create', @@ -91,6 +93,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 9e392604..04b0cb70 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -38,6 +38,11 @@ import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, } from './fleet-agent-crud-command.js'; +import { + registerFleetAgentScaffoldCommand, + type FleetAgentScaffoldCommandDeps, +} from './fleet-agent-scaffold-command.js'; +import { registerFleetAdoptCommand } from './fleet-adopt-command.js'; import { registerFleetMigrationCommand, type FleetMigrationCommandDeps, @@ -63,6 +68,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 +103,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 +114,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; } @@ -2042,6 +2054,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); @@ -2055,6 +2075,18 @@ 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, { + ...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }), + mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome, + }); + // The counterpart to launch's refusals: launch will not delete a real directory sitting on + // a managed path, and this is how one gets moved out of the way instead. + registerFleetAdoptCommand(cmd, { + ...(deps.fleetDataHome === undefined ? {} : { 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.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index 619fc627..f6d7ec76 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { Command } from 'commander'; -import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + symlinkSync, + rmSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -9,6 +20,8 @@ import { enumerateSkillDirs, piForceSkillNames, registerRuntimeLaunchers, + checkSequentialThinking, + launchFleetRuntimeForTest, type RuntimeLaunchHandler, type ClaudexLaunchHandler, } from './launch.js'; @@ -87,6 +100,285 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => { }); }); +describe('checkSequentialThinking', () => { + it('runs the real fleet launch preflight against the injected seat, not HOME', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + expect( + JSON.parse( + readFileSync( + join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'), + 'utf8', + ), + ).mcpServers['sequential-thinking'], + ).toEqual({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }); + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + { mode: 0o600 }, + ); + vi.stubEnv('HOME', home); + const final = vi.fn((): never => { + throw new Error('final runtime boundary'); + }); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final), + ).toThrow('final runtime boundary'); + expect(final).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('fails the real fleet launch preflight when only operator HOME is seeded', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync( + join(home, '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + // Scaffolded seat, unconfigured harness: the seat's own identity is present so this + // still fails on the missing MCP configuration rather than on a missing SOUL.md. + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); + vi.stubEnv('HOME', home); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { + throw new Error('must not execute'); + }), + ).toThrow('process.exit called'); + } finally { + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('passes with a seeded seat even when operator HOME has no MCP configuration', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + { mode: 0o600 }, + ); + vi.stubEnv('MOSAIC_HOME', installed); + vi.stubEnv('HOME', home); + expect(() => + checkSequentialThinking('claude', { agentDir, mosaicHome: installed }), + ).not.toThrow(); + } finally { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('repairs a legacy seat config in place without using operator HOME', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const bin = join(installed, 'bin'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n'); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + mkdirSync(bin, { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + for (const name of ['node', 'npx']) { + writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n'); + chmodSync(join(bin, name), 0o755); + } + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }), + { mode: 0o600 }, + ); + const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }; + expect( + spawnSync( + checker, + ['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + { + env, + }, + ).status, + ).toBe(0); + expect( + spawnSync( + checker, + ['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + { env }, + ).status, + ).toBe(0); + expect( + JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')), + ).toMatchObject({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: { 'sequential-thinking': { command: 'npx' } }, + }); + expect(existsSync(join(home, '.claude.json'))).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => { + // Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md + // reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at + // the pane the seat parked on the wizard's menu -- tmux session live, unit reporting + // fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an + // unattended launch must fail loudly rather than wait for a keystroke. + const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-')); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + vi.stubEnv('HOME', home); + expect(() => + launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => { + throw new Error('must not execute'); + }), + ).toThrow('process.exit called'); + expect(exit).toHaveBeenCalledWith(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md'))); + expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new')); + } finally { + error.mockRestore(); + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('rejects a group-writable installed helper root', () => { + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + chmodSync(installed, 0o770); + expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow( + /not a trusted installed file/, + ); + } finally { + chmodSync(installed, 0o700); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('refuses an empty seat even when operator HOME is configured', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync( + join(home, '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + vi.stubEnv('MOSAIC_HOME', installed); + vi.stubEnv('HOME', home); + expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow( + 'process.exit called', + ); + expect(exit).toHaveBeenCalledWith(1); + } finally { + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); +}); + describe('buildPiSkillArgs', () => { it('disables auto-discovery but force-loads fleet-critical skills by default', () => { expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([ diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 5672620d..de903a3f 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -8,6 +8,7 @@ import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { existsSync, + lstatSync, mkdirSync, readFileSync, writeFileSync, @@ -19,14 +20,14 @@ import { import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { homedir, hostname } from 'node:os'; -import { join, dirname } from 'node:path'; +import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path'; import type { Command } from 'commander'; import { buildResolvedFleetCommsBlock, renderToolsContractStatus, resolveFleetIdentity, } from '../fleet/comms-onboarding.js'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; @@ -35,7 +36,14 @@ 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; + /** Active installed Mosaic root for fleet-specific helper resolution. */ + readonly mosaicHome?: string; +} const RUNTIME_LABELS: Record = { claude: 'Claude Code', @@ -64,19 +72,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 +170,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 +194,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(), }; @@ -230,7 +244,22 @@ function checkRuntime(cmd: string): void { } } -function checkSoul(): void { +function checkSoul(fleet?: FleetHarnessContext): void { + // A fleet seat carries its own identity -- `mosaic fleet agent new` writes SOUL.md into the + // seat home -- so the operator's system-wide SOUL.md is not the file to check, and the + // interactive wizard is never the right answer for an unattended seat. Measured on a + // greenfield VM: a seat launched into tmux parked on the wizard's menu with nobody at the + // pane. The session was live, the unit reported fine, and no agent ever started. + if (fleet) { + const seatSoul = join(fleet.agentDir, 'SOUL.md'); + if (!existsSync(seatSoul)) { + console.error(`[mosaic] ERROR: seat identity not found: ${seatSoul}`); + console.error('[mosaic] Scaffold the seat first: mosaic fleet agent new '); + process.exit(1); + } + return; + } + const soulPath = join(MOSAIC_HOME, 'SOUL.md'); if (!existsSync(soulPath)) { console.log('[mosaic] SOUL.md not found. Running setup wizard...'); @@ -262,9 +291,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) { @@ -332,13 +361,98 @@ function printSettingsWarnings(audit: SettingsAudit): void { ); } -function checkSequentialThinking(runtime: string): void { - const checker = fwScript('mosaic-ensure-sequential-thinking'); +function resolveExecutable(name: string): string { + const result = spawnSync('which', [name], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + if (!path || !isAbsolute(path) || !existsSync(path)) { + throw new Error(`required helper executable is unavailable: ${name}`); + } + return path; +} + +function trustedFleetHelper(mosaicHome: string): string { + const root = resolve(mosaicHome); + const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + assertNoSymlinkAncestors(checker); + const owner = typeof process.getuid === 'function' ? process.getuid() : undefined; + let cursor = root; + for (const component of relative(root, checker).split(sep).filter(Boolean)) { + const info = lstatSync(cursor); + if ( + !info.isDirectory() || + info.isSymbolicLink() || + (info.mode & 0o022) !== 0 || + (owner !== undefined && info.uid !== owner && info.uid !== 0) + ) { + throw new Error('helper directory has unsafe type, owner, or permissions'); + } + cursor = join(cursor, component); + } + const helperInfo = lstatSync(checker); + if ( + !helperInfo.isFile() || + helperInfo.isSymbolicLink() || + (helperInfo.mode & 0o022) !== 0 || + (helperInfo.mode & 0o111) === 0 || + (owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0) + ) { + throw new Error('helper has unsafe type, owner, or permissions'); + } + } catch (error: unknown) { + throw new Error( + `fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return checker; +} + +export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void { + // Fleet launch must use the active --mosaic-home installation. Non-fleet + // launches retain the package/deployed helper resolver. + const checker = fleet?.mosaicHome + ? trustedFleetHelper(fleet.mosaicHome) + : fwScript('mosaic-ensure-sequential-thinking'); if (!existsSync(checker)) return; // Skip if checker doesn't exist - const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' }); + const fleetClaudeConfig = + runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined; + const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined; + const fleetOpenCodeHome = + runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined; + const python = resolveExecutable('python3'); + const node = resolveExecutable('node'); + const npx = resolveExecutable('npx'); + const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':'); + const result = spawnSync( + checker, + [ + '--check', + '--runtime', + runtime, + ...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]), + ], + { + stdio: 'ignore', + env: { + HOME: process.env['HOME'] ?? '', + PATH: capabilityPath, + LANG: process.env['LANG'] ?? 'C.UTF-8', + ...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined + ? {} + : { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }), + ...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined + ? {} + : { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }), + ...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }), + ...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }), + }, + }, + ); if (result.status !== 0) { console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); - console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`); + const repairArgs = + fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`; + console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`); process.exit(1); } } @@ -483,7 +597,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 +658,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 +701,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 +731,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 +813,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) { @@ -895,15 +1017,51 @@ 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>; + /** Test seam: bypass only final runtime binary discovery. */ + readonly runtimeCheck?: (runtime: RuntimeName) => void; + /** Test seam: receives the fully composed final runtime invocation. */ + readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void; + readonly recordLaunch?: boolean; +} + +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(); - checkRuntime(runtime); + checkSoul(context.fleet); + (context.runtimeCheck ?? checkRuntime)(runtime); // Pi doesn't need sequential-thinking (has native thinking levels) if (runtime !== 'pi') { - checkSequentialThinking(runtime); + checkSequentialThinking(runtime, context.fleet); } checkResumableSession(); @@ -915,14 +1073,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) { @@ -931,13 +1107,25 @@ 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); + if (context.recordLaunch !== false) + recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv); + if (process.env['MOSAIC_LAUNCH_ID']) { + launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID']; + } + if (context.finalExecutor) { + context.finalExecutor('claude', cliArgs, launchEnv); + } else { + 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); @@ -945,22 +1133,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(...discoverPiExtensionArgs()); @@ -970,8 +1174,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; } } @@ -999,6 +1206,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'] : []; @@ -1007,13 +1215,41 @@ 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, + dangerous = false, +): never { + return launchRuntime(runtime, args, dangerous, { fleet, declaredEnv }); +} + +/** Bounded production-path test seam; all preflight and composition remain real. */ +export function launchFleetRuntimeForTest( + runtime: RuntimeName, + args: string[], + declaredEnv: Readonly>, + fleet: FleetHarnessContext, + finalExecutor: NonNullable, +): never { + return launchRuntime(runtime, args, false, { + fleet, + declaredEnv, + runtimeCheck: () => undefined, + finalExecutor, + recordLaunch: false, + }); +} + /** 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/adoption.spec.ts b/packages/mosaic/src/fleet/adoption.spec.ts new file mode 100644 index 00000000..a5b01e5b --- /dev/null +++ b/packages/mosaic/src/fleet/adoption.spec.ts @@ -0,0 +1,341 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AdoptionError, promoteBundleAlias, promoteStoreEntry, scanAdoptions } from './adoption.js'; + +let root: string | undefined; + +afterEach(async (): Promise => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function userHome(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-')); + return join(root, '.mosaic'); +} + +/** A real directory where the primary alias belongs, with something inside worth not losing. */ +function realAliasDirectory( + home: string, + harness: string, + credential = '.credentials.json', +): string { + const path = join(home, 'auth', harness, 'primary'); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, credential), '{"token":"kept"}'); + return path; +} + +function seat( + home: string, + name: string, + profile: Record = { schema: 1, harness: 'claude', bundle: 'primary' }, +): string { + const dir = join(home, 'fleet', 'agents', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); + return dir; +} + +/** A real plugin/skill directory inside a seat, where a link into the store belongs. */ +function seatDirectory( + home: string, + agent: string, + harness: string, + plural: string, + name: string, +): string { + const path = join(home, 'fleet', 'agents', agent, `.${harness}`, plural, name); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, 'marker.txt'), 'kept'); + return path; +} + +describe('scanAdoptions', () => { + it('finds nothing on a host that has no ~/.mosaic at all', async () => { + expect(scanAdoptions(await userHome())).toEqual([]); + }); + + it('finds a real directory on the primary alias path and names the command that resolves it', async () => { + const home = await userHome(); + const path = realAliasDirectory(home, 'claude'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]?.kind).toBe('bundle-alias'); + expect(findings[0]?.path).toBe(path); + expect(findings[0]?.harness).toBe('claude'); + expect(findings[0]?.blocked).toBeUndefined(); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt bundle --harness claude --as '); + }); + + it('ignores a primary alias that is already a symlink', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'claude', 'jason_woltje.com'), { recursive: true }); + symlinkSync('jason_woltje.com', join(home, 'auth', 'claude', 'primary')); + + expect(scanAdoptions(home)).toEqual([]); + }); + + it('finds a real plugin directory inside a seat', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + const path = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + kind: 'store-entry', + path, + agent: 'uc-e6-coder', + store: 'plugin', + name: 'reviewer', + }); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt plugin reviewer --seat uc-e6-coder'); + }); + + it('finds skills the same way it finds plugins', async () => { + const home = await userHome(); + seat(home, 'uc-e6-rev'); + seatDirectory(home, 'uc-e6-rev', 'claude', 'skills', 'spec-audit'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]?.store).toBe('skill'); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt skill spec-audit --seat uc-e6-rev'); + }); + + // Scanning the wrong directory name would report nothing on a pi seat while launch keeps + // refusing to compose it, which is worse than not having the scan. + it('looks in the seat home the launcher uses, not always the claude one', async () => { + const home = await userHome(); + seat(home, 'terra', { schema: 1, harness: 'pi', bundle: 'primary' }); + const path = seatDirectory(home, 'terra', 'pi', 'plugins', 'notes'); + + expect(scanAdoptions(home).map((finding) => finding.path)).toEqual([path]); + }); + + it('does not report a link that is already pointing into the store', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + expect(scanAdoptions(home)).toEqual([]); + }); + + it('marks the finding blocked when the store already holds that name', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + + const findings = scanAdoptions(home); + + expect(findings[0]?.blocked).toBe('destination occupied'); + expect(findings[0]?.remedy).toContain('compare the two'); + }); + + // One malformed profile hiding every finding behind it would make the scan useless exactly + // on the hosts that need it most. + it('reports an unreadable seat as a gap and keeps scanning the others', async () => { + const home = await userHome(); + mkdirSync(join(home, 'fleet', 'agents', 'broken'), { recursive: true }); + writeFileSync(join(home, 'fleet', 'agents', 'broken', 'profile.json'), 'not json'); + seat(home, 'working'); + const path = seatDirectory(home, 'working', 'claude', 'plugins', 'reviewer'); + + const findings = scanAdoptions(home); + + expect(findings.map((finding) => finding.kind)).toEqual(['unreadable-seat', 'store-entry']); + expect(findings[0]?.blocked).toBe('unreadable profile'); + expect(findings[1]?.path).toBe(path); + }); +}); + +describe('promoteBundleAlias', () => { + it('moves the directory to its account name and points the alias at it', async () => { + const home = await userHome(); + const from = realAliasDirectory(home, 'claude'); + + const result = promoteBundleAlias(home, 'claude', 'jason_woltje.com'); + + expect(result.to).toBe(join(home, 'auth', 'claude', 'jason_woltje.com')); + expect(result.from).toBe(from); + // The credential travelled with the directory; adoption is a move, never a re-creation. + expect(readFileSync(join(result.to, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}'); + const alias = lstatSync(result.alias); + expect(alias.isSymbolicLink()).toBe(true); + expect(scanAdoptions(home)).toEqual([]); + }); + + it('refuses when the alias path is already a symlink', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'pi', 'jason_woltje.com'), { recursive: true }); + symlinkSync('jason_woltje.com', join(home, 'auth', 'pi', 'primary')); + + expect(() => promoteBundleAlias(home, 'pi', 'other')).toThrow( + /already an alias symlink.*mosaic auth default/su, + ); + }); + + it('refuses when there is nothing on the alias path', async () => { + const home = await userHome(); + expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(AdoptionError); + }); + + it('refuses to adopt a directory as the alias name itself', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + + expect(() => promoteBundleAlias(home, 'claude', 'primary')).toThrow( + /that is the alias being freed/u, + ); + }); + + it('refuses a name that would escape the auth root', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + + expect(() => promoteBundleAlias(home, 'claude', '../elsewhere')).toThrow( + /not a safe bundle name/u, + ); + expect(existsSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'))).toBe(true); + }); + + // The failure that would cost data: an occupied destination silently merged into, or worse, + // replaced. Both directories must still be exactly where they were. + it('refuses an occupied destination and moves nothing', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + const occupied = join(home, 'auth', 'claude', 'jason_woltje.com'); + mkdirSync(occupied, { recursive: true }); + writeFileSync(join(occupied, '.credentials.json'), '{"token":"other"}'); + + expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow( + /already exists and will not be overwritten/u, + ); + expect(readFileSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'), 'utf8')).toBe( + '{"token":"kept"}', + ); + expect(readFileSync(join(occupied, '.credentials.json'), 'utf8')).toBe('{"token":"other"}'); + }); +}); + +describe('promoteStoreEntry', () => { + it('moves the directory into the central store, creating the store root', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + const result = promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer'); + + expect(result.to).toBe(join(home, 'plugins', 'reviewer')); + expect(readFileSync(join(result.to, 'marker.txt'), 'utf8')).toBe('kept'); + expect(existsSync(from)).toBe(false); + expect(result.listedInProfile).toBe(true); + }); + + // Installing the link here would fail the next launch as an unrecorded symlink, because the + // seat's .mosaic-managed-links.json is launch's to write. Adoption stops at the move. + it('leaves the seat path empty rather than installing the link itself', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer'); + + expect(existsSync(from)).toBe(false); + expect(() => lstatSync(from)).toThrow(); + }); + + it('says when the seat does not list the entry, because then nothing links it back', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + expect(promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer').listedInProfile).toBe( + false, + ); + }); + + it('refuses an occupied destination and moves nothing', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + writeFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'store copy'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow( + /already exists and will not be overwritten/u, + ); + expect(readFileSync(join(from, 'marker.txt'), 'utf8')).toBe('kept'); + expect(readFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe( + 'store copy', + ); + }); + + it('refuses an entry that is already a link into the store', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow( + /already a link into the store/u, + ); + }); + + it('refuses a name that would escape the store root', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', '../escape')).toThrow( + /not a safe plugin name/u, + ); + }); + + it('names the profile it could not read rather than guessing the seat home', async () => { + const home = await userHome(); + + expect(() => promoteStoreEntry(home, 'ghost', 'plugin', 'reviewer')).toThrow( + /ghost.*profile\.json.*cannot be located/su, + ); + }); + + it('reports a missing directory as nothing to adopt', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'skill', 'absent')).toThrow( + /no such skill directory/u, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/adoption.ts b/packages/mosaic/src/fleet/adoption.ts new file mode 100644 index 00000000..c5c819c8 --- /dev/null +++ b/packages/mosaic/src/fleet/adoption.ts @@ -0,0 +1,380 @@ +/** + * Adopting real directories that sit where the fleet expects a managed link. + * + * A host used before the fleet arrived -- or an operator who ran a login by hand -- ends up + * with a real directory on a path launch reserves for a link: `auth//primary`, or a + * plugin/skill directory inside a seat's home. Launch refuses those on purpose, because the + * only way to make a link fit there is to delete whatever is already there. + * + * This module is the other half of that refusal. It finds those directories and moves them + * where they belong. Nothing here deletes anything: a promotion is a rename, and an occupied + * destination is a refusal rather than a merge or an overwrite. Cross-device renames are + * surfaced instead of being retried as copy-then-delete, because a copy-then-delete is a + * delete and this module does not do that. + * + * Link creation is deliberately NOT done here. Seat store links are recorded in the seat's + * `.mosaic-managed-links.json`, and that manifest is owned by launch -- a link installed + * behind its back reads as "unrecorded symlink occupies managed path" on the next launch, + * which trades one refusal for another. So a promoted plugin lands in the central store and + * the next launch links it, provided the seat's profile lists it. Whether a seat gets a + * plugin is `mosaic fleet plugin`'s decision, not this one's. + * + * The auth alias is different: it lives in the auth root, no manifest covers it, and + * setDefaultBundle() already owns installing it. So a bundle promotion finishes the job. + */ + +import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, type Stats } from 'node:fs'; +import { join } from 'node:path'; +import { PRIMARY_ALIAS, assertSafeBundleName, authRoot, setDefaultBundle } from './auth-bundles.js'; +import type { CredentialHarness } from './credential-sharing.js'; + +/** Mirrors the harness list the auth and launch surfaces accept. */ +const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi']; + +/** Store kinds a seat can hold, and the directory name each uses in both trees. */ +const STORE_DIRECTORY: Record = { plugin: 'plugins', skill: 'skills' }; + +/** Same charset as a bundle name; anything with a separator or a dot-dot never reaches a join. */ +const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/; + +export type StoreKind = 'plugin' | 'skill'; + +export type AdoptionErrorCode = + | 'invalid-request' + | 'nothing-to-adopt' + | 'destination-occupied' + | 'cross-device' + | 'unsafe-shape'; + +export class AdoptionError extends Error { + readonly code: AdoptionErrorCode; + + constructor(code: AdoptionErrorCode, message: string) { + super(message); + this.name = 'AdoptionError'; + this.code = code; + } +} + +export interface AdoptionFinding { + /** `bundle-alias` and `store-entry` are adoptable; `unreadable-seat` is a scan gap. */ + readonly kind: 'bundle-alias' | 'store-entry' | 'unreadable-seat'; + /** The real directory that a launch would refuse to touch. */ + readonly path: string; + /** What this is, in one line. */ + readonly reason: string; + /** The exact command that resolves it, or what to look at when nothing can. */ + readonly remedy: string; + readonly harness?: CredentialHarness; + readonly agent?: string; + readonly store?: StoreKind; + readonly name?: string; + /** Set when the promotion cannot run as-is; the remedy then describes the obstacle. */ + readonly blocked?: string; +} + +export interface BundlePromotion { + readonly harness: CredentialHarness; + /** Where the adopted directory now lives. */ + readonly bundle: string; + readonly from: string; + readonly to: string; + /** The alias path now pointing at it. */ + readonly alias: string; +} + +export interface StorePromotion { + readonly agent: string; + readonly store: StoreKind; + readonly name: string; + readonly from: string; + readonly to: string; + /** True when the seat's profile lists this entry, so the next launch will link it back. */ + readonly listedInProfile: boolean; +} + +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 isRealDirectory(path: string): boolean { + const info = lstatIfPresent(path); + return info !== undefined && info.isDirectory() && !info.isSymbolicLink(); +} + +function assertSafeEntryName(name: string, store: StoreKind): void { + if (!ENTRY_NAME.test(name)) { + throw new AdoptionError( + 'invalid-request', + `"${name}" is not a safe ${store} name; use letters, digits, and . _ @ -`, + ); + } +} + +function agentsRoot(dataHome: string): string { + return join(dataHome, 'fleet', 'agents'); +} + +/** + * A seat's harness home, by the same rule launch uses (`harnessHome()` in commands/launch.ts). + * Scanning by any other rule finds directories launch never looks at and misses the ones it + * refuses on. + */ +function seatHome(dataHome: string, agent: string, harness: CredentialHarness): string { + return join(agentsRoot(dataHome), agent, `.${harness}`); +} + +interface SeatProfile { + readonly harness: CredentialHarness; + readonly plugins: readonly string[]; + readonly skills: readonly string[]; +} + +/** + * Read only what adoption needs out of a seat profile, leniently. + * + * A scan that dies on one malformed profile hides every finding behind it, so an unreadable + * profile is reported as a scan gap and the walk continues. Strictness belongs at launch, + * which validates the whole profile and refuses to run the seat. + */ +function readSeatProfile(dataHome: string, agent: string): SeatProfile | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(join(agentsRoot(dataHome), agent, 'profile.json'), 'utf8')); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const raw = parsed as Record; + const harness = raw['harness']; + if (typeof harness !== 'string' || !HARNESSES.includes(harness as CredentialHarness)) { + return undefined; + } + const names = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; + return { + harness: harness as CredentialHarness, + plugins: names(raw['plugins']), + skills: names(raw['skills']), + }; +} + +function listDirectory(path: string): string[] { + try { + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort(); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +function listAgents(dataHome: string): string[] { + try { + return readdirSync(agentsRoot(dataHome), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +/** + * Everything under `~/.mosaic` that occupies a path the fleet manages with a link. + * + * Read-only. Every finding carries the command that resolves it, because the value of the + * scan is that an operator does not have to work out what a composition refusal meant. + */ +export function scanAdoptions(dataHome: string): AdoptionFinding[] { + const findings: AdoptionFinding[] = []; + + for (const harness of HARNESSES) { + const alias = join(authRoot(dataHome, harness), PRIMARY_ALIAS); + if (!isRealDirectory(alias)) continue; + findings.push({ + kind: 'bundle-alias', + path: alias, + harness, + reason: `a real directory occupies the ${PRIMARY_ALIAS} alias path; ${harness} seats pointed at "${PRIMARY_ALIAS}" cannot launch`, + remedy: `mosaic fleet adopt bundle --harness ${harness} --as `, + }); + } + + for (const agent of listAgents(dataHome)) { + const profile = readSeatProfile(dataHome, agent); + if (profile === undefined) { + findings.push({ + kind: 'unreadable-seat', + path: join(agentsRoot(dataHome), agent, 'profile.json'), + agent, + reason: + 'profile could not be read, or names no known harness, so this seat was not scanned', + remedy: `mosaic fleet agent get ${agent}`, + blocked: 'unreadable profile', + }); + continue; + } + for (const store of ['plugin', 'skill'] as const) { + const plural = STORE_DIRECTORY[store]; + const installRoot = join(seatHome(dataHome, agent, profile.harness), plural); + for (const name of listDirectory(installRoot)) { + const destination = join(dataHome, plural, name); + const occupied = lstatIfPresent(destination) !== undefined; + findings.push({ + kind: 'store-entry', + path: join(installRoot, name), + agent, + store, + name, + reason: `a real ${store} directory sits where the seat expects a link into the central store`, + remedy: occupied + ? `${destination} already exists; compare the two and remove or rename one by hand` + : `mosaic fleet adopt ${store} ${name} --seat ${agent}`, + ...(occupied ? { blocked: 'destination occupied' } : {}), + }); + } + } + } + + return findings; +} + +/** + * Move a directory, refusing every case where the move would cost data. + * + * EXDEV is surfaced rather than handled: the fallback for a cross-device rename is copy then + * delete, and this module does not delete. + */ +function movePreservingBoth(from: string, to: string, label: string): void { + if (lstatIfPresent(to) !== undefined) { + throw new AdoptionError( + 'destination-occupied', + `${label} destination already exists and will not be overwritten: ${to}`, + ); + } + try { + renameSync(from, to); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + throw new AdoptionError( + 'cross-device', + `${from} and ${to} are on different filesystems, so this cannot be a rename. Copy it across yourself and remove the original once you have checked the copy: ${to}`, + ); + } + throw error; + } +} + +/** + * Adopt a real directory sitting on the `primary` alias path as a named bundle. + * + * The directory is moved to its account name first and the alias installed second. That order + * is the one that survives a failure: if the alias cannot be created, the credentials are + * intact under their own name and the error says where they are. The reverse order would have + * a window where the alias points at nothing. + */ +export function promoteBundleAlias( + dataHome: string, + harness: CredentialHarness, + as: string, +): BundlePromotion { + assertSafeBundleName(as); + if (as === PRIMARY_ALIAS) { + throw new AdoptionError( + 'invalid-request', + `--as must be the account this directory holds, not "${PRIMARY_ALIAS}" — that is the alias being freed`, + ); + } + const root = authRoot(dataHome, harness); + const alias = join(root, PRIMARY_ALIAS); + const info = lstatIfPresent(alias); + if (info === undefined) { + throw new AdoptionError('nothing-to-adopt', `nothing at ${alias}; there is nothing to adopt`); + } + if (info.isSymbolicLink()) { + throw new AdoptionError( + 'nothing-to-adopt', + `${alias} is already an alias symlink. Retarget it with: mosaic auth default --harness ${harness} `, + ); + } + if (!info.isDirectory()) { + throw new AdoptionError( + 'unsafe-shape', + `${alias} is neither a directory nor a symlink; adoption only moves directories`, + ); + } + + const destination = join(root, as); + movePreservingBoth(alias, destination, 'bundle'); + return { + harness, + bundle: as, + from: alias, + to: destination, + alias: setDefaultBundle(dataHome, harness, as), + }; +} + +/** + * Adopt a real plugin/skill directory out of a seat and into the central store. + * + * No link is installed. The seat's link manifest belongs to launch, and a link this command + * created behind it would fail the next composition as an unrecorded symlink. The next launch + * installs and records the link itself when the seat's profile lists the entry -- and when it + * does not, the entry is now vetted store content that any seat can be given deliberately, + * which is the outcome that was wanted anyway. + */ +export function promoteStoreEntry( + dataHome: string, + agent: string, + store: StoreKind, + name: string, +): StorePromotion { + assertSafeEntryName(name, store); + const profile = readSeatProfile(dataHome, agent); + if (profile === undefined) { + throw new AdoptionError( + 'invalid-request', + `cannot read a harness out of ${join(agentsRoot(dataHome), agent, 'profile.json')}, so the seat's home cannot be located`, + ); + } + const plural = STORE_DIRECTORY[store]; + const source = join(seatHome(dataHome, agent, profile.harness), plural, name); + const info = lstatIfPresent(source); + if (info === undefined) { + throw new AdoptionError('nothing-to-adopt', `no such ${store} directory: ${source}`); + } + if (info.isSymbolicLink()) { + throw new AdoptionError( + 'nothing-to-adopt', + `${source} is already a link into the store; there is nothing to adopt`, + ); + } + if (!info.isDirectory()) { + throw new AdoptionError( + 'unsafe-shape', + `${source} is not a directory; adoption only moves directories`, + ); + } + + mkdirSync(join(dataHome, plural), { recursive: true }); + const destination = join(dataHome, plural, name); + movePreservingBoth(source, destination, store); + return { + agent, + store, + name, + from: source, + to: destination, + listedInProfile: (store === 'plugin' ? profile.plugins : profile.skills).includes(name), + }; +} diff --git a/packages/mosaic/src/fleet/auth-bundles.spec.ts b/packages/mosaic/src/fleet/auth-bundles.spec.ts new file mode 100644 index 00000000..d3919a31 --- /dev/null +++ b/packages/mosaic/src/fleet/auth-bundles.spec.ts @@ -0,0 +1,294 @@ +import { chmodSync, lstatSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + AuthBundleError, + bundleNameForEmail, + completeEnrollment, + listBundles, + prepareEnrollment, + readBundleIdentity, + setDefaultBundle, +} from './auth-bundles.js'; + +let root: string | undefined; + +afterEach(async (): Promise => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function userHome(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-auth-')); + return join(root, '.mosaic'); +} + +describe('prepareEnrollment', () => { + it('creates the bundle directory owner-only and names the environment the login needs', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + + expect(plan.created).toBe(true); + expect(plan.hadCredential).toBe(false); + expect(plan.bundleDir).toBe(join(home, 'auth', 'claude', 'jason_woltje.com')); + expect(plan.credentialPath).toBe(join(plan.bundleDir, '.credentials.json')); + // Claude reaches its bundle by CLAUDE_SECURESTORAGE_CONFIG_DIR because rename() replaces + // a symlink rather than following it; the login has to write into the bundle directly. + expect(plan.env).toEqual({ + CLAUDE_CONFIG_DIR: plan.bundleDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: plan.bundleDir, + }); + + for (const path of [home, join(home, 'auth'), join(home, 'auth', 'claude'), plan.bundleDir]) { + expect(lstatSync(path).mode & 0o077).toBe(0); + } + }); + + it('gives a harness without a credential-directory variable only its home variable', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'pi', 'jason_woltje.com'); + + expect(plan.credentialPath).toBe(join(plan.bundleDir, 'auth.json')); + expect(plan.env).toEqual({ PI_CODING_AGENT_DIR: plan.bundleDir }); + }); + + it('refuses to enrol into the primary alias and says what to do instead', async () => { + const home = await userHome(); + // `primary` is a movable pointer, not storage. Enrolling into it would turn the alias + // into a real directory and there would no longer be a default to move. + expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow( + /movable alias, not a bundle/u, + ); + expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(AuthBundleError); + }); + + it('refuses a bundle name that could escape the auth root', async () => { + const home = await userHome(); + expect(() => prepareEnrollment(home, 'claude', '../elsewhere')).toThrow(/not a safe bundle/u); + }); + + it('tightens an existing world-readable bundle directory rather than trusting it', async () => { + const home = await userHome(); + const bundleDir = join(home, 'auth', 'claude', 'loose'); + mkdirSync(bundleDir, { recursive: true }); + chmodSync(bundleDir, 0o755); + + const plan = prepareEnrollment(home, 'claude', 'loose'); + + expect(plan.created).toBe(false); + expect(lstatSync(plan.bundleDir).mode & 0o077).toBe(0); + }); + + it('reports an existing credential so a re-login is not mistaken for a first enrolment', async () => { + const home = await userHome(); + const first = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(first.credentialPath, '{}', { mode: 0o600 }); + + expect(prepareEnrollment(home, 'claude', 'jason_woltje.com').hadCredential).toBe(true); + }); +}); + +describe('completeEnrollment', () => { + it('fails when the login exited without writing a credential', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + + // The directory exists and looks fine; only the credential proves a login happened. Without + // this check the failure surfaces much later, at composition, blaming the missing file + // rather than the login that never completed. + expect(() => completeEnrollment(plan)).toThrow(/login left no credential/u); + try { + completeEnrollment(plan); + } catch (error: unknown) { + expect((error as AuthBundleError).code).toBe('credential-missing'); + } + }); + + it('tightens a credential the harness wrote with group or other permissions', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(plan.credentialPath, '{}'); + chmodSync(plan.credentialPath, 0o644); + + const result = completeEnrollment(plan); + + expect(result.tightened).toBe(true); + expect(lstatSync(plan.credentialPath).mode & 0o077).toBe(0); + }); + + it('records the logged-in account so the bundle can say who it holds', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(plan.credentialPath, '{}', { mode: 0o600 }); + writeFileSync( + join(plan.bundleDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'jason@woltje.com' } }), + ); + + const result = completeEnrollment(plan); + + expect(result.email).toBe('jason@woltje.com'); + expect(result.identityMismatch).toBeUndefined(); + const recorded = JSON.parse( + await readFile(join(plan.bundleDir, 'account.json'), 'utf8'), + ) as Record; + expect(recorded['emailAddress']).toBe('jason@woltje.com'); + expect(lstatSync(join(plan.bundleDir, 'account.json')).mode & 0o077).toBe(0); + }); + + it('flags a bundle whose name does not match the account that logged into it', async () => { + const home = await userHome(); + // This is the failure the whole two-principal model rests on. If an operator enrolling a + // reviewer bundle logs in as the author's account by habit, both seats end up holding one + // principal, the review is self-review, and nothing else in the system notices. + const plan = prepareEnrollment(home, 'claude', 'reviewer_example.com'); + writeFileSync(plan.credentialPath, '{}', { mode: 0o600 }); + writeFileSync( + join(plan.bundleDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'author@example.com' } }), + ); + + const result = completeEnrollment(plan); + + expect(result.email).toBe('author@example.com'); + expect(result.identityMismatch).toBe('author_example.com'); + }); + + it('enrols a harness whose files carry no identity, without inventing one', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'pi', 'someone_example.com'); + writeFileSync(plan.credentialPath, JSON.stringify({ token: 'x' }), { mode: 0o600 }); + + const result = completeEnrollment(plan); + + expect(result.email).toBeUndefined(); + expect(result.identityMismatch).toBeUndefined(); + }); +}); + +describe('bundleNameForEmail', () => { + it('maps an account to its bundle name', () => { + expect(bundleNameForEmail('Jason.Woltje@uscllc.com')).toBe('jason.woltje_uscllc.com'); + }); +}); + +describe('readBundleIdentity', () => { + it('prefers the recorded account over whatever the harness left lying around', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(join(plan.bundleDir, 'account.json'), JSON.stringify({ emailAddress: 'a@b.c' })); + writeFileSync( + join(plan.bundleDir, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'stale@old.example' } }), + ); + + expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('a@b.c'); + }); + + it('returns nothing rather than guessing when the files are unreadable', async () => { + const home = await userHome(); + const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(join(plan.bundleDir, '.claude.json'), 'not json'); + + expect(readBundleIdentity(plan.bundleDir, 'claude')).toBeUndefined(); + }); +}); + +describe('listBundles', () => { + it('is empty on a host that has never enrolled anything', async () => { + expect(listBundles(await userHome(), 'claude')).toEqual([]); + }); + + it('reports enrolment state, the alias, and which account each bundle holds', async () => { + const home = await userHome(); + const enrolled = prepareEnrollment(home, 'claude', 'jason_woltje.com'); + writeFileSync(enrolled.credentialPath, '{}', { mode: 0o600 }); + writeFileSync( + join(enrolled.bundleDir, 'account.json'), + JSON.stringify({ emailAddress: 'jason@woltje.com' }), + ); + prepareEnrollment(home, 'claude', 'empty_example.com'); + setDefaultBundle(home, 'claude', 'jason_woltje.com'); + + const bundles = listBundles(home, 'claude'); + + expect(bundles.map((b) => b.name)).toEqual([ + 'empty_example.com', + 'jason_woltje.com', + 'primary', + ]); + expect(bundles.find((b) => b.name === 'jason_woltje.com')).toMatchObject({ + alias: false, + enrolled: true, + email: 'jason@woltje.com', + }); + expect(bundles.find((b) => b.name === 'empty_example.com')).toMatchObject({ + alias: false, + enrolled: false, + }); + expect(bundles.find((b) => b.name === 'primary')).toMatchObject({ + alias: true, + target: 'jason_woltje.com', + enrolled: true, + }); + }); + + it('shows a dangling alias instead of failing the whole listing', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'claude'), { recursive: true }); + symlinkSync('gone', join(home, 'auth', 'claude', 'primary')); + + expect(listBundles(home, 'claude')).toEqual([ + { + name: 'primary', + path: join(home, 'auth', 'claude', 'primary'), + resolved: join(home, 'auth', 'claude', 'primary'), + alias: true, + enrolled: false, + }, + ]); + }); +}); + +describe('setDefaultBundle', () => { + it('retargets an existing alias without writing through into the old bundle', async () => { + const home = await userHome(); + for (const name of ['one_example.com', 'two_example.com']) { + const plan = prepareEnrollment(home, 'claude', name); + writeFileSync(plan.credentialPath, '{}', { mode: 0o600 }); + } + setDefaultBundle(home, 'claude', 'one_example.com'); + setDefaultBundle(home, 'claude', 'two_example.com'); + + expect(listBundles(home, 'claude').find((b) => b.name === 'primary')?.target).toBe( + 'two_example.com', + ); + // The bundle it used to point at is untouched, not emptied by the retarget. + expect( + lstatSync(join(home, 'auth', 'claude', 'one_example.com', '.credentials.json')).isFile(), + ).toBe(true); + }); + + it('refuses to point the alias at a bundle that does not exist', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'claude'), { recursive: true }); + + expect(() => setDefaultBundle(home, 'claude', 'missing_example.com')).toThrow( + /no such bundle/u, + ); + }); + + it('will not delete a real directory that occupies the alias path', async () => { + const home = await userHome(); + prepareEnrollment(home, 'claude', 'real_example.com'); + mkdirSync(join(home, 'auth', 'claude', 'primary'), { recursive: true }); + + // A real `primary` directory means someone enrolled into the alias by hand and their + // credentials are inside it. Deleting it to install a symlink would destroy an account. + expect(() => setDefaultBundle(home, 'claude', 'real_example.com')).toThrow( + /will not be deleted/u, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/auth-bundles.ts b/packages/mosaic/src/fleet/auth-bundles.ts new file mode 100644 index 00000000..6b7286a5 --- /dev/null +++ b/packages/mosaic/src/fleet/auth-bundles.ts @@ -0,0 +1,415 @@ +/** + * Credential bundles under `~/.mosaic/auth///`. + * + * A bundle is one account's credentials for one harness. Seats point at a bundle by name in + * their `profile.json`, so two seats can hold genuinely different principals on one host -- + * which is the whole reason the fleet can run an author seat and a reviewer seat without the + * review being self-review wearing two hats. + * + * Enrolling does not reimplement any harness's login. It creates the bundle directory, points + * the harness at it by environment, and runs the harness's own login. What this module owns is + * everything around that: that the directory is a real directory nobody can read but its owner, + * that the credential actually landed, and that the account you logged in as is the account the + * bundle claims to hold. + * + * Composition-side reader: commands/fleet-launch-command.ts resolveCredential(). + */ + +import { + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, + type Stats, +} from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { + CREDENTIAL_DIR_ENV, + CREDENTIAL_FILE_NAMES, + type CredentialHarness, +} from './credential-sharing.js'; + +/** Mirrors BUNDLE_NAME in commands/fleet-launch-command.ts; drift here is a launch failure. */ +const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/; + +/** + * The movable alias. `"bundle": "primary"` in a profile follows whatever this points at; a + * named bundle stays pinned. It is the only symlink launch tolerates in an auth root. + */ +export const PRIMARY_ALIAS = 'primary'; + +/** Where each harness expects its own home, so login writes into the bundle we just made. */ +const HOME_ENV_NAME: Record = { + claude: 'CLAUDE_CONFIG_DIR', + pi: 'PI_CODING_AGENT_DIR', + codex: 'CODEX_HOME', + opencode: 'XDG_CONFIG_HOME', +}; + +/** + * Files a harness writes that carry the logged-in account's identity, and the paths within + * them to try. Best effort by design: a harness we cannot read an identity from still enrolls, + * it just cannot be checked against its bundle name. + */ +const IDENTITY_SOURCES: Record> = { + claude: [ + ['.claude.json', ['oauthAccount.emailAddress', 'oauthAccount.email']], + ['.credentials.json', ['claudeAiOauth.emailAddress']], + ], + pi: [['auth.json', ['account.email', 'email', 'user.email']]], + codex: [['auth.json', ['tokens.id_token.email', 'account.email', 'email']]], + opencode: [['auth.json', ['account.email', 'email']]], +}; + +export type AuthBundleErrorCode = + | 'invalid-request' + | 'bundle-not-found' + | 'bundle-exists' + | 'credential-missing' + | 'unsafe-shape'; + +export class AuthBundleError extends Error { + readonly code: AuthBundleErrorCode; + + constructor(code: AuthBundleErrorCode, message: string) { + super(message); + this.name = 'AuthBundleError'; + this.code = code; + } +} + +export interface BundleInfo { + readonly name: string; + /** Absolute path of the entry as named, before alias resolution. */ + readonly path: string; + /** Where it actually lives. Differs from `path` only for the primary alias. */ + readonly resolved: string; + /** True when this entry is the movable primary alias rather than a real bundle. */ + readonly alias: boolean; + /** Alias target's bundle name, when this is the alias. */ + readonly target?: string; + /** True when the harness's credential file is present in the resolved bundle. */ + readonly enrolled: boolean; + /** Account identity recorded at enrollment, when one could be determined. */ + readonly email?: string; +} + +export interface EnrollmentPlan { + readonly harness: CredentialHarness; + readonly bundle: string; + readonly bundleDir: string; + /** Absolute path the harness must end up writing its credential to. */ + readonly credentialPath: string; + /** True when the directory did not exist before this call. */ + readonly created: boolean; + /** True when a credential was already present -- a re-login, not a first enrollment. */ + readonly hadCredential: boolean; + /** + * Environment the harness login must run under. Every value is an absolute path; Claude + * reads an empty credential-dir value as ~/.claude, the operator's own account, so an + * empty value is never produced here. + */ + readonly env: Readonly>; +} + +export interface EnrollmentResult { + readonly harness: CredentialHarness; + readonly bundle: string; + readonly bundleDir: string; + readonly credentialPath: string; + /** Identity read back out of what the harness wrote, when it could be determined. */ + readonly email?: string; + /** + * Set when an identity was found and it does not match the bundle name. Logging into the + * wrong account is silent otherwise, and it is the failure that quietly collapses two + * principals back into one. + */ + readonly identityMismatch?: string; + /** True when the credential file's permissions had to be tightened to owner-only. */ + readonly tightened: boolean; +} + +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 assertContained(root: string, candidate: string, label: string): void { + const rel = relative(resolve(root), resolve(candidate)); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new AuthBundleError('unsafe-shape', `${label} resolves outside ${root}: ${candidate}`); + } +} + +/** Reject a name before it is ever joined onto a path. */ +export function assertSafeBundleName(bundle: string): void { + if (!BUNDLE_NAME.test(bundle)) { + throw new AuthBundleError( + 'invalid-request', + `"${bundle}" is not a safe bundle name; use letters, digits, and . _ @ -`, + ); + } +} + +/** `~/.mosaic/auth/`. */ +export function authRoot(userHome: string, harness: CredentialHarness): string { + return join(userHome, 'auth', harness); +} + +/** + * Create the auth root chain with owner-only permissions, refusing anything that is not a + * real directory. An explicit mode on mkdir is not enough on its own -- it is masked by the + * ambient umask -- so each level is chmod'ed after creation. + */ +function ensurePrivateDirectory(path: string, label: string): boolean { + const info = lstatIfPresent(path); + if (info) { + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new AuthBundleError( + 'unsafe-shape', + `${label} must be a real, non-symlink directory: ${path}`, + ); + } + if ((info.mode & 0o077) !== 0) chmodSync(path, 0o700); + return false; + } + mkdirSync(path, { recursive: true, mode: 0o700 }); + chmodSync(path, 0o700); + return true; +} + +function readJson(path: string): Record | undefined { + const info = lstatIfPresent(path); + if (!info?.isFile() || info.isSymbolicLink()) return undefined; + try { + const value: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return value as Record; + } catch { + return undefined; + } +} + +function dig(source: Record, dotted: string): string | undefined { + let cursor: unknown = source; + for (const key of dotted.split('.')) { + if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor)) return undefined; + cursor = (cursor as Record)[key]; + } + return typeof cursor === 'string' && cursor.trim() !== '' ? cursor.trim() : undefined; +} + +/** Best-effort account identity from whatever the harness wrote into the bundle. */ +export function readBundleIdentity( + bundleDir: string, + harness: CredentialHarness, +): string | undefined { + const recorded = readJson(join(bundleDir, 'account.json')); + if (recorded) { + for (const path of ['emailAddress', 'email', 'oauthAccount.emailAddress']) { + const found = dig(recorded, path); + if (found) return found; + } + } + for (const [file, paths] of IDENTITY_SOURCES[harness]) { + const source = readJson(join(bundleDir, file)); + if (!source) continue; + for (const path of paths) { + const found = dig(source, path); + if (found) return found; + } + } + return undefined; +} + +/** + * The bundle name an email implies. Bundles are named by account identity so that a roster + * row's `"bundle"` says who the seat is, not merely which slot it uses. + */ +export function bundleNameForEmail(email: string): string { + return email.trim().toLowerCase().replace(/@/gu, '_'); +} + +/** + * Create the bundle directory and describe the environment its login must run under. + * + * This deliberately stops short of running anything. The caller runs the harness's own login + * under `plan.env`, then calls completeEnrollment() to check what landed. + */ +export function prepareEnrollment( + userHome: string, + harness: CredentialHarness, + bundle: string, +): EnrollmentPlan { + assertSafeBundleName(bundle); + if (bundle === PRIMARY_ALIAS) { + throw new AuthBundleError( + 'invalid-request', + `"${PRIMARY_ALIAS}" is a movable alias, not a bundle. Enroll a bundle named for the account (for example: mosaic auth enroll --harness ${harness} --bundle jason_woltje.com), then point the alias at it with: mosaic auth default --harness ${harness} `, + ); + } + + ensurePrivateDirectory(userHome, 'user Mosaic root'); + ensurePrivateDirectory(join(userHome, 'auth'), 'auth directory'); + const root = authRoot(userHome, harness); + ensurePrivateDirectory(root, `${harness} auth root`); + + const bundleDir = join(root, bundle); + assertContained(realpathSync(root), resolve(bundleDir), 'credential bundle'); + const created = ensurePrivateDirectory(bundleDir, 'credential bundle'); + + const credentialPath = join(bundleDir, CREDENTIAL_FILE_NAMES[harness]); + const credentialDirEnvName = CREDENTIAL_DIR_ENV[harness]; + return { + harness, + bundle, + bundleDir, + credentialPath, + created, + hadCredential: lstatIfPresent(credentialPath)?.isFile() === true, + env: { + [HOME_ENV_NAME[harness]]: bundleDir, + ...(credentialDirEnvName === undefined ? {} : { [credentialDirEnvName]: bundleDir }), + }, + }; +} + +/** + * Check what the harness login actually left behind, tighten it, and record the identity. + * + * A login that exits zero having written nothing is the failure worth catching here: the seat + * would then fail much later, at composition, with a message about a missing credential and no + * hint that the login was the thing that did not work. + */ +export function completeEnrollment(plan: EnrollmentPlan): EnrollmentResult { + const info = lstatIfPresent(plan.credentialPath); + if (!info?.isFile() || info.isSymbolicLink()) { + throw new AuthBundleError( + 'credential-missing', + `login left no credential at ${plan.credentialPath}. The bundle directory exists but is not enrolled; nothing was assigned.`, + ); + } + let tightened = false; + if ((info.mode & 0o077) !== 0) { + chmodSync(plan.credentialPath, 0o600); + tightened = true; + } + + const email = readBundleIdentity(plan.bundleDir, plan.harness); + if (email !== undefined) { + writeFileSync( + join(plan.bundleDir, 'account.json'), + `${JSON.stringify({ emailAddress: email, harness: plan.harness }, null, 2)}\n`, + { mode: 0o600 }, + ); + chmodSync(join(plan.bundleDir, 'account.json'), 0o600); + } + + const expected = email === undefined ? undefined : bundleNameForEmail(email); + return { + harness: plan.harness, + bundle: plan.bundle, + bundleDir: plan.bundleDir, + credentialPath: plan.credentialPath, + ...(email === undefined ? {} : { email }), + ...(expected === undefined || expected === plan.bundle.toLowerCase() + ? {} + : { identityMismatch: expected }), + tightened, + }; +} + +/** Every entry in a harness's auth root, alias included, with enrollment state. */ +export function listBundles(userHome: string, harness: CredentialHarness): BundleInfo[] { + const root = authRoot(userHome, harness); + const info = lstatIfPresent(root); + if (!info) return []; + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new AuthBundleError( + 'unsafe-shape', + `${harness} auth root must be a real, non-symlink directory: ${root}`, + ); + } + + const entries: BundleInfo[] = []; + for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + )) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const path = join(root, entry.name); + let resolved: string; + try { + resolved = realpathSync(path); + } catch { + // A dangling alias is real state worth showing rather than a reason to fail the listing. + entries.push({ name: entry.name, path, resolved: path, alias: true, enrolled: false }); + continue; + } + const alias = entry.isSymbolicLink(); + const credential = join(resolved, CREDENTIAL_FILE_NAMES[harness]); + const email = readBundleIdentity(resolved, harness); + entries.push({ + name: entry.name, + path, + resolved, + alias, + ...(alias ? { target: resolved.slice(resolved.lastIndexOf(sep) + 1) } : {}), + enrolled: lstatIfPresent(credential)?.isFile() === true, + ...(email === undefined ? {} : { email }), + }); + } + return entries; +} + +/** + * Point the movable `primary` alias at a real bundle. + * + * Relative so the whole `~/.mosaic` tree stays relocatable, and replaced rather than followed + * so retargeting never writes through into the old bundle. + */ +export function setDefaultBundle( + userHome: string, + harness: CredentialHarness, + bundle: string, +): string { + assertSafeBundleName(bundle); + if (bundle === PRIMARY_ALIAS) { + throw new AuthBundleError('invalid-request', `the ${PRIMARY_ALIAS} alias cannot target itself`); + } + const root = authRoot(userHome, harness); + const target = join(root, bundle); + const info = lstatIfPresent(target); + if (!info) { + throw new AuthBundleError( + 'bundle-not-found', + `no such bundle: ${target} — enroll it first: mosaic auth enroll --harness ${harness} --bundle ${bundle}`, + ); + } + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new AuthBundleError( + 'unsafe-shape', + `the ${PRIMARY_ALIAS} alias may only target a real bundle directory: ${target}`, + ); + } + + const alias = join(root, PRIMARY_ALIAS); + const existing = lstatIfPresent(alias); + if (existing && !existing.isSymbolicLink()) { + throw new AuthBundleError( + 'unsafe-shape', + `a real directory occupies the ${PRIMARY_ALIAS} alias path and will not be deleted: ${alias}. Move it aside, or enroll under its own name.`, + ); + } + if (existing) rmSync(alias); + symlinkSync(bundle, alias); + return alias; +} diff --git a/packages/mosaic/src/fleet/credential-sharing.ts b/packages/mosaic/src/fleet/credential-sharing.ts new file mode 100644 index 00000000..eab88cf4 --- /dev/null +++ b/packages/mosaic/src/fleet/credential-sharing.ts @@ -0,0 +1,44 @@ +/** + * How each harness reaches the credential stored in its auth bundle. + * + * Scaffolding and launch both act on this, so it lives in one module: a seat whose + * scaffold planted a credential symlink that launch never maintains (or the reverse) + * fails in a way that only shows up at the first token refresh. + */ + +/** Mirrors RuntimeName in commands/launch.ts; assignability is asserted there. */ +export type CredentialHarness = 'claude' | 'codex' | 'opencode' | 'pi'; + +/** Credential file each harness reads, relative to its credential directory. */ +export const CREDENTIAL_FILE_NAMES: Record = { + claude: '.credentials.json', + pi: 'auth.json', + codex: 'auth.json', + opencode: 'auth.json', +}; + +/** + * Harnesses that can be pointed at a shared credential directory by environment, + * and the variable that does it. + * + * Claude Code saves credentials by writing a sibling temp file and rename()-ing it + * over the target. rename() replaces a symlink rather than following it, so a managed + * link at the seat's credential path is destroyed by the first token refresh and the + * seat silently forks its credentials. CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the + * credential directory independently of CLAUDE_CONFIG_DIR, which keeps both the temp + * file and the rename inside the bundle where they belong. Evidence: + * docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain). + * + * The value is always an absolute bundle path. Claude reads an empty value as + * ~/.claude — the operator's own account — so an empty value must never be exported. + * + * Harnesses absent from this map keep the managed-link mechanism. + */ +export const CREDENTIAL_DIR_ENV: Partial> = { + claude: 'CLAUDE_SECURESTORAGE_CONFIG_DIR', +}; + +/** True when the harness reaches its bundle by environment instead of a seat-local link. */ +export function sharesCredentialDirByEnv(harness: CredentialHarness): boolean { + return CREDENTIAL_DIR_ENV[harness] !== undefined; +} 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..ab05acb3 --- /dev/null +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -0,0 +1,346 @@ +import { readFileSync } from 'node:fs'; +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'; + +import { CREDENTIAL_FILE_NAMES, sharesCredentialDirByEnv } from './credential-sharing.js'; + +export type FleetAgentHarness = 'claude' | 'pi'; + +export interface FleetAgentScaffoldOptions { + readonly dataHome?: string; + /** Active installed Mosaic root; supplies the canonical authored runtime base. */ + readonly mosaicHome?: 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 mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic')); + const agentDir = join(dataHome, 'fleet', 'agents', name); + const homeName = harness === 'claude' ? '.claude' : '.pi'; + const credentialName = CREDENTIAL_FILE_NAMES[harness]; + 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 credentialLink = join(agentDir, homeName, credentialName); + const sharesByEnv = sharesCredentialDirByEnv(harness); + 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) }, + ], + ...(sharesByEnv + ? [] + : ([[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }]] as [ + string, + ExpectedFile, + ][])), + [ + join(homeName, '.mosaic-managed-links.json'), + { + type: 'file', + content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }), + }, + ], + ]; + if (harness === 'claude') { + entries.push([ + join(homeName, '.claude.json'), + { type: 'file', content: json(onboardingState(mosaicHome)) }, + ]); + } + const files = new Map(entries); + + // Seats scaffolded before the harness moved to an environment-shared credential + // directory still hold a credential symlink and name it in their manifest. The link + // is inert once the harness resolves its credential directory from the environment, + // so it is tolerated rather than reported as a foreign file or silently rewritten. + const legacyCredentialShape = sharesByEnv + ? { + path: join(homeName, credentialName), + manifestPath: join(homeName, '.mosaic-managed-links.json'), + manifestContent: json({ links: { [credentialLink]: credentialTarget } }), + } + : undefined; + + const differences = await findDifferences(agentDir, files, legacyCredentialShape); + 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 }; + +interface LegacyCredentialShape { + /** Seat-relative path of the now-unused credential symlink. */ + readonly path: string; + readonly manifestPath: string; + /** Manifest content written when that link was still maintained. */ + readonly manifestContent: string; +} + +async function findDifferences( + agentDir: string, + expected: ReadonlyMap, + legacy?: LegacyCredentialShape, +): 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()) { + if (legacy && path === legacy.path) continue; + const required = expected.get(path); + if (!required) { + differences.push(path); + continue; + } + try { + const info = await lstat(join(agentDir, path)); + if (required.type === 'file') { + const content = info.isFile() ? await readFile(join(agentDir, path), 'utf8') : undefined; + const acceptable = + legacy && path === legacy.manifestPath + ? [required.content, legacy.manifestContent] + : [required.content]; + if ( + !info.isFile() || + info.isSymbolicLink() || + content === undefined || + !acceptable.includes(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(mosaicHome: string): Record { + const settingsPath = join(mosaicHome, 'runtime', 'claude', 'settings.json'); + let authored: unknown; + try { + authored = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`, + ); + } + if (typeof authored !== 'object' || authored === null || Array.isArray(authored)) { + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings must be a JSON object: ${settingsPath}`, + ); + } + // The shipped settings.json has no mcpServers key at all, so demanding one refused to + // scaffold any Claude seat on a clean install. Absent and empty mean the same thing here: + // no MCP servers. A present-but-wrong-typed key is still an error -- that is a real + // mistake in the file rather than a section the author had nothing to put in. + const servers = 'mcpServers' in authored ? authored.mcpServers : {}; + if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) { + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings have a non-object mcpServers: ${settingsPath}`, + ); + } + return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: servers }; +} + +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 e305a821..3d17d101 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, @@ -45,6 +46,19 @@ const compactionThreatPath = join( 'docs/DEVELOPER-GUIDE/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'); @@ -367,7 +381,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 === '.*'); @@ -663,7 +677,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( @@ -959,7 +973,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..acae7166 --- /dev/null +++ b/packages/mosaic/src/runtime/claude-settings-base.spec.ts @@ -0,0 +1,165 @@ +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); +} + +// Production composition uses universal last-layer-wins array replacement. The +// lease overlay therefore carries complete affected event arrays, including the +// two QA carry-forward entries needed to avoid dropping non-lease hooks. +function deepMerge(base: Json, overlay: Json): Json { + if (Array.isArray(base) && Array.isArray(overlay)) return overlay; + 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, + hooks: { + ...(preSplit['hooks'] as JsonObject), + Stop: [ + { + hooks: [ + { + type: 'command', + command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh', + timeout: 15, + }, + ], + }, + { + 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, + }, + ], + }, + ], + }, + 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('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => { + const overlay = readJson(overlayPath); + expect(Object.keys(overlay)).toEqual(['hooks']); + + const commands = hookCommands(overlay); + const lease = commands.filter((command) => + /mutator-gate|receipt-observer|promote-|revoke-lease/.test(command), + ); + const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command)); + expect(lease).toHaveLength(6); + expect(qa).toHaveLength(2); + expect(commands).toHaveLength(8); + }); + + it.each(['prevent-memory-write', 'reflect-stop'])( + 'fails lossless reconstruction if QA carry-forward %s is removed', + (marker) => { + const base = readJson(basePath); + const overlay = readJson(overlayPath); + const expected = { + ...readJson(gatedFixturePath), + mcpServers: { 'sequential-thinking': sequentialThinking }, + }; + const hooks = overlay['hooks'] as JsonObject; + const mutated: JsonObject = { + hooks: Object.fromEntries( + Object.entries(hooks).map(([event, entries]) => [ + event, + Array.isArray(entries) + ? entries.filter((entry) => !JSON.stringify(entry).includes(marker)) + : entries, + ]), + ), + }; + expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected)); + }, + ); +}); 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 +}