Compare commits

...
Author SHA1 Message Date
terra bf6b245f3c fleet: move directories off managed paths instead of refusing forever
Launch will not delete a real directory sitting where it expects a managed
link -- an auth/<harness>/primary that someone logged into by hand, or a
plugin directory a seat acquired before the central store existed. That
refusal is right and it is also a dead end: the operator gets a composition
error and no way forward.

`mosaic fleet adopt` is the way forward. Bare, it lists every such directory
and the command that resolves it. With a verb, it moves one where it belongs.

Nothing here deletes. A promotion is a rename; an occupied destination is a
refusal, not a merge; a cross-device rename is reported rather than retried as
copy-then-delete, because a copy-then-delete is a delete.

Store adoption stops at the move and does not install the link. The seat's
.mosaic-managed-links.json belongs to launch, and a link written behind it
fails the next composition as an unrecorded symlink -- one refusal traded for
another. The next launch installs and records it when the profile lists the
entry; whether a seat gets a plugin stays `mosaic fleet plugin`'s decision.

W-F3 of docs/plans/2026-08-14_fleet-seats-on-web1.md.
2026-08-14 19:38:41 -05:00
terraandClaude Opus 5 478e925041 fleet: give one host several accounts per harness, and peg each seat to one
`mosaic auth enroll | assign | list | default` (W-F5). Until now a host had one
account per harness, so an author seat and a reviewer seat were the same
principal wearing two names, and a review carried out under that arrangement is
self-review. Bundles under ~/.mosaic/auth/<harness>/<bundle>/ are what a seat's
profile.json points at, so two seats on one host can hold genuinely different
accounts.

Enroll does not reimplement any harness's login. It creates the bundle
directory owner-only, points the harness's own home at it by environment, runs
the harness, and then checks what landed: credential present, permissions
tightened, and the account recorded. Claude is reached through
CLAUDE_SECURESTORAGE_CONFIG_DIR rather than a symlink because it writes by
rename(2), which replaces a symlink instead of following it. --no-login prints
the environment for an operator who would rather run the login themselves.

The check worth naming is identity: enroll reads the account back out of what
the harness wrote and refuses quietly to accept a bundle named for one account
that holds another. That mistake is otherwise silent -- an operator enrolling
the reviewer bundle logs in out of habit as the author, both seats collapse to
one principal, and nothing else in the system notices.

Assign re-parses a seat's profile before rewriting its bundle, so an already
broken profile is reported here rather than re-serialized into something that
looks repaired and still fails at launch. An unenrolled bundle is assigned but
said out loud, because the seat will refuse to launch until the account exists.

registerAuthCommand now returns its Command so these local verbs can hang off
it. They never talk to the gateway and work on a host where it is down.

41 tests. Each of the load-bearing checks was mutation-tested: nine mutations,
each killing exactly the one test that covers it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-14 19:23:19 -05:00
terraandClaude Opus 5 309a99a600 fleet: fix four defects that made no seat launchable on a clean install
Found by rehearsing the full install on a greenfield Debian 13 VM
(mosaic-sbx-dev) rather than on a host that already had a working Mosaic
tree. Each one is invisible on a developer machine and fatal on a new host.

1. Required system settings layer. The framework ships runtime/<harness>/
   for claude, codex, opencode and pi but a settings.json only for claude,
   so requiring the file made every pi, codex and opencode seat refuse to
   compose. The system layer is now optional; what must exist is the
   harness runtime directory, which is the thing that actually proves the
   framework is installed and carries that harness.

2. Required mcpServers in canonical Claude settings. The shipped
   settings.json has no such key, so `fleet agent new` refused to scaffold
   any Claude seat. Absent now means the same as empty. A present but
   wrong-typed value is still an error.

3. Never-enrolled hosts were told their auth directory "must be a real,
   non-symlink directory", which reads as a tampering report when the real
   situation is that nobody has logged in yet. Absent and wrong-shaped are
   now separate messages, and the absent one names `mosaic auth enroll`.

4. A fleet seat whose host had no system SOUL.md reached checkSoul(),
   which spawns the interactive `mosaic wizard` with inherited stdio. On a
   detached tmux seat that parks the pane on a menu with nobody at it: the
   session is live, the systemd unit reports fine, and no agent ever
   starts. A seat's identity is its own SOUL.md, written by `fleet agent
   new`, so the fleet path checks that and fails loudly instead.

Each fix has a regression test verified red against the unfixed source.
The launch.spec.ts seat fixtures gained a SOUL.md they always should have
had -- without it those tests were satisfied by whatever SOUL.md the
developer's real ~/.config/mosaic happened to contain.

Full suite before and after: the same 5 pre-existing failures in
mutator-gate.acceptance.spec.ts and install-ordering-guard.spec.ts,
1585 -> 1591 passing. typecheck and eslint clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-14 19:03:30 -05:00
terra c1a42cdb81 fleet: start a roster pane through its seat when one is scaffolded
The roster lane and the harness-homes lane did not touch. start-agent-session.sh
ran `mosaic yolo "$RUNTIME"` with HOME set to the operator's home, so every fleet
seat on a host shared the operator's harness home and, for Claude, the operator's
own ~/.claude credentials. Nothing in framework/ called `mosaic fleet launch` at
all, which meant ~/.mosaic was a directory nothing read.

The pane now runs `mosaic fleet launch "$AGENT_NAME"` when a scaffolded seat
exists at $PANE_HOME/.mosaic/fleet/agents/<name>/profile.json, and the historical
command otherwise. Detection uses $PANE_HOME/.mosaic rather than MOSAIC_DATA_HOME
because the pane environment is cleared with env -i; the composition resolves the
same root from HOME, so the two cannot disagree.

Additive by construction: a host with no scaffolded seats launches exactly as
before, so this can land ahead of any seat being enrolled.

- fleet launch gains --dangerous, threaded to launchFleetRuntime. Without it a
  seat launched from the roster would drop the permissions footing `mosaic yolo`
  gave it and prompt at a pane with nobody at it. The roster launcher asks for it
  explicitly so it stays visible in the process table instead of becoming a
  profile default.
- A caller's --model replaces the profile's instead of being appended after it.
  The roster carries a model per seat and is the surface operators edit; emitting
  both flags would leave the choice to each harness's argument parser.
- Claude workdir trust is written into the seat's .claude.json when the pane will
  run in a seat home. It previously always went to the operator's ~/.claude.json,
  which would leave the seat prompting on its first turn.

Covers Jason's scope amendment for web1: without this seam, "multiple
authentication accounts and agent pegging to auth" cannot be demonstrated on a
roster-managed seat.
2026-08-14 18:35:11 -05:00
terra a12eeb4786 fleet: share Claude credentials by directory env, not a seat symlink
Claude Code saves credentials by writing a sibling temp file and rename()-ing
it over the target. rename(2) replaces a symlink rather than following it, so
the managed link W-F1/W-F2 planted at <seat>/.claude/.credentials.json is
destroyed by the first token refresh and the seat silently forks its
credentials. The in-place fallback arm opens with O_NOFOLLOW and would refuse
the link anyway. Evidence, quoting the 2.1.232 binary:
docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain).

CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the credential directory
independently of CLAUDE_CONFIG_DIR, so the temp file and the rename both land
inside the bundle. That is the property the design wanted -- share the
credential, never the transcripts -- with no symlink and no privileges.

- new fleet/credential-sharing.ts owns the harness -> credential-file and
  harness -> credential-directory-variable maps, so scaffold and launch cannot
  disagree about the mechanism. It also removes the duplicate credential-file
  name table the two already carried.
- launch composes CLAUDE_SECURESTORAGE_CONFIG_DIR from the resolved bundle
  directory and plans no credential link for Claude. The value is always the
  absolute bundle path: Claude reads an empty value as ~/.claude, which is the
  operator's own account.
- scaffold stops emitting the credential symlink and its manifest entry for
  Claude, and tolerates one left by an earlier scaffold rather than reporting
  it as a foreign file or rewriting it.
- FIRST_AUTH_REFUSAL still fires when a real file occupies the seat path.
- Harnesses absent from the map (pi, codex, opencode) keep managed links; the
  containment specs now exercise them on pi.

Answers promotion gate #1 negatively for the frozen mechanism and positively
for the replacement. E3.3 (two seats refreshing one bundle at once) is still
open.
2026-08-14 18:20:54 -05:00
terra 326a1a58b5 fix(fleet): harden managed launch composition
AMD1213-C: repair stale array consumer, fail closed on foreign link provenance, validate manifests before mutation, and exercise the fleet MCP preflight call path.
2026-08-13 15:37:32 -05:00
terra 2755f86f7b fix(fleet): seed seat MCP preflight config
AMD1213-B5: derive Claude seat MCP configuration from the active installed runtime base and inspect the isolated seat during fleet launch.
2026-08-13 14:38:25 -05:00
terra fe2cf19461 fix(fleet): preserve managed link provenance
AMD1213-B3: record Mosaic-owned links and refuse foreign or retargeted symlink mutations. Out-of-scope review follow-up: settings output/snapshot apply-time TOCTOU remains reported, not patched.
2026-08-13 14:38:25 -05:00
terra 4fde3f622d fix(fleet): contain credential trust roots
AMD1213-B4: reject symlinked auth ancestry and group/world-readable credential artifacts before composition can write.
2026-08-13 14:38:25 -05:00
terra 9de9ffa56b fix(lease): restore uniform settings array replacement
AMD1213-B1: preserve the gated Claude hook composition explicitly in the lease overlay while restoring last-layer-wins arrays and null tombstones.
2026-08-13 14:38:19 -05:00
Jason Woltje cb960237d3 test(lease): assert promotion wiring against composed base+overlay template
ci/woodpecker/pr/ci Pipeline was successful
The lease-overlay split (a42d5e2e) moved the promotion hooks out of the
base Claude settings template; the wiring test still read the base alone
and failed on the absent UserPromptSubmit event, stopping the whole
test:framework-shell chain. The test now composes base + lease overlay
the way a launched seat does (hook event arrays concatenate, base
first) and asserts the same wiring contract against that view.

Reported-by: goals (clean-head probe on 5e154310)
2026-08-13 12:17:10 -05:00
Jason WoltjeandClaude Fable 5 5e15431027 fix(fleet): tolerate harness metadata files in the managed install root
ci/woodpecker/pr/ci Pipeline was canceled
Claude Code writes installed_plugins.json and other metadata files into
the seat's plugins directory during a session, so refusing every real
entry made composition fail on each seat's second launch. Only a real
directory is an unmanaged entry the pruner would orphan; plain files are
harness state and pass through untouched. Found by the in-box hour-gate
relaunch of the probe seat.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-13 11:59:31 -05:00
Jason WoltjeandClaude Fable 5 c16256d48c fix(fleet): compose system settings from the installed flattened home layout
The installed ~/.config/mosaic home flattens the repo's
packages/mosaic/framework/ prefix: the real file is
<home>/runtime/<harness>/settings.json, exactly as launch.ts already
resolves it everywhere. The fleet launch composition leaked the repo
layout (framework/runtime/...) into the system layer path, so a real
installed home failed with COMPOSITION_FAILED while the temp-fixture
specs (which mirrored the same wrong prefix) stayed green. Found by the
in-box hour-gate dry-run against the installed mos-dev-stage home.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-13 11:52:00 -05:00
Jason WoltjeandClaude Fable 5 92e790ae9d fix(fleet): additive hook-event merge and gated-composition acceptance reads
Integration adjudication (fred, W-F1): the general arrays-replace merge rule
conflicts with the gap-7 base/overlay split — base and lease overlay share
the PreToolUse and Stop events, so replace semantics would silently drop the
base QA hooks from every gated seat. Ruling: hook event arrays directly
under the top-level hooks key concatenate (base first); all other arrays
keep replace semantics; null tombstones still delete an event.

- mutator-gate acceptance now asserts lease wiring against the COMPOSED
  gated settings (base + lease-overlay via the launcher's own merge),
  matching the post-split contract.
- fleet subcommand canary gains the intended new 'agent' surface from T3.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-13 11:44:58 -05:00
Jason WoltjeandClaude Fable 5 0fdcfa0ff4 fix(fleet): unify user data-home seam and add actionable unscaffolded-agent error
Integration reconciliation of T2/T3 seams on feat/wf-fleet-mvp:
- fleet launch now resolves the user root through defaultFleetDataHome()
  (MOSAIC_DATA_HOME), the same seam fleet agent new uses, instead of a
  divergent MOSAIC_USER_HOME variable.
- Launching an unscaffolded name raises AGENT_NOT_SCAFFOLDED with the
  actionable message pointing at 'mosaic fleet agent new <name>' (acceptance
  carried over from the T3 card after the roster-v2 reconciliation moved it
  onto the launch path).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
2026-08-13 11:39:44 -05:00
Jason Woltje 4e2f9888a0 Merge branch 'feat/wf-fleet-t2-launch' into feat/wf-fleet-mvp 2026-08-13 11:36:32 -05:00
Jason Woltje 9a92bb64ff Merge branch 'feat/wf-fleet-t3-scaffold' into feat/wf-fleet-mvp 2026-08-13 11:36:32 -05:00
Jason Woltje fe26b37e81 Merge branch 'feat/wf-fleet-t1-base' into feat/wf-fleet-mvp 2026-08-13 11:36:32 -05:00
Jason Woltje 378c227cbb feat(fleet): compose and launch profile-backed seats 2026-08-13 11:30:53 -05:00
Jason Woltje 4522adaa5e feat(fleet): scaffold user-owned agent homes 2026-08-13 11:25:25 -05:00
Jason Woltje a42d5e2ee5 feat(mosaic): split Claude lease overlay from base 2026-08-13 11:21:51 -05:00
32 changed files with 6682 additions and 129 deletions
+94
View File
@@ -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/[email protected] 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.
+102
View File
@@ -0,0 +1,102 @@
# REPORT-T2
Date: 2026-08-13 11:29 CDT
Branch: `feat/wf-fleet-t2-launch`
Base: `216cd722`
Issue: #1209
## What changed
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
- required `schema` and `harness`
- default bundle `primary`
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
- unknown-key refusal naming the key
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
## Reconciliation decisions and contradictions
### Prominent contradiction: roster registries do not contain the frozen launch schema
The existing code has two other profile/registry concepts:
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
### Historical whole-store plugin link
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
### Existing `FleetProfile` name
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
## Ambiguities and bounded choices
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
## Test run
Dependency install and build:
```text
$ pnpm install --frozen-lockfile
Scope: all 28 workspace projects
Lockfile is up to date, resolution step is skipped
Done in 4.7s using pnpm v10.6.2
$ pnpm --filter @mosaicstack/mosaic... build
Scope: 13 of 28 workspace projects
packages/mosaic build: Done
```
Focused and touched integration tests:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
Test Files 3 passed (3)
Tests 256 passed (256)
```
Typecheck and lint:
```text
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
(exit 0)
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
(exit 0)
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
Checking formatting...
All matched files use Prettier code style!
```
Package-wide Vitest result:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run
Test Files 1 failed | 83 passed (84)
Tests 4 failed | 1535 passed (1539)
```
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
+46
View File
@@ -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/<name>` (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 <name> [--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 <name>` 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.
@@ -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
}
]
}
]
}
}
@@ -1,60 +1,7 @@
{
"model": "opus",
"hooks": {
"PreCompact": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
}
]
},
{
"matcher": "resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
"timeout": 3
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
@@ -91,11 +38,6 @@
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
},
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
@@ -315,5 +257,11 @@
"cpan",
"nohup"
],
"enableAllMcpTools": true
"enableAllMcpTools": true,
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
}
}
@@ -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:
@@ -286,12 +286,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
@@ -325,11 +337,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
@@ -409,4 +409,23 @@ if echo "$stop_args" | grep -qF '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'
+2 -1
View File
@@ -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 ──────────────────────────────────────────────────────────
+5 -2
View File
@@ -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;
}
@@ -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<void>;
}
beforeEach((): void => {
process.exitCode = undefined;
});
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function harness(): Promise<Harness> {
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<void> => {
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<string, unknown>): 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 <account>');
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');
});
});
@@ -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 <agent>',
);
}
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>', `Harness: ${HARNESSES.join(', ')}`)
.requiredOption('--as <bundle>', '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} <name>`)
.description(`Move a real ${store} directory out of a seat and into the central store`)
.requiredOption('--seat <agent>', '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);
}
});
}
}
@@ -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<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function fleetDataHome(): Promise<string> {
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<string[]> {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
// 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<void> => {
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);
});
});
@@ -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 <name>')
.description('Create an additive-or-refuse fleet agent harness home')
.option('--harness <harness>', 'Harness: claude or pi', 'claude')
.option('--bundle <bundle>', 'Auth bundle selector', 'primary')
.option('--model <model>', 'Optional harness-native model')
.action(async (name: string, options: NewAgentOptions): Promise<void> => {
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`);
}
});
}
@@ -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<void> => {
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<string, string> }>;
run: (argv: string[]) => Promise<void>;
}
async function harness(
overrides: Omit<FleetAuthCommandDeps, 'fleetDataHome'> = {},
): Promise<Harness> {
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<void> => {
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<FleetAuthCommandDeps['runLogin']> {
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<string, unknown> = { 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('[email protected]') });
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: [email protected]');
const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record<
string,
unknown
>;
expect(recorded['emailAddress']).toBe('[email protected]');
});
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('[email protected]') });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
expect(process.exitCode).toBe(1);
expect(h.err.join('')).toContain('[email protected]');
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('[email protected]', 130) });
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
expect(process.exitCode).toBeUndefined();
expect(h.out.join('\n')).toContain('account: [email protected]');
});
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<string, unknown>;
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('[email protected]') });
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<string, unknown>;
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('[email protected]') });
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('[email protected]');
});
});
describe('mosaic auth default', () => {
it('moves the primary alias to a bundle', async () => {
const h = await harness({ runLogin: goodLogin('[email protected]') });
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');
});
});
@@ -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<Record<string, string>>,
) => 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<Record<string, string>>,
): 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<string, unknown>;
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>', `Harness: ${HARNESSES.join(', ')}`)
.requiredOption('--bundle <bundle>', 'Bundle name, normally the account email with @ as _')
.option('--login-arg <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 <agent> --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>', '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<CredentialHarness>();
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 <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 <harness> --bundle <account>`,
);
}
} catch (error: unknown) {
fail(error, 'list');
}
});
authCommand
.command('default <bundle>')
.description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`)
.requiredOption('--harness <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');
}
});
}
@@ -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<string, unknown> = { 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":"[email protected]"}}\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/<harness>/ 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":"[email protected]"}}\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 ([email protected])');
});
it('refuses first-auth state when a real file occupies the seat link', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
mkdirSync(seatHome, { recursive: true });
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
});
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, '<ROOT>');
expect(output).toMatchInlineSnapshot(`
"mosaic fleet launch fred --dry-run
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
harness: claude
seat-home: <ROOT>/user/fleet/agents/fred/.claude
settings sources:
system: <ROOT>/system/runtime/claude/settings.json
user: <ROOT>/user/config/claude/settings.json
agent: <ROOT>/user/fleet/agents/fred/overlay.json
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
merged settings:
{
"hooks": [
"user"
],
"nested": {
"agent": true,
"system": true,
"user": true
}
}
bundle: primary -> fred_example.com ([email protected])
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
symlinks:
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
declared env:
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/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);
});
});
File diff suppressed because it is too large Load Diff
@@ -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',
+32
View File
@@ -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<FleetMigrationCommandDeps, 'mosaicHome'>;
}
@@ -2041,6 +2053,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 <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
@@ -2054,6 +2074,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);
+293 -1
View File
@@ -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 {
@@ -8,6 +19,8 @@ import {
enumerateSkillDirs,
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
launchFleetRuntimeForTest,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -86,6 +99,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([
+282 -46
View File
@@ -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<RuntimeName, string> = {
claude: 'Claude Code',
@@ -64,19 +72,19 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
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<string, string> {
function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
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 <name>');
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<RuntimeName, string> = {
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) {
@@ -889,15 +1011,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<Record<string, string>>;
/** 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<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of [
'PATH',
'HOME',
'USER',
'LOGNAME',
'SHELL',
'TERM',
'COLORTERM',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_RUNTIME_DIR',
]) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
return { ...env, ...declared };
}
function launchRuntime(
runtime: RuntimeName,
args: string[],
yolo: boolean,
context: RuntimeLaunchContext = {},
): never {
checkMosaicHome();
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();
@@ -909,14 +1067,32 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime);
const launchEnv =
context.declaredEnv === undefined ? process.env : minimalLaunchEnv(context.declaredEnv);
// A per-agent profile is the launch SSOT and intentionally does not require a
// second roster registry. Keep roster-v1 identity composition for legacy
// launches, but remove its identity keys from the contract-build environment
// for a profile-backed seat. The declared identity is still exported to the
// harness process below.
const contractEnv =
context.declaredEnv === undefined
? launchEnv
: Object.fromEntries(
Object.entries(launchEnv).filter(
([name]) =>
name !== 'MOSAIC_AGENT_NAME' &&
name !== 'MOSAIC_AGENT_CLASS' &&
name !== 'MOSAIC_AGENT_TOOL_POLICY',
),
);
switch (runtime) {
case 'claude': {
// Audit Claude Code settings and warn about missing hooks/plugins
const settingsAudit = auditClaudeSettings();
const settingsAudit = auditClaudeSettings(context.fleet);
printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude');
const prompt = buildRuntimePrompt('claude', contractEnv);
const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
@@ -925,13 +1101,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);
@@ -939,22 +1127,38 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime('codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break;
}
case 'opencode': {
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
ensureRuntimeConfig(
'opencode',
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
contractEnv,
);
console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime('opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break;
}
case 'pi': {
const prompt = buildRuntimePrompt('pi');
const prompt = buildRuntimePrompt('pi', contractEnv);
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension());
@@ -964,8 +1168,11 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo);
execLeaseGatedRuntime('pi', cliArgs);
recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
break;
}
}
@@ -993,6 +1200,7 @@ function execLeaseGatedRuntime(
args: string[],
baseEnv: NodeJS.ProcessEnv = process.env,
dangerous = false,
fleet?: FleetHarnessContext,
): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
const dangerousArgs = dangerous ? ['--dangerous'] : [];
@@ -1001,13 +1209,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<Record<string, string>>,
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<Record<string, string>>,
fleet: FleetHarnessContext,
finalExecutor: NonNullable<RuntimeLaunchContext['finalExecutor']>,
): 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 {
+341
View File
@@ -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<void> => {
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function userHome(): Promise<string> {
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<string, unknown> = { 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 <account>');
});
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,
);
});
});
+380
View File
@@ -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/<harness>/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<StoreKind, string> = { 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<string, unknown>;
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 <account>`,
});
}
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} <bundle>`,
);
}
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),
};
}
@@ -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<void> => {
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function userHome(): Promise<string> {
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: '[email protected]' } }),
);
const result = completeEnrollment(plan);
expect(result.email).toBe('[email protected]');
expect(result.identityMismatch).toBeUndefined();
const recorded = JSON.parse(
await readFile(join(plan.bundleDir, 'account.json'), 'utf8'),
) as Record<string, unknown>;
expect(recorded['emailAddress']).toBe('[email protected]');
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: '[email protected]' } }),
);
const result = completeEnrollment(plan);
expect(result.email).toBe('[email protected]');
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('[email protected]')).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: '[email protected]' }));
writeFileSync(
join(plan.bundleDir, '.claude.json'),
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
);
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('[email protected]');
});
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: '[email protected]' }),
);
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: '[email protected]',
});
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,
);
});
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Credential bundles under `~/.mosaic/auth/<harness>/<bundle>/`.
*
* 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<CredentialHarness, string> = {
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<CredentialHarness, ReadonlyArray<readonly [string, string[]]>> = {
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<Record<string, string>>;
}
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/<harness>`. */
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<string, unknown> | 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<string, unknown>;
} catch {
return undefined;
}
}
function dig(source: Record<string, unknown>, 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<string, unknown>)[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} <bundle>`,
);
}
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;
}
@@ -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<CredentialHarness, string> = {
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<Record<CredentialHarness, string>> = {
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;
}
@@ -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<Record<string, unknown>>;
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<FleetAgentScaffoldResult> {
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<string, unknown> = {
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<string, ExpectedFile>(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<string, ExpectedFile>,
legacy?: LegacyCredentialShape,
): Promise<string[]> {
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<string>();
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<string[]> {
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<string, unknown> {
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<boolean> {
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));
}
@@ -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"]
@@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { afterEach, describe, expect, test } from 'vitest';
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
import { deepMergeSettings } from '../commands/fleet-launch-command.js';
import {
observeAndPromoteReceiptChallenge,
requestBrokerReply,
@@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py')
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json');
// The gated seat contract is the COMPOSITION of the ungated base and the
// lease overlay (gap-7 split); assertions about lease wiring must read that
// composed view, produced by the same merge the launcher uses.
async function readGatedClaudeSettings(): Promise<Record<string, unknown>> {
const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record<string, unknown>;
const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record<
string,
unknown
>;
return deepMergeSettings(base, overlay);
}
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
@@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => {
expect(parserResult.status).toBe(0);
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
};
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
@@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => {
});
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
};
expect(
@@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
await promote(socket, sessionId, pending.receipt_challenge!);
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
};
expect(
@@ -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));
},
);
});
@@ -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
}