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
26 changed files with 3397 additions and 2077 deletions
@@ -1,202 +0,0 @@
# AMD1213-D — transaction and helper trust remediation
- **Task:** AMD1213-D (issue #1213 amendment; controlling packet `comms/20260813T212447Z__from-scrappy__dc43de.md`)
- **Objective:** Address D1D6 on local `feat/wf-fleet-mvp`, commit-only. Never push or re-author.
- **Scope:** Existing C-fence production/tests only. No provider calls.
- **Standing constraint:** AMEND/HOLD. Do not push, do not update PR #1213, do not merge, do not
re-author. PR #1216 remains independently held for Jason.
## Where this actually stands (measured 2026-08-15, not inherited from notes)
Everything below was re-measured against the tree rather than trusted from the previous entries,
which understated progress by roughly two defects. Branch head `3667a7a7`.
| Defect | State | Evidence |
|---|---|---|
| D1 transactional rollback | **Confirmed** — controls exist and are strong | see below; one narrow gap (in-memory link restoration unasserted) |
| D2 exact managed-link classes | Substantially implemented | classifier at `fleet-launch-command.ts:598-663`: exact resolved credential, direct one-component plugin/skill only, symlink target and ancestor rejected, `realpath` containment, duplicates rejected |
| D3 ambient-PATH executable resolution | **Closed** — executables `585dac7a`, environment `3667a7a7` | see below; one deliberate residual (HOME) |
| D4 config check/apply safety | Substantially implemented | `secure_dir` ancestor checks, `read_private` with `O_NOFOLLOW` + fstat, apply via mkstemp + fchmod 0600 + fsync + dev/ino re-check before `os.replace`, compatibility path separated |
| D5 bounded test seam | **Closed this pass** (commit `b91b702a`) | see below |
| D6 validate-by-path then exec-by-path | Substantially implemented | helper runs as a verified snapshot piped to `bash -s`, not executed by pathname; the same binding applied to the runtime in D3 |
### D1 — confirmed, and a correction to my own survey
I first reported that eleven `injectFailure` seams existed in production and **zero tests used
any of them**, and that D1's rollback path had never been executed. That was wrong. The grep
behind it searched for the identifier `injectFailure` in the specs; the specs supply the injector
as an inline lambda, so the controls were there and the search could not see them. Method note
for the next survey: grep the production seam names, not the parameter name.
The controls that exist, all in `fleet-launch-command.spec.ts`:
- **All ten mutation seams**, table-driven — `mkdir-seat`, `prepare-manifest`, `write-settings`,
`write-snapshot`, `credential-link`, `prune-link`, `install-link`, `write-manifest`,
`close-manifest`, `rename-manifest`. Each asserts byte-for-byte restoration of six artifacts
(settings bytes, settings mode, generated snapshot, manifest, credential symlink target, plugin
symlink target) plus the absence of the `.tmp` manifest.
- **New-seat rollback** — a failure on a seat the transaction itself created leaves no directory
and no residue.
- **`ROLLBACK_INTEGRITY` escalation** — three parent-substitution attacks (symlink swap, inode
replacement, rename away) each produce a typed refusal, leave an external sentinel untouched,
and write `.mosaic-fleet-launch-recovery.json`. A replacement of the transaction-created seat
is likewise refused rather than deleted.
That is a real RED→GREEN matrix, not an implementation read as done.
**Gap, narrow:** nothing asserts the in-memory restoration of `plan.managedLinks.links` to
`manifestLinksBefore` — the filesystem is checked, the plan object is not. It matters only if a
caller reuses a plan after catching a failure, which nothing currently does, so this is
defence-in-depth rather than a live defect. Worth one assertion when D2/D4/D6 are confirmed.
### D2 — one thing worth recording so it is not "fixed" later
The alias concern in the packet (`duplicates/normalization aliases`) is closed by strictness, not
by normalization. `dirname(link)` is compared literally against the seat root, so `/s/plugins//foo`
(`dirname``/s/plugins/`), `/s/plugins/./foo` and `/s/plugins/bar/../foo` all fail the comparison
and are rejected. Verified by direct measurement of `path.dirname` on each form. Anyone who
"improves" this by normalizing the link first would open the alias hole the strict comparison
currently closes.
### D3 — what was wrong and what was done
The launcher asked `which` whether a runtime was reachable and then spawned the **bare name**,
letting the OS resolve it a second time against an ambient PATH at a later moment. Two independent
resolutions of an attacker-influenced name with a gap between them.
Demonstrated against the old code before changing it — a world-writable `codex` shim prepended to
PATH:
```
OLD checkRuntime -> PASSED (which found it)
OLD execRuntime -> "SHIM EXECUTED — this is not the real runtime"
```
Three exposed call sites, not one: `checkRuntime`'s `which`; `execRuntime` spawning
`codex`/`opencode` by name; and `execLeaseGatedRuntime` spawning `python3` by name — the
interpreter that starts the lease gate, where a shim replaces the process that enforces every other
check. `minimalLaunchEnv` copies ambient PATH straight through.
Fix: `resolveExecutableFromPath` searches only the PATH the child will actually receive, validates
what the search lands on (regular file, executable, not group/other-writable, owned by the
launching user or root, no group/world-writable non-sticky directory and no foreign-owned directory
on the resolved path), and returns that path pinned to dev/ino. Callers execute the returned path
and never the name again. The fleet lease-gate interpreter comes from the root-owned
`trustedCapability('python3')`. `checkRuntime` is deliberately kept on the operator path, where
"is it reachable from my shell" is the right question.
**Residuals, stated not engineered around:**
1. `assertUnchangedSinceValidation` re-confirms dev/ino immediately before spawn. That narrows the
validation→exec window; it does not close it. Closing it means exec by held descriptor, which
Node cannot do portably. Same accepted boundary already documented for the fleet helper.
2. For claude and pi the runtime binary is still re-resolved inside `launch-runtime.py` after the
trusted interpreter starts it. **This change does not cover that path.**
12 tests, one per hole. One was written wrong first and is worth remembering:
`mkdirSync(path, { mode: 0o777 })` is masked by the umask to 0o755, so the world-writable-directory
case passed while testing nothing. Create at 0o755, then `chmodSync`.
### D3 environment half — measured, and mostly already true
Measured before changing anything: the real `fleet launch` route with a shim in place of the
runtime binary, the shim dumping its own environment. The subject is therefore what arrives after
composition **and** after `launch-runtime.py` adds the lease variables — not the object the
launcher builds. Those are different sets.
The complete child environment for a composed claude seat:
```
PATH HOME USER LOGNAME SHELL TERM COLORTERM TMPDIR XDG_RUNTIME_DIR (inherited allowlist)
LANG LC_ALL (fixed, this pass)
CLAUDE_CONFIG_DIR MOSAIC_AGENT_NAME <profile env> (declared)
MOSAIC_LAUNCH_ID (minted per launch)
MOSAIC_LEASE_BROKER_SOCKET MOSAIC_LEASE_GENERATION_FILE
MOSAIC_LEASE_RUNTIME MOSAIC_LEASE_SESSION_ID
MOSAIC_RECEIPT_OBSERVER_SOCKET MOSAIC_RUNTIME_GENERATION (lease gate)
```
Most of the defect was already closed **by construction and untested**. `minimalLaunchEnv` builds
from an empty object over a fixed list, so `BASH_ENV`, `ENV`, `PYTHON*`, `NODE_*`, `NPM_CONFIG_*`,
`LD_PRELOAD`, `LD_LIBRARY_PATH` and provider credentials never reach the child. All sixteen were
planted; none survived, including through the lease gate. The gap was that nothing named the
allowlist, and an allowlist no test names is one careless edit away from being a denylist.
Fixed: **locale was inherited**, so the same seat emitted different message language, collation and
number/date formatting depending on who started it. Composed launches now pin `C.UTF-8` — not `C`,
which is ASCII and would mangle non-ASCII output. A profile-declared `LANG`/`LC_ALL` still wins,
and a test holds that escape hatch open. The operator path is untouched.
**Residual, deliberate — `HOME` is still the operator's.** The card is right that this is the
remaining leak: the runtime gets its own config dir, but anything it shells out to (git, ssh, npm)
reads the operator's dotfiles and therefore the operator's credentials. Not changed here, because
a seat whose HOME is a bare directory has no gitconfig and no ssh key, so it cannot commit or push
— and the fleet MVP's proof is a seat carrying a change to a pushed branch. Moving HOME before the
per-agent home is populated improves isolation and breaks the deliverable. **Owner: the
harness-homes design**, which is exactly the track that populates a per-agent home with its own
auth bundle. Do it there, not here.
Eight tests, each falsified by inverting the property it defends; every inversion hit only its own
test: `BASH_ENV` added to the inherited list → permitted-set + loader-hook killers red (2 failed);
locale pin reverted → locale killer red; ambient `MOSAIC_LAUNCH_ID` reused → launch-id killer red;
`process.env` recorded into the ledger → ledger-value killer red.
The permitted-name list in the spec is hand-written, not derived from the launcher. Deriving it
would make the test agree with the code by construction and detect nothing.
### D5 — what remained and what was done
Most of D5 was already closed: `launchFleetRuntimeForTest` is gone, specs enter through the real
`registerFleetLaunchCommand → apply → launchFleetRuntime → launchRuntime` route on a fixture seat,
the ledger points at the fixture and **is** asserted, and the seat-seeded/HOME-empty pass plus
HOME-seeded/seat-empty fail pair both exist.
What remained was the dead `recordLaunch?: boolean` context field. Nothing in the package set it;
its only effect was to let a caller silently disable recording on the claude branch while codex,
opencode and pi recorded unconditionally. Removed.
## Not part of D1D6, fixed because it blocked the required evidence
The amend requires a green full-package Vitest run.
`install-ordering-guard.spec.ts > defaults to the real leaseEnforcementActivatable()` made that
non-reproducible. `defaultCapabilityProbe` executes `dist/cli.js` out-of-process with a **2000 ms
timeout**; in a full run with 86 spec files scheduled at once, one observation beats the timeout and
the next does not, so the test's two observations of the same predicate disagree and it fails —
reporting machine load as a wiring defect. Passed 3/3 in isolation, failed in three consecutive
full runs.
Ruled out my own change by reverting only the `recordLaunch` edit and re-running: still failed.
The guard call is now bracketed by two observations, only an agreeing pair is used as ground truth,
a disagreeing pair is retried up to three times, and never holding still is a failure rather than a
skip. Falsified by inverting the guard's default to `!leaseEnforcementActivatable()` → red
(1 failed / 18 passed), then reverted.
## Verification state
- typecheck RC=0.
- Full package Vitest, sanitized lease env (`MOSAIC_LEASE_*` + `MOSAIC_RUNTIME_GENERATION`
stripped): **87 files / 1627 tests passed, 0 failed**, three consecutive runs plus one against
the committed tree, RC=0. That is exactly one file and eight tests above the 86/1619 baseline,
so the D3 environment work moved nothing else. eslint RC=0, prettier clean.
- Without that sanitization the suite shows 4 failures in `mutator-gate.acceptance.spec.ts`. Those
are the known host lease-identity leak into spawned hooks, **not** a product defect — the same
spec re-run with only those five variables stripped and no code change is 20/20. The standing fix
is the unpushed `fix/lease-test-env-isolation` branch (blocked on the identity blocker below).
## Still open
- **D2/D4/D6 need confirmation, not assumption.** They read as substantially implemented but I
have not run the packet's full RED→GREEN control matrix against each seam. D1 is now confirmed
(see above), D3 and D5 are closed; these three are the remaining item.
- **D3's HOME residual** is routed to harness-homes (see above). It is stated, not engineered
around, and it does not belong to this branch.
- Required next evidence per the packet: all D1D6 observed RED→GREEN controls, framework-shell,
build/lint/Prettier/bash -n, fresh current-next merge-tree.
## Blocker not solvable inside this branch
No `fred` principal exists (`tea login list` has no entry; `MOSAIC_GIT_IDENTITY` never reaches the
pane). The only push path on this host is the **retired** mos-dt-0 token. That is why this work is
commit-only beyond scrappy's instruction — even after the hold lifts, the truthful authenticated
push the packet requires cannot be made under a correct identity yet. Raised with mos-claude and
with Jason; awaiting a mint decision.
@@ -1,17 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# Fleet launches execute this source through an already-validated absolute bash
# capability and pass all interpreter capabilities explicitly. Do not add PATH
# lookup here: this helper is intentionally capability-minimal.
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
MODE="apply"
RUNTIME="all"
STRICT_CHECK=0
CLAUDE_CONFIG_DIR=""
PYTHON_BIN=""
NODE_BIN=""
NPX_BIN=""
TIMEOUT_BIN=""
PKG="@modelcontextprotocol/server-sequential-thinking"
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
@@ -19,130 +14,273 @@ log() { echo "[mosaic-seq] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--check) MODE="check"; shift ;;
--runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;;
--strict) STRICT_CHECK=1; shift ;;
--claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;;
--python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;;
--node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;;
--npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;;
--timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;;
*) err "Unknown argument: $1"; exit 2 ;;
--check)
MODE="check"
shift
;;
--runtime)
if [[ $# -lt 2 ]]; then
err "--runtime requires a value: claude|codex|opencode|all"
exit 2
fi
RUNTIME="$2"
shift 2
;;
--strict)
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
;;
esac
done
case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac
# Explicit fleet-seat operation is capability-minimal. Legacy operator repair
# keeps its documented PATH-based compatibility contract.
if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then
[[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; }
else
PYTHON_BIN=python3
NODE_BIN=node
NPX_BIN=npx
TIMEOUT_BIN=timeout
fi
case "$RUNTIME" in
all|claude|codex|opencode) ;;
*)
err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)"
exit 2
;;
esac
require_binary() {
local name="$1"
if ! command -v "$name" >/dev/null 2>&1; then
err "Required binary missing: $name"
return 1
fi
}
check_software() {
require_binary node
require_binary npx
}
warm_package() {
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1
else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi
if command -v timeout >/dev/null 2>&1; then
timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1
else
npx -y "$PKG" --help >/dev/null 2>&1
fi
}
claude_config_python='import json, os, stat, tempfile
check_claude_config() {
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
import json
import os
from pathlib import Path
# 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"))
except Exception:
raise SystemExit(1)
mcp = data.get("mcpServers")
if not isinstance(mcp, dict):
raise SystemExit(1)
entry = mcp.get("sequential-thinking")
if not isinstance(entry, dict):
raise SystemExit(1)
if entry.get("command") != "npx":
raise SystemExit(1)
args = entry.get("args")
if args != ["-y", "@modelcontextprotocol/server-sequential-thinking"]:
raise SystemExit(1)
PY
}
def die(): raise SystemExit(1)
def secure_dir(p):
p=Path(p)
if not p.is_absolute(): die()
# Every parent may be sticky /tmp, but none may be a symlink. The fleet
# config root itself must be private and owned by the invoking principal.
for q in [p, *p.parents]:
try: s=os.lstat(q)
except OSError: die()
if stat.S_ISLNK(s.st_mode) or not stat.S_ISDIR(s.st_mode): die()
if q != p and s.st_mode & 0o022 and not (s.st_mode & stat.S_ISVTX): die()
s=os.lstat(p)
if s.st_uid not in (os.geteuid(), 0) or s.st_mode & 0o022: die()
return p
def read_private(p):
try: fd=os.open(p, os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
except OSError: die()
apply_claude_config() {
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
import json
import os
from pathlib import Path
# 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:
s=os.fstat(fd)
if not stat.S_ISREG(s.st_mode) or s.st_uid not in (os.geteuid(),0) or s.st_mode & 0o077 or s.st_size>1048576: die()
data=b""
while len(data)<=1048576:
c=os.read(fd,65536)
if not c: break
data+=c
if len(data)>1048576: die()
return data, (s.st_dev,s.st_ino)
finally: os.close(fd)
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
else:
data = {}
mcp = data.get("mcpServers")
if not isinstance(mcp, dict):
mcp = {}
mcp["sequential-thinking"] = {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
data["mcpServers"] = mcp
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
}
def entry_ok(data):
try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{})
except Exception: return False
return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"]
check_codex_config() {
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" && \
grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg"
}
def explicit_check_or_apply(apply):
root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json"
if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1
old={}; identity=None
if os.path.lexists(p):
raw,identity=read_private(str(p))
try: old=json.loads(raw.decode())
except Exception: old={}
mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {}
mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp
fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root)
apply_codex_config() {
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
mkdir -p "$(dirname "$cfg")"
[[ -f "$cfg" ]] || touch "$cfg"
local tmp
tmp="$(mktemp)"
awk '
BEGIN { skip = 0 }
/^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next }
skip && /^\[/ { skip = 0 }
!skip { print }
' "$cfg" > "$tmp"
mv "$tmp" "$cfg"
{
echo ""
echo "[mcp_servers.sequential-thinking]"
echo "command = \"npx\""
echo "args = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]"
} >> "$cfg"
}
check_opencode_config() {
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
import json
import os
from pathlib import Path
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:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
raise SystemExit(1)
mcp = data.get("mcp")
if not isinstance(mcp, dict):
raise SystemExit(1)
entry = mcp.get("sequential-thinking")
if not isinstance(entry, dict):
raise SystemExit(1)
if entry.get("type") != "local":
raise SystemExit(1)
if entry.get("command") != ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]:
raise SystemExit(1)
if entry.get("enabled") is not True:
raise SystemExit(1)
PY
}
apply_opencode_config() {
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
import json
import os
from pathlib import Path
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:
os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd)
try: now=os.lstat(p); current=(now.st_dev,now.st_ino)
except FileNotFoundError: current=None
if current!=identity: die()
os.replace(tmp,p)
finally:
try: os.close(fd)
except OSError: pass
try: os.unlink(tmp)
except FileNotFoundError: pass
return 0
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
data = {}
else:
data = {}
mcp = data.get("mcp")
if not isinstance(mcp, dict):
mcp = {}
mcp["sequential-thinking"] = {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
"enabled": True
}
data["mcp"] = mcp
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
}
if os.environ.get("CLAUDE_CONFIG_DIR"):
raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1"))
# Compatibility path is intentionally not fleet-authoritative.
p=Path.home()/".claude.json"
if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json"
if os.environ.get("SEQ_APPLY")=="1":
try: d=json.loads(p.read_text()) if p.exists() else {}
except Exception: d={}
m=d.get("mcpServers") if isinstance(d.get("mcpServers"),dict) else {}
m["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; d["mcpServers"]=m
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n")
raise SystemExit(0)
try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1)
except Exception: raise SystemExit(1)'
check_runtime_config() {
case "$RUNTIME" in
all)
check_claude_config
check_codex_config
check_opencode_config
;;
claude)
check_claude_config
;;
codex)
check_codex_config
;;
opencode)
check_opencode_config
;;
esac
}
check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; }
apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; }
check_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; s=Path(os.environ["CODEX_CFG"]).read_text(); ok=bool(re.search(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]",s,re.M) and "command = \"npx\"" in s and "@modelcontextprotocol/server-sequential-thinking" in s); raise SystemExit(0 if ok else 1)'; }
apply_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; p=Path(os.environ["CODEX_CFG"]); p.parent.mkdir(parents=True,exist_ok=True); out=[]; skip=False
for line in (p.read_text().splitlines() if p.exists() else []):
if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue
if skip and line.startswith("["): skip=False
if not skip: out.append(line)
p.write_text("\n".join(out).rstrip()+"\n\n[mcp_servers.sequential-thinking]\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]\n")'; }
check_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; d=json.loads(p.read_text()); e=d.get("mcp",{}).get("sequential-thinking"); expected={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; raise SystemExit(0 if e==expected else 1)' ; }
apply_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; 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); d=json.loads(p.read_text()) if p.exists() else {}; m=d.get("mcp") if isinstance(d.get("mcp"),dict) else {}; m["sequential-thinking"]={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; d["mcp"]=m; p.write_text(json.dumps(d,indent=2)+"\n")'; }
check_runtime_config() { case "$RUNTIME" in all) check_claude_config && check_codex_config && check_opencode_config;; claude) check_claude_config;; codex) check_codex_config;; opencode) check_opencode_config;; esac; }
apply_runtime_config() { case "$RUNTIME" in claude) apply_claude_config;; codex) apply_codex_config;; opencode) apply_opencode_config;; all) apply_claude_config && apply_codex_config && apply_opencode_config;; esac; }
if [[ "$MODE" == check ]]; then
apply_runtime_config() {
case "$RUNTIME" in
all)
apply_claude_config
apply_codex_config
apply_opencode_config
;;
claude)
apply_claude_config
;;
codex)
apply_codex_config
;;
opencode)
apply_opencode_config
;;
esac
}
if [[ "$MODE" == "check" ]]; then
check_software
check_runtime_config
if [[ "$STRICT_CHECK" == 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == 1 ]]; then warm_package || { err "sequential-thinking package warm-up failed in strict mode"; exit 1; }; fi
log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0
# Runtime launch checks should be local/fast by default.
if [[ "$STRICT_CHECK" -eq 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == "1" ]]; then
if ! warm_package; then
err "sequential-thinking package warm-up failed in strict mode"
exit 1
fi
fi
log "sequential-thinking MCP is configured and available (${RUNTIME})"
exit 0
fi
check_software
if ! warm_package; then
err "Unable to warm sequential-thinking package (npx timeout/failure)"
exit 1
fi
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
apply_runtime_config
log "sequential-thinking MCP configured (${RUNTIME})"
@@ -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);
}
});
}
}
@@ -1,5 +1,14 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
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';
@@ -60,9 +69,10 @@ describe('mosaic fleet agent new', (): void => {
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/.credentials.json',
'.claude/.mosaic-managed-links.json',
'.claude/CLAUDE.md',
'SOUL.md',
@@ -87,13 +97,30 @@ describe('mosaic fleet agent new', (): void => {
},
},
});
const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget);
expect(
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
).toEqual({
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget },
});
).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> => {
@@ -210,10 +237,61 @@ describe('mosaic fleet agent new', (): void => {
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
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(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
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);
});
});
@@ -46,7 +46,7 @@ export function registerFleetAgentScaffoldCommand(
);
if (!result.credentialTargetExists) {
console.log(
`Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`,
`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) {
@@ -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');
}
});
}
@@ -6,7 +6,6 @@ import {
mkdtempSync,
readFileSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -37,25 +36,28 @@ function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claud
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', 'claude', 'fred_example.com');
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
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', 'claude', 'settings.json'), '{}\n');
writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n');
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 });
writeFileSync(
join(namedBundleDir, 'account.json'),
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
);
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
return { root, systemHome, userHome, agentDir, namedBundleDir };
symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
}
describe('fleet launch profile schema 1', () => {
@@ -289,6 +291,65 @@ describe('profile-selected overlay', () => {
});
});
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();
@@ -382,6 +443,33 @@ describe('A3 credential validation', () => {
).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', () => {
@@ -426,158 +514,6 @@ describe('managed plugin and skill links', () => {
expect(readlinkSync(link)).toBe(target);
});
it.each([
'mkdir-seat',
'prepare-manifest',
'write-settings',
'write-snapshot',
'credential-link',
'prune-link',
'install-link',
'write-manifest',
'close-manifest',
'rename-manifest',
])('rolls an existing seat back byte-for-byte at the %s mutation seam', (seam) => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old', 'keep'] });
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { 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":["keep"]}\n',
);
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const seat = join(fx.agentDir, '.claude');
const before = Object.fromEntries([
['settings', readFileSync(join(seat, 'settings.json'))],
['settingsMode', lstatSync(join(seat, 'settings.json')).mode],
['snapshot', readFileSync(join(fx.agentDir, 'settings.generated.json'))],
['manifest', readFileSync(join(seat, '.mosaic-managed-links.json'))],
['credential', readlinkSync(join(seat, '.credentials.json'))],
['old', readlinkSync(join(seat, 'plugins', 'old'))],
]);
expect(() =>
applyFleetLaunchComposition(
plan,
(point) =>
point === seam &&
(() => {
throw new Error(seam);
})(),
),
).toThrow(seam);
expect(readFileSync(join(seat, 'settings.json'))).toEqual(before.settings);
expect(lstatSync(join(seat, 'settings.json')).mode).toBe(before.settingsMode);
expect(readFileSync(join(fx.agentDir, 'settings.generated.json'))).toEqual(before.snapshot);
expect(readFileSync(join(seat, '.mosaic-managed-links.json'))).toEqual(before.manifest);
expect(readlinkSync(join(seat, '.credentials.json'))).toBe(before.credential);
expect(readlinkSync(join(seat, 'plugins', 'old'))).toBe(before.old);
expect(existsSync(join(seat, '.mosaic-managed-links.json.tmp'))).toBe(false);
});
it.each([
[
'symlink swap',
(seat: string, displaced: string, sentinel: string) => {
renameSync(seat, displaced);
symlinkSync(sentinel, seat, 'dir');
},
],
[
'inode replacement',
(seat: string, displaced: string) => {
renameSync(seat, displaced);
mkdirSync(seat);
},
],
[
'rename away',
(seat: string, displaced: string) => {
renameSync(seat, displaced);
},
],
])('refuses rollback parent %s without touching the external sentinel', (_kind, substitute) => {
const fx = fixture({ schema: 1, harness: 'claude' });
const initial = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
applyFleetLaunchComposition(initial);
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const seat = join(fx.agentDir, '.claude');
const displacedSeat = join(fx.root, 'displaced-seat');
const sentinel = join(fx.root, 'external-sentinel');
mkdirSync(sentinel);
writeFileSync(join(sentinel, 'settings.json'), 'outside\n');
try {
applyFleetLaunchComposition(plan, (point) => {
if (point !== 'write-settings') return;
substitute(seat, displacedSeat, sentinel);
throw new Error('injected parent swap');
});
throw new Error('expected rollback integrity refusal');
} catch (error) {
expect(error).toBeInstanceOf(FleetLaunchError);
expect((error as FleetLaunchError).code).toBe('ROLLBACK_INTEGRITY');
expect((error as Error).message).toContain('injected parent swap');
}
expect(readFileSync(join(sentinel, 'settings.json'), 'utf8')).toBe('outside\n');
expect(existsSync(join(fx.agentDir, '.mosaic-fleet-launch-recovery.json'))).toBe(true);
});
it('refuses a replacement of the transaction-created new seat before rollback cleanup', () => {
const fx = fixture({ schema: 1, harness: 'claude' });
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const seat = join(fx.agentDir, '.claude');
const createdSeat = join(fx.root, 'created-seat');
try {
applyFleetLaunchComposition(plan, (point) => {
if (point !== 'credential-link') return;
renameSync(seat, createdSeat);
mkdirSync(seat);
throw new Error('injected created-seat replacement');
});
throw new Error('expected rollback integrity refusal');
} catch (error) {
expect(error).toMatchObject({ code: 'ROLLBACK_INTEGRITY' });
}
expect(existsSync(createdSeat)).toBe(true);
expect(existsSync(join(fx.agentDir, '.mosaic-fleet-launch-recovery.json'))).toBe(true);
});
it('rolls a new seat back without directories or residues after a mutation failure', () => {
const fx = fixture({ schema: 1, harness: 'claude' });
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(() =>
applyFleetLaunchComposition(
plan,
(point) =>
point === 'credential-link' &&
(() => {
throw new Error(point);
})(),
),
).toThrow('credential-link');
expect(existsSync(join(fx.agentDir, '.claude'))).toBe(false);
});
it('prunes a recorded matching stale symlink', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
@@ -602,26 +538,6 @@ describe('managed plugin and skill links', () => {
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
});
it('prunes a recorded direct managed link after its central-store target vanished', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
const target = join(fx.userHome, 'plugins', 'old');
mkdirSync(target, { recursive: true });
applyFleetLaunchComposition(
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
);
rmSync(target, { recursive: true });
writeFileSync(
join(fx.agentDir, 'profile.json'),
'{"schema":1,"harness":"claude","plugins":[]}\n',
);
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
applyFleetLaunchComposition(plan);
expect(existsSync(join(fx.agentDir, '.claude', 'plugins', 'old'))).toBe(false);
});
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');
@@ -665,82 +581,10 @@ describe('managed plugin and skill links', () => {
expect(() =>
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
).toThrowError(/not an exact managed class/);
).toThrowError(/escapes an approved seat\/store root/);
expect(readFileSync(manifest, 'utf8')).toContain(crossSeat);
});
it.each([
[
'nested managed link',
(fx: ReturnType<typeof fixture>) =>
[
join(fx.agentDir, '.claude', 'plugins', 'nested', 'keep'),
join(fx.userHome, 'plugins', 'keep'),
] as const,
],
[
'store root target',
(fx: ReturnType<typeof fixture>) =>
[join(fx.agentDir, '.claude', 'plugins', 'keep'), join(fx.userHome, 'plugins')] as const,
],
[
'plugin to skill cross-class',
(fx: ReturnType<typeof fixture>) =>
[
join(fx.agentDir, '.claude', 'plugins', 'keep'),
join(fx.userHome, 'skills', 'keep'),
] as const,
],
[
'out-of-auth credential',
(fx: ReturnType<typeof fixture>) =>
[
join(fx.agentDir, '.claude', '.credentials.json'),
join(fx.userHome, 'auth', 'claude', 'other', '.credentials.json'),
] as const,
],
] as const)(
'rejects a manifest %s entry',
(_label, entry: (fx: ReturnType<typeof fixture>) => readonly [string, string]) => {
const fx = fixture({ schema: 1, harness: 'claude' });
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
mkdirSync(join(fx.userHome, 'skills', 'keep'), { recursive: true });
mkdirSync(join(fx.userHome, 'auth', 'claude', 'other'), { recursive: true });
writeFileSync(join(fx.userHome, 'auth', 'claude', 'other', '.credentials.json'), '{}\n', {
mode: 0o600,
});
const seat = join(fx.agentDir, '.claude');
mkdirSync(seat, { recursive: true });
const [link, target] = entry(fx);
writeFileSync(
join(seat, '.mosaic-managed-links.json'),
JSON.stringify({ links: { [link]: target } }),
);
expect(() =>
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
).toThrow(/not an exact managed class/);
},
);
it('rejects a symlink-escaped central store manifest target', () => {
const fx = fixture({ schema: 1, harness: 'claude' });
const foreign = join(fx.root, 'foreign');
mkdirSync(foreign, { recursive: true });
mkdirSync(join(fx.userHome, 'plugins'), { recursive: true });
symlinkSync(foreign, join(fx.userHome, 'plugins', 'keep'), 'dir');
const seat = join(fx.agentDir, '.claude');
mkdirSync(seat, { recursive: true });
writeFileSync(
join(seat, '.mosaic-managed-links.json'),
JSON.stringify({
links: { [join(seat, 'plugins', 'keep')]: join(fx.userHome, 'plugins', 'keep') },
}),
);
expect(() =>
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
).toThrow(/not an exact managed class/);
});
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');
@@ -758,12 +602,14 @@ describe('managed plugin and skill links', () => {
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();
const seatHome = join(fx.agentDir, '.claude');
const link = join(seatHome, '.credentials.json');
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, '.credentials.json'), link, 'file');
symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
@@ -772,7 +618,7 @@ describe('managed plugin and skill links', () => {
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/,
);
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, '.credentials.json'));
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName));
});
it.each(['plugins', 'skills'] as const)(
@@ -798,12 +644,12 @@ describe('managed plugin and skill links', () => {
);
it('refuses an unrecorded mismatched credential symlink', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
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, '.credentials.json'), 'file');
symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
@@ -812,7 +658,7 @@ describe('managed plugin and skill links', () => {
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
/unrecorded or retargeted symlink/,
);
expect(readFileSync(join(seatHome, '.credentials.json'), 'utf8')).toBe('{}\n');
expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n');
});
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
@@ -899,124 +745,45 @@ describe('fleet launch command outcomes', () => {
['--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,
);
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
true,
);
// 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('drives register → apply → real launch through the seeded seat, not HOME', () => {
const fx = fixture({ schema: 1, harness: 'claude', env: {} });
const bin = join(fx.root, 'bin');
const helper = join(fx.systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const seatConfig = join(fx.agentDir, '.claude', '.claude.json');
mkdirSync(bin, { recursive: true });
mkdirSync(join(fx.systemHome, 'tools', '_scripts'), { recursive: true });
writeFileSync(join(fx.systemHome, 'AGENTS.md'), '# fixture\n');
writeFileSync(join(fx.systemHome, 'SOUL.md'), '# fixture\n');
writeFileSync(
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')),
);
writeFileSync(
helper,
readFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
),
{ mode: 0o700 },
);
chmodSync(helper, 0o700);
mkdirSync(join(fx.agentDir, '.claude'), { recursive: true });
writeFileSync(
join(fx.systemHome, 'runtime', 'claude', 'RUNTIME.md'),
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')),
);
const base = JSON.parse(
readFileSync(join(fx.systemHome, 'runtime', 'claude', 'settings.json'), 'utf8'),
);
writeFileSync(seatConfig, JSON.stringify(base), { mode: 0o600 });
for (const name of ['claude', 'python3']) {
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o700 });
chmodSync(join(bin, name), 0o700);
}
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 exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const oldPath = process.env['PATH'];
const oldHome = process.env['HOME'];
try {
process.env['PATH'] = `${bin}:${oldPath ?? ''}`;
process.env['HOME'] = join(fx.root, 'operator-home-empty');
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome });
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(
readFileSync(join(fx.systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'), 'utf8'),
).toContain('"runtime":"claude"');
} finally {
exit.mockRestore();
process.env['PATH'] = oldPath;
process.env['HOME'] = oldHome;
}
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('rejects the same register → apply → real launch route when only HOME is seeded', () => {
const fx = fixture({ schema: 1, harness: 'claude', env: {} });
const bin = join(fx.root, 'bin');
const helper = join(fx.systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
const home = join(fx.root, 'operator-home');
mkdirSync(bin, { recursive: true });
mkdirSync(join(fx.systemHome, 'tools', '_scripts'), { recursive: true });
writeFileSync(join(fx.systemHome, 'AGENTS.md'), '# fixture\n');
writeFileSync(join(fx.systemHome, 'SOUL.md'), '# fixture\n');
writeFileSync(
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')),
);
writeFileSync(
helper,
readFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
),
{ mode: 0o700 },
);
chmodSync(helper, 0o700);
mkdirSync(home, { recursive: true });
writeFileSync(
join(home, '.claude.json'),
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json')),
);
writeFileSync(join(bin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o700 });
chmodSync(join(bin, 'claude'), 0o700);
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 exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const priorExitCode = process.exitCode;
const oldPath = process.env['PATH'];
const oldHome = process.env['HOME'];
try {
process.exitCode = 0;
process.env['PATH'] = `${bin}:${oldPath ?? ''}`;
process.env['HOME'] = home;
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome });
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(process.exitCode).toBe(1);
expect(existsSync(join(fx.systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'))).toBe(
false,
);
} finally {
exit.mockRestore();
process.exitCode = priorExitCode;
process.env['PATH'] = oldPath;
process.env['HOME'] = oldHome;
}
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', () => {
@@ -1098,12 +865,13 @@ describe('dry-run composition', () => {
}
}
bundle: primary -> fred_example.com ([email protected])
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
symlinks:
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
declared env:
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/user/auth/claude/fred_example.com
MOSAIC_AGENT_NAME=fred
SEAT_FLAG=yes
argv: ["claude","--model","opus"]"
@@ -1118,6 +886,7 @@ describe('dry-run composition', () => {
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
readFileSync(plan.settings.output, 'utf8'),
);
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
expect(plan.credential.link).toBeUndefined();
expect(plan.credential.dir).toBe(fx.namedBundleDir);
});
});
@@ -1,8 +1,5 @@
import {
closeSync,
constants,
fchmodSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
@@ -26,7 +23,10 @@ import {
type RuntimeName,
} from './launch.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
import { assertNoSymlinkAncestors } from '../fleet/secure-file.js';
import {
CREDENTIAL_DIR_ENV as CREDENTIAL_DIR_ENV_BY_HARNESS,
CREDENTIAL_FILE_NAMES,
} from '../fleet/credential-sharing.js';
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [
@@ -45,20 +45,16 @@ const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREDENTIAL_FILES: Record<RuntimeName, string> = {
claude: '.credentials.json',
pi: 'auth.json',
codex: 'auth.json',
opencode: 'auth.json',
};
// Assignability here is what keeps CredentialHarness and RuntimeName from drifting apart.
const CREDENTIAL_FILES: Record<RuntimeName, string> = CREDENTIAL_FILE_NAMES;
const CREDENTIAL_DIR_ENV: Partial<Record<RuntimeName, string>> = CREDENTIAL_DIR_ENV_BY_HARNESS;
export type FleetLaunchErrorCode =
| 'SCHEMA_TOO_NEW'
| 'PROFILE_INVALID'
| 'AGENT_NOT_SCAFFOLDED'
| 'COMPOSITION_FAILED'
| 'FIRST_AUTH_REFUSAL'
| 'ROLLBACK_INTEGRITY';
| 'FIRST_AUTH_REFUSAL';
export class FleetLaunchError extends Error {
constructor(
@@ -130,8 +126,14 @@ export interface FleetLaunchComposition {
readonly display: string;
};
readonly credential: {
readonly link: string;
/**
* The seat-local managed link to the bundle credential. Absent for harnesses
* that reach the shared bundle by environment instead (see CREDENTIAL_DIR_ENV).
*/
readonly link?: string;
readonly target: string;
/** Resolved bundle directory holding the credential file. */
readonly dir: string;
};
readonly managedLinks: ManagedLinkState;
readonly installs: readonly PlannedLink[];
@@ -147,6 +149,7 @@ export interface FleetLaunchCommandDeps {
args: string[],
declaredEnv: Readonly<Record<string, string>>,
context: FleetHarnessContext,
dangerous: boolean,
) => void;
}
@@ -315,9 +318,20 @@ function lstatIfPresent(path: string): Stats | undefined {
}
}
function assertRealDirectory(path: string, label: string): void {
function assertRealDirectory(path: string, label: string, absentHint?: string): void {
const info = lstatIfPresent(path);
if (!info?.isDirectory() || info.isSymbolicLink()) {
// Absent and wrong-shaped are different problems and want different words. A host that has
// simply never enrolled a bundle was being told its auth directory "must be a real,
// non-symlink directory", which reads as a tampering report rather than "log in first".
if (!info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
absentHint
? `${label} does not exist: ${path}${absentHint}`
: `${label} does not exist: ${path}`,
);
}
if (!info.isDirectory() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} must be a real, non-symlink directory: ${path}`,
@@ -335,6 +349,22 @@ function assertContained(root: string, candidate: string, label: string): void {
}
}
/**
* Proves the framework is installed and knows this harness. This is the check the required
* system settings layer used to stand in for, moved to the thing that is actually always
* present: the runtime directory. A missing one means an uninstalled framework or a harness
* the install does not carry, and both are worth failing on before a seat is composed.
*/
function assertHarnessRuntimeInstalled(systemHome: string, harness: string): void {
const runtimeDir = join(systemHome, 'runtime', harness);
if (!lstatIfPresent(runtimeDir)?.isDirectory()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`harness runtime is not installed: ${runtimeDir} — install the Mosaic framework, or check the harness name`,
);
}
}
function readSettingsLayer(
name: SettingsLayer['name'],
path: string,
@@ -400,9 +430,10 @@ function resolveCredential(
assertRealDirectory(userHome, 'user Mosaic root');
const realUserHome = realpathSync(userHome);
const authDirectory = join(userHome, 'auth');
assertRealDirectory(authDirectory, 'auth directory');
const enrollHint = `no auth bundle has been enrolled yet — run: mosaic auth enroll --harness ${profile.harness} --bundle ${profile.bundle}`;
assertRealDirectory(authDirectory, 'auth directory', enrollHint);
const authRoot = join(authDirectory, profile.harness);
assertRealDirectory(authRoot, `${profile.harness} auth root`);
assertRealDirectory(authRoot, `${profile.harness} auth root`, enrollHint);
const resolvedAuthRoot = realpathSync(authRoot);
assertContained(realUserHome, resolvedAuthRoot, `${profile.harness} auth root`);
@@ -453,6 +484,10 @@ function resolveCredential(
`first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`,
);
}
// Environment-shared harnesses never read the seat-local path, so no link is
// planned for it. A leftover link from an earlier scaffold is inert: the harness
// resolves its credential directory from the environment instead.
const sharesByEnv = CREDENTIAL_DIR_ENV[profile.harness] !== undefined;
const resolvedName = basename(resolvedBundleDir);
const email = accountEmail(resolvedBundleDir);
@@ -467,7 +502,11 @@ function resolveCredential(
...(email === undefined ? {} : { email }),
display,
},
credential: { link: credentialLink, target: resolvedCredential },
credential: {
...(sharesByEnv ? {} : { link: credentialLink }),
target: resolvedCredential,
dir: resolvedBundleDir,
},
};
}
@@ -597,65 +636,29 @@ function readManagedLinkState(
);
}
const links = new Map<string, string>();
const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const expectedCredential = join(
userHome,
'auth',
profile.harness,
profile.bundle,
CREDENTIAL_FILES[profile.harness],
);
const stores: Array<readonly ['plugins' | 'skills', string, string]> = [
['plugins', join(seatHome, 'plugins'), join(userHome, 'plugins')],
['skills', join(seatHome, 'skills'), join(userHome, 'skills')],
];
const requireRealDirectStoreDirectory = (target: string, root: string): void => {
const canonicalRoot = realpathSync(root);
const lexicalTarget = resolve(target);
if (dirname(lexicalTarget) !== canonicalRoot) {
throw new Error('target is not a direct central-store entry');
}
const targetInfo = lstatIfPresent(target);
// A vanished target remains safe to prune only because the link itself is
// still an exact direct managed class. Retained/install targets revalidate below.
if (!targetInfo) return;
if (targetInfo.isSymbolicLink()) throw new Error('target is a symbolic link');
assertNoSymlinkAncestors(target);
const canonicalTarget = realpathSync(target);
assertContained(canonicalRoot, canonicalTarget, 'managed link manifest target');
if (dirname(canonicalTarget) !== canonicalRoot || !lstatSync(canonicalTarget).isDirectory()) {
throw new Error('target is not a direct real central-store directory');
}
};
for (const [link, target] of Object.entries(parsed['links'])) {
try {
if (
typeof target !== 'string' ||
!isAbsolute(link) ||
!isAbsolute(target) ||
links.has(link)
) {
throw new Error('entry is not an absolute unique path pair');
}
if (link === credential) {
if (lstatSync(target).isSymbolicLink())
throw new Error('credential target is a symbolic link');
assertNoSymlinkAncestors(target);
if (realpathSync(target) !== realpathSync(expectedCredential)) {
throw new Error('credential target is not the active resolved credential');
}
} else {
const store = stores.find(([, seatRoot]) => dirname(link) === seatRoot);
if (!store || basename(link) === '.' || basename(link) === '..') {
throw new Error('link is not a direct managed plugin or skill entry');
}
requireRealDirectStoreDirectory(target, store[2]);
}
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`managed link manifest entry is not an exact managed class: ${detail}`,
`managed link manifest has invalid entry: ${path}`,
);
}
const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const pluginRoot = join(seatHome, 'plugins');
const skillRoot = join(seatHome, 'skills');
const authRoot = join(userHome, 'auth', profile.harness);
const inRoot = (root: string, candidate: string): boolean => {
const rel = relative(resolve(root), resolve(candidate));
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
};
const valid =
(link === credential && inRoot(authRoot, target)) ||
(inRoot(pluginRoot, link) && inRoot(join(userHome, 'plugins'), target)) ||
(inRoot(skillRoot, link) && inRoot(join(userHome, 'skills'), target));
if (!valid) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`managed link manifest entry escapes an approved seat/store root: ${path}`,
);
}
links.set(link, target);
@@ -698,7 +701,10 @@ function buildArgv(
passthrough: string[],
): string[] {
const argv: string[] = [profile.harness];
if (profile.model) argv.push('--model', profile.model);
// A caller-supplied --model replaces the profile's rather than being appended after
// it. The fleet roster carries a model per seat and is the surface operators edit, so
// it has to win; emitting both flags would leave that to each harness's arg parser.
if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model);
if (profile.harness === 'pi') {
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
}
@@ -757,11 +763,16 @@ export function resolveFleetLaunchComposition(
}
const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json');
assertContained(agentDir, overlayPath, 'agent overlay');
// The framework ships a runtime directory per harness but a settings.json only where it
// has settings to state -- as of 0.0.49 that is claude alone, so requiring the file made
// every pi, codex and opencode seat unlaunchable on a clean install. The install is what
// has to be present; an absent base layer just means the harness has no system settings.
assertHarnessRuntimeInstalled(roots.systemHome, profile.harness);
const layers: SettingsLayer[] = [
readSettingsLayer(
'system',
join(roots.systemHome, 'runtime', profile.harness, 'settings.json'),
true,
false,
),
readSettingsLayer(
'user',
@@ -795,9 +806,15 @@ export function resolveFleetLaunchComposition(
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
const credentialDirEnvName = CREDENTIAL_DIR_ENV[profile.harness];
const env: Record<string, string> = {
...profile.env,
[homeEnvName[profile.harness]]: seatHome,
// Only ever an absolute bundle path. Claude reads an empty value as ~/.claude,
// which is the operator's own account, so an empty value is never exported.
...(credentialDirEnvName === undefined
? {}
: { [credentialDirEnvName]: credential.credential.dir }),
MOSAIC_AGENT_NAME: name,
};
return {
@@ -876,372 +893,37 @@ function canonicalJson(value: unknown): unknown {
);
}
interface PathSnapshot {
readonly path: string;
readonly kind: 'absent' | 'file' | 'symlink' | 'directory';
readonly mode?: number;
readonly content?: Buffer;
readonly target?: string;
readonly dev?: number | bigint;
readonly ino?: number | bigint;
}
interface RollbackAnchor {
readonly root: string;
readonly descriptor: number;
readonly directories: ReadonlyMap<string, Pick<PathSnapshot, 'dev' | 'ino'>>;
}
function sameIdentity(
expected: Pick<PathSnapshot, 'dev' | 'ino'>,
actual: { dev: number | bigint; ino: number | bigint },
): boolean {
return expected.dev === actual.dev && expected.ino === actual.ino;
}
function snapshotPath(path: string): PathSnapshot {
const info = lstatIfPresent(path);
if (!info) return { path, kind: 'absent' };
if (info.isSymbolicLink())
return {
path,
kind: 'symlink',
mode: info.mode,
target: readlinkSync(path),
dev: info.dev,
ino: info.ino,
};
if (info.isFile())
return {
path,
kind: 'file',
mode: info.mode,
content: readFileSync(path),
dev: info.dev,
ino: info.ino,
};
if (info.isDirectory())
return { path, kind: 'directory', mode: info.mode, dev: info.dev, ino: info.ino };
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`cannot transactionally snapshot special object: ${path}`,
);
}
function rollbackIntegrity(message: string): FleetLaunchError {
return new FleetLaunchError('ROLLBACK_INTEGRITY', `rollback integrity refusal: ${message}`);
}
function descriptorPath(descriptor: number, child?: string): string {
return child === undefined
? `/proc/self/fd/${descriptor}`
: `/proc/self/fd/${descriptor}/${child}`;
}
function openRollbackAnchor(root: string, snapshots: readonly PathSnapshot[]): RollbackAnchor {
if (process.platform !== 'linux')
throw rollbackIntegrity('descriptor-relative rollback requires Linux');
const rootInfo = lstatSync(root);
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
throw rollbackIntegrity(`rollback root is not a real directory: ${root}`);
}
const descriptor = openSync(
root,
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
const opened = fstatSync(descriptor);
if (!sameIdentity(rootInfo, opened)) {
closeSync(descriptor);
throw rollbackIntegrity(`rollback root changed while opening: ${root}`);
}
const directories = new Map<string, Pick<PathSnapshot, 'dev' | 'ino'>>();
directories.set(root, { dev: rootInfo.dev, ino: rootInfo.ino });
for (const snapshot of snapshots) {
if (snapshot.kind === 'directory') directories.set(snapshot.path, snapshot);
}
return { root, descriptor, directories };
}
function rollbackComponents(anchor: RollbackAnchor, path: string): string[] {
const rel = relative(anchor.root, path);
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw rollbackIntegrity(`rollback path escapes its pinned root: ${path}`);
}
return rel.split(sep).filter(Boolean);
}
function openPinnedRollbackParent(
anchor: RollbackAnchor,
path: string,
): { descriptor: number; close: readonly number[]; name: string } {
const components = rollbackComponents(anchor, path);
const name = components.pop();
if (!name) throw rollbackIntegrity(`rollback path lacks a final component: ${path}`);
const rootStat = fstatSync(anchor.descriptor);
const expectedRoot = anchor.directories.get(anchor.root)!;
if (!sameIdentity(expectedRoot, rootStat))
throw rollbackIntegrity('pinned rollback root identity changed');
let parent = anchor.descriptor;
const close: number[] = [];
let cursor = anchor.root;
try {
for (const component of components) {
cursor = join(cursor, component);
const expected = anchor.directories.get(cursor);
if (!expected)
throw rollbackIntegrity(`rollback ancestor was not present at snapshot: ${cursor}`);
const child = openSync(
descriptorPath(parent, component),
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
close.push(child);
const actual = fstatSync(child);
if (!sameIdentity(expected, actual)) {
throw rollbackIntegrity(`rollback ancestor identity changed: ${cursor}`);
}
parent = child;
}
return { descriptor: parent, close, name };
} catch (error) {
for (const descriptor of close.reverse()) closeSync(descriptor);
throw error;
}
}
function closeRollbackParents(descriptors: readonly number[]): void {
for (const descriptor of [...descriptors].reverse()) closeSync(descriptor);
}
function removePinnedPath(
anchor: RollbackAnchor,
path: string,
expectedCreated?: PathSnapshot,
): void {
const parent = openPinnedRollbackParent(anchor, path);
try {
const pinned = descriptorPath(parent.descriptor, parent.name);
const current = lstatIfPresent(pinned);
if (current && expectedCreated && !sameIdentity(expectedCreated, current)) {
throw rollbackIntegrity(`rollback-created path identity changed: ${path}`);
}
if (current) rmSync(pinned, { recursive: current.isDirectory(), force: true });
if (lstatIfPresent(pinned)) throw rollbackIntegrity(`rollback removal did not remove ${path}`);
} finally {
closeRollbackParents(parent.close);
}
}
function writePinnedFile(anchor: RollbackAnchor, snapshot: PathSnapshot): void {
const parent = openPinnedRollbackParent(anchor, snapshot.path);
try {
const pinned = descriptorPath(parent.descriptor, parent.name);
const descriptor = openSync(
pinned,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
snapshot.mode,
);
try {
writeSync(descriptor, snapshot.content!);
fchmodSync(descriptor, snapshot.mode!);
} finally {
closeSync(descriptor);
}
const restored = lstatSync(pinned);
if (!restored.isFile() || restored.isSymbolicLink()) {
throw rollbackIntegrity(`rollback file restoration was redirected: ${snapshot.path}`);
}
} finally {
closeRollbackParents(parent.close);
}
}
function createPinnedSymlink(anchor: RollbackAnchor, snapshot: PathSnapshot): void {
const parent = openPinnedRollbackParent(anchor, snapshot.path);
try {
const pinned = descriptorPath(parent.descriptor, parent.name);
symlinkSync(snapshot.target!, pinned, 'file');
const restored = lstatSync(pinned);
if (!restored.isSymbolicLink() || readlinkSync(pinned) !== snapshot.target) {
throw rollbackIntegrity(`rollback symlink restoration was redirected: ${snapshot.path}`);
}
} finally {
closeRollbackParents(parent.close);
}
}
function chmodPinnedDirectory(anchor: RollbackAnchor, snapshot: PathSnapshot): void {
const parent = openPinnedRollbackParent(anchor, snapshot.path);
try {
const descriptor = openSync(
descriptorPath(parent.descriptor, parent.name),
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
try {
const actual = fstatSync(descriptor);
if (!sameIdentity(snapshot, actual)) {
throw rollbackIntegrity(`rollback directory identity changed: ${snapshot.path}`);
}
fchmodSync(descriptor, snapshot.mode!);
} finally {
closeSync(descriptor);
}
} finally {
closeRollbackParents(parent.close);
}
}
function hasAbsentSnapshotAncestor(
snapshot: PathSnapshot,
snapshots: readonly PathSnapshot[],
): boolean {
return snapshots.some(
(ancestor) =>
ancestor.kind === 'absent' &&
ancestor.path !== snapshot.path &&
snapshot.path.startsWith(`${ancestor.path}${sep}`),
);
}
function writeRollbackRecovery(
anchor: RollbackAnchor,
originalError: unknown,
rollbackError: unknown,
snapshots: readonly PathSnapshot[],
): string {
const name = '.mosaic-fleet-launch-recovery.json';
const path = join(anchor.root, name);
const rootStat = fstatSync(anchor.descriptor);
const expectedRoot = anchor.directories.get(anchor.root)!;
if (!sameIdentity(expectedRoot, rootStat)) {
throw rollbackIntegrity(
'cannot safely retain recovery evidence: rollback root identity changed',
);
}
const descriptor = openSync(
descriptorPath(anchor.descriptor, name),
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
try {
writeSync(
descriptor,
`${JSON.stringify({
error: String(originalError),
rollbackError: String(rollbackError),
snapshots: snapshots.map(({ path: snapshotPath, kind, mode, target }) => ({
path: snapshotPath,
kind,
...(mode === undefined ? {} : { mode }),
...(target === undefined ? {} : { target }),
})),
})}\n`,
);
} finally {
closeSync(descriptor);
}
return path;
}
function captureCreatedPaths(
snapshots: readonly PathSnapshot[],
created: Map<string, PathSnapshot>,
extra: readonly string[] = [],
): void {
for (const path of [
...snapshots.filter((snapshot) => snapshot.kind === 'absent').map(({ path }) => path),
...extra,
]) {
if (created.has(path)) continue;
const current = snapshotPath(path);
if (current.kind !== 'absent') created.set(path, current);
}
}
function restoreSnapshots(
snapshots: readonly PathSnapshot[],
anchor: RollbackAnchor,
created: ReadonlyMap<string, PathSnapshot>,
): void {
const deepestFirst = [...snapshots].sort((a, b) => b.path.length - a.path.length);
for (const snapshot of deepestFirst) {
if (snapshot.kind === 'directory' || hasAbsentSnapshotAncestor(snapshot, snapshots)) continue;
removePinnedPath(anchor, snapshot.path, created.get(snapshot.path));
}
for (const snapshot of deepestFirst) {
if (hasAbsentSnapshotAncestor(snapshot, snapshots)) continue;
if (snapshot.kind === 'absent') continue;
if (snapshot.kind === 'directory') continue;
if (snapshot.kind === 'file') writePinnedFile(anchor, snapshot);
else createPinnedSymlink(anchor, snapshot);
}
for (const snapshot of [...snapshots].sort((a, b) => a.path.length - b.path.length)) {
if (snapshot.kind === 'directory') chmodPinnedDirectory(anchor, snapshot);
}
for (const snapshot of deepestFirst) {
if (snapshot.kind !== 'absent' || hasAbsentSnapshotAncestor(snapshot, snapshots)) continue;
removePinnedPath(anchor, snapshot.path, created.get(snapshot.path));
}
}
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
export function applyFleetLaunchComposition(
plan: FleetLaunchComposition,
injectFailure?: (seam: string) => void,
): void {
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
// All link-state checks must complete before the first filesystem mutation.
assertManagedLinkMutationAllowed(plan.credential.link, plan.credential.target, plan.managedLinks);
// This makes a late foreign/retargeted link refusal leave the seat untouched.
if (plan.credential.link !== undefined) {
assertManagedLinkMutationAllowed(
plan.credential.link,
plan.credential.target,
plan.managedLinks,
);
}
for (const path of plan.prune)
assertManagedLinkMutationAllowed(path, undefined, plan.managedLinks);
for (const install of plan.installs)
for (const install of plan.installs) {
assertManagedLinkMutationAllowed(install.link, install.target, plan.managedLinks);
}
const tracked = [
...new Set([
plan.seatHome,
plan.settings.output,
plan.settings.snapshot,
plan.managedLinks.path,
plan.credential.link,
...plan.prune,
...plan.installs.map((install) => install.link),
...[plan.credential.link, ...plan.prune, ...plan.installs.map((install) => install.link)].map(
dirname,
),
]),
];
const snapshots = tracked.map(snapshotPath);
const rollbackAnchor = openRollbackAnchor(plan.agentDir, snapshots);
const manifestLinksBefore = new Map(plan.managedLinks.links);
const createdPaths = new Map<string, PathSnapshot>();
let preparedManifest: PreparedManagedLinkManifest | undefined;
let descriptorOpen = false;
mkdirSync(plan.seatHome, { recursive: true });
const preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
let descriptorOpen = true;
let committedManifest = false;
try {
mkdirSync(plan.seatHome, { recursive: true, mode: 0o700 });
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('mkdir-seat');
preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
descriptorOpen = true;
captureCreatedPaths(snapshots, createdPaths, [preparedManifest.path]);
injectFailure?.('prepare-manifest');
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('write-settings');
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('write-snapshot');
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('credential-link');
if (plan.credential.link !== undefined) {
ensureSymlink(plan.credential.link, plan.credential.target, plan.managedLinks);
}
for (const path of plan.prune) {
const info = lstatIfPresent(path);
if (info) {
if (!info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object replaced managed symlink before prune: ${path}`,
);
}
if (info?.isSymbolicLink()) {
const current = currentLinkTarget(path);
if (plan.managedLinks.links.get(path) !== current) {
throw new FleetLaunchError(
@@ -1250,61 +932,26 @@ export function applyFleetLaunchComposition(
);
}
rmSync(path);
plan.managedLinks.links.delete(path);
} else if (info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object replaced managed symlink before prune: ${path}`,
);
}
plan.managedLinks.links.delete(path);
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('prune-link');
}
for (const install of plan.installs) {
ensureSymlink(install.link, install.target, plan.managedLinks);
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('install-link');
}
writeManagedLinkState(plan.managedLinks, preparedManifest);
captureCreatedPaths(snapshots, createdPaths);
injectFailure?.('write-manifest');
closeSync(preparedManifest.descriptor);
descriptorOpen = false;
injectFailure?.('close-manifest');
renameSync(preparedManifest.path, plan.managedLinks.path);
injectFailure?.('rename-manifest');
} catch (error: unknown) {
try {
if (descriptorOpen && preparedManifest) closeSync(preparedManifest.descriptor);
if (
preparedManifest &&
!hasAbsentSnapshotAncestor({ path: preparedManifest.path, kind: 'absent' }, snapshots)
) {
removePinnedPath(rollbackAnchor, preparedManifest.path);
}
restoreSnapshots(snapshots, rollbackAnchor, createdPaths);
plan.managedLinks.links.clear();
for (const [link, target] of manifestLinksBefore) plan.managedLinks.links.set(link, target);
} catch (rollbackError: unknown) {
let recovery = join(plan.agentDir, '.mosaic-fleet-launch-recovery.json');
try {
recovery = writeRollbackRecovery(rollbackAnchor, error, rollbackError, snapshots);
} catch {
// The pinned root was unavailable; preserve the original rollback-integrity refusal.
}
throw new FleetLaunchError(
'ROLLBACK_INTEGRITY',
`launch composition rollback failed after ${String(error)}; recovery evidence: ${recovery}`,
);
}
throw error;
committedManifest = true;
} finally {
try {
closeSync(rollbackAnchor.descriptor);
} catch {
// The anchor only gates rollback and must not hide the launch result.
}
if (descriptorOpen && preparedManifest) {
try {
closeSync(preparedManifest.descriptor);
} catch {
// The catch path may already have closed it before restoring snapshots.
}
if (!committedManifest) {
if (descriptorOpen) closeSync(preparedManifest.descriptor);
rmSync(preparedManifest.path, { force: true });
}
}
}
@@ -1326,8 +973,13 @@ export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
lines.push('merged settings:');
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
lines.push(`bundle: ${plan.bundle.display}`);
lines.push(`credential: ${plan.credential.target}`);
lines.push('symlinks:');
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
// Environment-shared harnesses have no credential symlink; the exported
// credential-directory variable below is what points them at the bundle.
if (plan.credential.link !== undefined) {
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
}
for (const install of plan.installs) {
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
}
@@ -1348,33 +1000,43 @@ export function registerFleetLaunchCommand(
.command('launch <name>')
.description('Compose and launch one per-agent harness home')
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
.option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
.action(
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
// Dangerous mode is the caller's to ask for, not the seat's to assume. An
// unattended tmux seat needs it -- a permission prompt with nobody at the pane
// is a hung agent -- so the roster launcher passes the flag explicitly and it
// stays visible in the process table rather than hiding in a profile default.
launcher(
plan.profile.harness,
plan.argv.slice(1),
plan.env,
{ agentDir: plan.agentDir, mosaicHome: plan.systemHome },
opts.dangerous === true,
);
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, {
agentDir: plan.agentDir,
mosaicHome: plan.systemHome,
});
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
});
},
);
}
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'adopt',
'agent',
'apply',
'backlog',
+6
View File
@@ -42,6 +42,7 @@ import {
registerFleetAgentScaffoldCommand,
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
@@ -2080,6 +2081,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
...(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);
@@ -201,34 +201,10 @@ describe('guardClaudeSettingsWiring', () => {
// red-first). Instead it proves the wiring is genuinely delegated: the
// no-deps call must agree with an explicit call to the same real
// predicate, not some other hardcoded value.
//
// The predicate is observed on BOTH sides of the guard call because it is
// not deterministic under load. `defaultCapabilityProbe` executes
// `dist/cli.js` out-of-process with a 2s timeout; in a full-package run
// with every spec file scheduled at once, one observation can beat that
// timeout while the next does not. Comparing a single before-observation
// against the guard's own internal call therefore failed intermittently
// in the full suite while passing in isolation — the test was reporting
// machine load as a wiring defect.
//
// So the guard call is bracketed by two observations and only a pair that
// agrees is used as ground truth. A disagreeing pair proves the
// environment moved mid-test rather than that the wiring is wrong, and is
// retried. This does not weaken the assertion: a delegation failure is
// stable and survives every attempt, while load noise does not. Three
// attempts that never hold still is itself a failure, so an environment
// that is permanently unstable is reported rather than skipped.
let before = false;
let outcome: ReturnType<typeof guardClaudeSettingsWiring> | undefined;
for (let attempt = 0; attempt < 3 && outcome === undefined; attempt += 1) {
before = leaseEnforcementActivatable();
const candidate = guardClaudeSettingsWiring(fixtureJson());
if (leaseEnforcementActivatable() === before) outcome = candidate;
}
expect(outcome, 'activation probe never held still across three attempts').toBeDefined();
if (outcome === undefined) return;
const reallyActivatable = leaseEnforcementActivatable();
const outcome = guardClaudeSettingsWiring(fixtureJson());
if (before) {
if (reallyActivatable) {
expect(outcome.exitCode).toBe(0);
expect(outcome.wired).toBe(true);
} else {
@@ -1,338 +0,0 @@
/**
* What the launched runtime actually receives in its environment.
*
* These tests do not inspect `minimalLaunchEnv` and do not use a test seam. They run the real
* `fleet launch` route -- register, apply, compose, lease gate, exec -- with a shim standing in
* for the runtime binary, and the shim dumps its own environment. So the thing under test is the
* environment at the far end of the whole chain, after `launch-runtime.py` has added the lease
* variables, rather than the object the launcher believed it was building. The two differ, and
* only the first one matters.
*
* The property being defended: an operator's environment is large, grows over time, and contains
* names that make a child execute code before its first instruction (`BASH_ENV`, `PYTHONSTARTUP`,
* `NODE_OPTIONS`, `LD_PRELOAD`) as well as credentials for accounts the seat is deliberately not
* pegged to. A composed seat must receive a closed set of names, and "closed" is only true if
* something measures it.
*/
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetLaunchCommand } from './fleet-launch-command.js';
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
/**
* Names permitted to reach a composed seat, written out rather than derived from the launcher.
*
* Deriving it would make the test agree with the code by construction and detect nothing. The
* cost of a literal list is that adding a variable means editing this file, which is the point:
* a new name in a seat's environment should be a decision someone made, not a side effect.
*
* PWD, SHLVL and `_` are absent because the shim's own shell sets them after exec; they are
* filtered at the measurement site, not permitted here.
*/
const PERMITTED_CHILD_ENV = new Set([
// inherited from the operator by the launcher's allowlist
'PATH',
'HOME',
'USER',
'LOGNAME',
'SHELL',
'TERM',
'COLORTERM',
'TMPDIR',
'XDG_RUNTIME_DIR',
// fixed by the launcher
'LANG',
'LC_ALL',
// declared by the seat profile and composition
'CLAUDE_CONFIG_DIR',
'MOSAIC_AGENT_NAME',
'SEAT_FLAG',
// minted per launch for ledger correlation
'MOSAIC_LAUNCH_ID',
// added by the lease gate in launch-runtime.py
'MOSAIC_LEASE_BROKER_SOCKET',
'MOSAIC_LEASE_GENERATION_FILE',
'MOSAIC_LEASE_RUNTIME',
'MOSAIC_LEASE_SESSION_ID',
'MOSAIC_RECEIPT_OBSERVER_SOCKET',
'MOSAIC_RUNTIME_GENERATION',
]);
/**
* Operator environment that must not survive composition.
*
* Three classes, all real. Loader hooks run attacker-chosen code inside the runtime before it
* does anything (`BASH_ENV`/`ENV` for shells, `PYTHON*` for the interpreter that runs the lease
* gate, `NODE_*` for the runtime itself, `LD_*` for every dynamically linked binary in the tree).
* Package configuration redirects where code is fetched from. Provider credentials belong to the
* operator's accounts, and a seat pegged to its own auth bundle that can still read them is not
* pegged to anything.
*
* The values are distinctive so the diagnostics check below can search for them by content.
*/
const OPERATOR_ONLY_ENV: Record<string, string> = {
BASH_ENV: '/poison-a1b2/bash_env.sh',
ENV: '/poison-a1b2/env.sh',
PYTHONPATH: '/poison-a1b2/pythonpath',
PYTHONSTARTUP: '/poison-a1b2/pythonstartup.py',
NODE_OPTIONS: '--require /poison-a1b2/preload.js',
NODE_PATH: '/poison-a1b2/node_path',
NPM_CONFIG_PREFIX: '/poison-a1b2/npm_prefix',
NPM_CONFIG_REGISTRY: 'https://poison-a1b2.example.invalid/',
LD_PRELOAD: '/poison-a1b2/preload.so',
LD_LIBRARY_PATH: '/poison-a1b2/lib',
ANTHROPIC_API_KEY: 'poison-a1b2-anthropic-key',
OPENAI_API_KEY: 'poison-a1b2-openai-key',
GH_TOKEN: 'poison-a1b2-github-token',
GITEA_TOKEN: 'poison-a1b2-gitea-token',
AWS_SECRET_ACCESS_KEY: 'poison-a1b2-aws-secret',
SSH_AUTH_SOCK: '/poison-a1b2/ssh-agent.sock',
};
interface Fixture {
root: string;
systemHome: string;
userHome: string;
agentDir: string;
seatHome: string;
bin: string;
dump: string;
ledger: string;
}
function fixture(profileEnv: Record<string, string>, extraProfile: Record<string, unknown> = {}) {
const root = mkdtempSync(join(tmpdir(), 'mosaic-child-env-'));
roots.push(root);
const systemHome = join(root, 'system');
const userHome = join(root, 'user');
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
const seatHome = join(agentDir, '.claude');
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
const bin = join(root, 'bin');
const dump = join(root, 'child-env.txt');
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
mkdirSync(join(systemHome, 'tools', '_scripts'), { recursive: true });
mkdirSync(seatHome, { recursive: true });
mkdirSync(namedBundleDir, { recursive: true });
mkdirSync(bin, { recursive: true });
writeFileSync(
join(agentDir, 'profile.json'),
`${JSON.stringify({ schema: 1, harness: 'claude', env: profileEnv, ...extraProfile }, null, 2)}\n`,
);
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
writeFileSync(
join(namedBundleDir, 'account.json'),
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
);
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
writeFileSync(join(systemHome, 'AGENTS.md'), '# fixture\n');
writeFileSync(join(systemHome, 'SOUL.md'), '# fixture\n');
const frameworkSettings = readFileSync(
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
);
writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), frameworkSettings);
writeFileSync(
join(systemHome, 'runtime', 'claude', 'RUNTIME.md'),
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')),
);
const helper = join(systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
writeFileSync(
helper,
readFileSync(
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
),
{ mode: 0o700 },
);
chmodSync(helper, 0o700);
writeFileSync(join(seatHome, '.claude.json'), frameworkSettings.toString(), { mode: 0o600 });
// The shim records its own environment and exits. `claude` is the measurement point; `python3`
// is present only so a PATH lookup for it would succeed -- the lease gate deliberately takes the
// root-owned interpreter instead, so this copy should never run, and the assertions below do not
// depend on which one does.
for (const name of ['claude', 'python3']) {
const path = join(bin, name);
writeFileSync(
path,
`#!/usr/bin/env bash\nenv > ${JSON.stringify(`${dump}.${name}`)}\nexit 0\n`,
{
mode: 0o700,
},
);
chmodSync(path, 0o700);
}
return {
root,
systemHome,
userHome,
agentDir,
seatHome,
bin,
dump,
ledger: join(systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'),
} satisfies Fixture;
}
/** Run the real launch route with a controlled operator environment. */
function launch(fx: Fixture, operatorEnv: Record<string, string>): Map<string, string> {
const program = new Command().exitOverride();
const fleet = program.command('fleet');
// The launcher execs and then exits; the fixture runtime returns instead, so the exit is the
// normal end of this route rather than a failure.
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const saved = new Map<string, string | undefined>();
const set = (name: string, value: string): void => {
saved.set(name, process.env[name]);
process.env[name] = value;
};
try {
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) set(name, value);
for (const [name, value] of Object.entries(operatorEnv)) set(name, value);
saved.set('PATH', process.env['PATH']);
process.env['PATH'] = `${fx.bin}:${process.env['PATH'] ?? ''}`;
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome });
try {
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
} catch {
// exec replaced by the mocked exit above
}
} finally {
exit.mockRestore();
for (const [name, value] of saved) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
const path = `${fx.dump}.claude`;
if (!existsSync(path)) throw new Error('runtime shim never ran; nothing was measured');
const env = new Map<string, string>();
for (const line of readFileSync(path, 'utf8').split('\n')) {
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line);
// Names the shim's own shell sets after exec, not names the launcher passed.
if (match && !['PWD', 'SHLVL', '_', 'OLDPWD'].includes(match[1]!))
env.set(match[1]!, match[2]!);
}
return env;
}
const OPERATOR_BASELINE: Record<string, string> = {
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
describe('composed seat child environment', () => {
it('hands the runtime no name outside the permitted set', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
const unexpected = [...env.keys()].filter((name) => !PERMITTED_CHILD_ENV.has(name)).sort();
expect(
unexpected,
'a name reached the seat that nobody declared; add it to PERMITTED_CHILD_ENV only if it belongs there',
).toEqual([]);
});
it('drops operator loader hooks, package configuration, and provider credentials', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
const survivors = Object.keys(OPERATOR_ONLY_ENV)
.filter((name) => env.has(name))
.sort();
expect(survivors, 'operator-only variables reached the seat').toEqual([]);
});
it('gives the runtime the declared seat values, not the operator equivalents', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
expect(env.get('CLAUDE_CONFIG_DIR')).toBe(fx.seatHome);
expect(env.get('MOSAIC_AGENT_NAME')).toBe('fred');
expect(env.get('SEAT_FLAG')).toBe('yes');
});
it('fixes the locale instead of inheriting the operator locale', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, {
LANG: 'de_DE.UTF-8',
LC_ALL: 'de_DE.UTF-8',
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
HOME: join(fx.root, 'operator-home'),
});
expect(env.get('LANG')).toBe('C.UTF-8');
expect(env.get('LC_ALL')).toBe('C.UTF-8');
});
it('lets a seat that needs a different locale declare one', () => {
const fx = fixture({ SEAT_FLAG: 'yes', LANG: 'de_DE.UTF-8', LC_ALL: 'de_DE.UTF-8' });
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
expect(env.get('LANG')).toBe('de_DE.UTF-8');
expect(env.get('LC_ALL')).toBe('de_DE.UTF-8');
});
it('inherits the allowlisted operator values it is supposed to inherit', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
expect(env.get('TERM')).toBe('xterm-256color');
expect(env.get('COLORTERM')).toBe('truecolor');
expect(env.get('PATH')).toContain(fx.bin);
});
it('mints a fresh launch id rather than forwarding the operator session id', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
const env = launch(fx, {
...OPERATOR_BASELINE,
HOME: join(fx.root, 'operator-home'),
MOSAIC_LAUNCH_ID: 'operator-session-launch-id',
});
const childId = env.get('MOSAIC_LAUNCH_ID');
expect(childId).toBeDefined();
expect(childId).not.toBe('operator-session-launch-id');
// The id is only useful if the ledger records the same one; correlation is by this value and
// never by pid, because exec makes the runtime a different process.
expect(readFileSync(fx.ledger, 'utf8')).toContain(`"launch_id":"${childId}"`);
});
it('keeps operator environment values out of the launch ledger', () => {
const fx = fixture({ SEAT_FLAG: 'yes' });
launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
// The ledger records env as present names only, by design. This checks the design holds for
// values as well as for the credential file it was written to protect.
const ledger = readFileSync(fx.ledger, 'utf8');
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) {
expect(ledger, `ledger leaked the value of ${name}`).not.toContain(value);
}
});
});
+51 -178
View File
@@ -20,7 +20,7 @@ import {
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
resolveExecutableFromPath,
launchFleetRuntimeForTest,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -122,6 +122,7 @@ describe('checkSequentialThinking', () => {
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'),
@@ -136,9 +137,13 @@ describe('checkSequentialThinking', () => {
{ mode: 0o600 },
);
vi.stubEnv('HOME', home);
const final = vi.fn((): never => {
throw new Error('final runtime boundary');
});
expect(() =>
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
).not.toThrow();
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
).toThrow('final runtime boundary');
expect(final).toHaveBeenCalledOnce();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
@@ -170,10 +175,15 @@ describe('checkSequentialThinking', () => {
},
}),
);
// 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(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
'process.exit called',
);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
@@ -194,6 +204,7 @@ describe('checkSequentialThinking', () => {
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'),
@@ -228,6 +239,7 @@ describe('checkSequentialThinking', () => {
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(
@@ -247,20 +259,7 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
[
'--runtime',
'claude',
'--claude-config-dir',
join(agentDir, '.claude'),
'--python-bin',
'/usr/bin/python3',
'--node-bin',
'/usr/bin/node',
'--npx-bin',
'/usr/bin/npx',
'--timeout-bin',
'/usr/bin/timeout',
],
['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{
env,
},
@@ -269,21 +268,7 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
[
'--check',
'--runtime',
'claude',
'--claude-config-dir',
join(agentDir, '.claude'),
'--python-bin',
'/usr/bin/python3',
'--node-bin',
'/usr/bin/node',
'--npx-bin',
'/usr/bin/npx',
'--timeout-bin',
'/usr/bin/timeout',
],
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
{ env },
).status,
).toBe(0);
@@ -302,6 +287,37 @@ describe('checkSequentialThinking', () => {
}
});
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-'));
@@ -625,146 +641,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
expect(mockExit).not.toHaveBeenCalled();
});
});
/**
* Executable resolution for fleet launches (AMD1213-D, D3/D6).
*
* The defect these cover: the launcher proved a runtime existed by running ambient
* `which`, then spawned the bare name and let the OS resolve it a second time against an
* ambient PATH. A directory prepended to PATH satisfied the probe and then supplied the
* binary that actually ran, so the check could pass without ever reading seat state.
*
* Every case below was run against the pre-change resolution first. The shim case is the
* one that matters -- under `which` + bare-name spawn it passes, because that is exactly
* the behaviour being removed.
*/
describe('resolveExecutableFromPath', () => {
let dir: string;
const bin = (root: string, name: string, mode = 0o755): string => {
const p = join(root, name);
writeFileSync(p, '#!/bin/sh\nexit 0\n');
chmodSync(p, mode);
return p;
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mosaic-exec-resolve-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('resolves a safe executable and reports its real path and identity', () => {
const safe = join(dir, 'safe');
mkdirSync(safe, { mode: 0o755 });
const target = bin(safe, 'codex');
const resolved = resolveExecutableFromPath('codex', safe);
expect(resolved.path).toBe(target);
expect(resolved.ino).toBeDefined();
});
it('refuses a world-writable binary planted on PATH', () => {
// The shim case. `which` reports this happily and a bare-name spawn runs it.
const shim = join(dir, 'shim');
mkdirSync(shim, { mode: 0o755 });
bin(shim, 'codex', 0o777);
expect(() => resolveExecutableFromPath('codex', shim)).toThrow(/writable by group or other/);
});
it('refuses a safe binary reached through a world-writable directory', () => {
// The binary itself is fine; anyone can swap it for one that is not.
const open = join(dir, 'open');
mkdirSync(open, { mode: 0o755 });
bin(open, 'codex');
// chmod after mkdir: the mode argument is masked by the process umask, so a
// directory created as 0o777 is really 0o755 and the case tests nothing.
chmodSync(open, 0o777);
expect(() => resolveExecutableFromPath('codex', open)).toThrow(/writable directory/);
});
it('does not fall through to a later PATH entry when the first match is unsafe', () => {
// Falling through would let a planted unsafe binary silently downgrade the search to
// whatever came after it, inverting the precedence PATH exists to express.
const shim = join(dir, 'first');
const good = join(dir, 'second');
mkdirSync(shim, { mode: 0o755 });
mkdirSync(good, { mode: 0o755 });
bin(shim, 'codex', 0o777);
const safeTarget = bin(good, 'codex');
let resolvedPath: string | undefined;
try {
resolvedPath = resolveExecutableFromPath('codex', `${shim}:${good}`).path;
} catch {
resolvedPath = undefined;
}
expect(resolvedPath).not.toBe(safeTarget);
});
it('ignores a relative PATH entry', () => {
// A relative entry resolves against the current directory, so what it names depends
// on where the launcher happened to be started.
expect(() => resolveExecutableFromPath('codex', '.:relative/bin')).toThrow(/not found/);
});
it('follows a symlink and validates the real file behind it', () => {
const safe = join(dir, 'real');
const linkDir = join(dir, 'links');
mkdirSync(safe, { mode: 0o755 });
mkdirSync(linkDir, { mode: 0o755 });
const target = bin(safe, 'codex-real');
symlinkSync(target, join(linkDir, 'codex'));
expect(resolveExecutableFromPath('codex', linkDir).path).toBe(target);
});
it('refuses a symlink whose real target is unsafe', () => {
const open = join(dir, 'openreal');
const linkDir = join(dir, 'links2');
mkdirSync(open, { mode: 0o755 });
mkdirSync(linkDir, { mode: 0o755 });
const target = bin(open, 'codex-real', 0o777);
symlinkSync(target, join(linkDir, 'codex'));
expect(() => resolveExecutableFromPath('codex', linkDir)).toThrow(/writable by group or other/);
});
it('refuses a non-executable file', () => {
const safe = join(dir, 'noexec');
mkdirSync(safe, { mode: 0o755 });
bin(safe, 'codex', 0o644);
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not executable/);
});
it('refuses a directory that merely shares the name', () => {
const safe = join(dir, 'dirname');
mkdirSync(join(safe, 'codex'), { recursive: true, mode: 0o755 });
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not a regular file/);
});
it('refuses a name that is a path rather than a bare command', () => {
expect(() => resolveExecutableFromPath('../evil', dir)).toThrow(/bare command name/);
});
it('refuses when no PATH was declared', () => {
expect(() => resolveExecutableFromPath('codex', undefined)).toThrow(/no PATH was declared/);
expect(() => resolveExecutableFromPath('codex', '')).toThrow(/no PATH was declared/);
});
it('reports not-found rather than resolving something else', () => {
const empty = join(dir, 'empty');
mkdirSync(empty, { mode: 0o755 });
expect(() => resolveExecutableFromPath('codex', empty)).toThrow(
/not found on the declared PATH/,
);
});
});
+83 -313
View File
@@ -20,7 +20,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -140,18 +140,15 @@ function sha256Of(value: string | Buffer): string {
* so an unexpected digest here is a mechanically detectable red flag rather than
* a matter of judgement.
*/
function normativeFragmentDigests(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
): NormativeFragmentDigest[] {
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')],
['AGENTS.md', join(mosaicHome, 'AGENTS.md')],
['SOUL.md', join(mosaicHome, 'SOUL.md')],
['USER.md', join(mosaicHome, 'USER.md')],
['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')],
['TOOLS.md', join(mosaicHome, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')],
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
['USER.md', join(MOSAIC_HOME, 'USER.md')],
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
];
return candidates.map(([sourceId, path]) => {
try {
@@ -181,10 +178,7 @@ function recordLaunch(
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try {
const ledgerDir = fleet?.mosaicHome
? join(fleet.mosaicHome, 'fleet', 'run', 'sessions')
: LAUNCH_LEDGER_DIR;
mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
@@ -204,13 +198,13 @@ function recordLaunch(
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome),
normative_fragments: normativeFragmentDigests(runtime),
// names only — values are never recorded
mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
@@ -250,126 +244,22 @@ function checkRuntime(cmd: string): void {
}
}
/** An executable located once and pinned by the identity it had when validated. */
interface ResolvedExecutable {
readonly path: string;
readonly dev: number | bigint;
readonly ino: number | bigint;
}
function executableRefusal(name: string, detail: string): Error {
return new Error(`refusing to launch '${name}': ${detail}`);
}
/**
* Reject a directory whose contents someone else could swap under us.
*
* Group- or world-writable is the disqualifier, with the /tmp exception: a sticky
* directory is writable by design but only its owner may replace its entries, so it
* cannot be used to shadow one.
*/
function assertSafeAncestry(path: string, name: string, owner: number | undefined): void {
let cursor = dirname(path);
for (;;) {
const info = lstatSync(cursor);
if (!info.isDirectory() || info.isSymbolicLink()) {
throw executableRefusal(name, `path component is not a real directory: ${cursor}`);
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);
}
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) {
throw executableRefusal(name, `writable directory on the resolved path: ${cursor}`);
}
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
throw executableRefusal(
name,
`directory on the resolved path has a foreign owner: ${cursor}`,
);
}
const parent = dirname(cursor);
if (parent === cursor) return;
cursor = parent;
return;
}
}
/**
* Find one executable named `name`, searching only `searchPath`, and validate the object
* that search lands on.
*
* This exists because `which` answered a different question than the one the launcher
* needed. `which` reported that *something* by that name was reachable; the launcher then
* spawned the bare name and let the OS resolve it a second time, against an ambient PATH,
* at a later moment. Two independent resolutions of an attacker-influenced name, with a
* gap in between, is not a check -- a directory prepended to PATH satisfied the probe and
* then supplied the thing that actually ran. Resolving once here and executing the exact
* path returned is the whole point; callers must not go back to the name.
*
* Rules worth stating because each one is a hole if dropped:
*
* * A relative PATH entry is skipped. It resolves against the current directory, so
* what it names depends on where the launcher happened to be started.
* * The FIRST name match decides the outcome, and an unsafe first match is a refusal
* rather than a reason to keep looking. Falling through to a later entry would let a
* planted unsafe binary silently downgrade the search to whatever came after it,
* which inverts the precedence PATH is supposed to express.
* * A symlink is followed, and the real file it lands on is what gets validated and
* executed. Validating the link and executing the name would repeat the original bug
* one level down.
*/
export function resolveExecutableFromPath(
name: string,
searchPath: string | undefined,
): ResolvedExecutable {
if (name.includes('/')) {
throw executableRefusal(name, 'expected a bare command name, not a path');
}
if (searchPath === undefined || searchPath === '') {
throw executableRefusal(name, 'no PATH was declared for the launch');
}
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
for (const entry of searchPath.split(delimiter)) {
if (entry === '' || !isAbsolute(entry)) continue;
const candidate = join(entry, name);
if (!existsSync(candidate)) continue;
// First match wins, for good or ill. Everything below either returns or throws.
const real = realpathSync(candidate);
const info = lstatSync(real);
if (!info.isFile()) {
throw executableRefusal(name, `${real} is not a regular file`);
}
if ((info.mode & 0o111) === 0) {
throw executableRefusal(name, `${real} is not executable`);
}
if ((info.mode & 0o022) !== 0) {
throw executableRefusal(name, `${real} is writable by group or other`);
}
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
throw executableRefusal(name, `${real} is owned by neither the launching user nor root`);
}
assertSafeAncestry(real, name, owner);
return { path: real, dev: info.dev, ino: info.ino };
}
throw executableRefusal(name, `not found on the declared PATH`);
}
/**
* Re-confirm, immediately before spawning, that the path still names the object that was
* validated.
*
* This narrows the window between validation and exec; it does not close it. Closing it
* would mean executing a held descriptor, and there is no portable way to exec by
* descriptor from Node. The residual is a same-UID replacement landing inside the
* remaining window, which is the same accepted boundary already documented for the fleet
* helper. Stated rather than engineered around, so nobody reads this as a proof.
*/
function assertUnchangedSinceValidation(executable: ResolvedExecutable, name: string): void {
const now = lstatSync(executable.path);
if (now.dev !== executable.dev || now.ino !== executable.ino) {
throw executableRefusal(name, `${executable.path} was replaced after it was validated`);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -471,37 +361,16 @@ function printSettingsWarnings(audit: SettingsAudit): void {
);
}
interface TrustedCapability {
readonly path: string;
readonly content: Buffer;
readonly dev: number | bigint;
readonly ino: number | bigint;
}
/** The fleet helper accepts capabilities only from root-owned /usr/bin. */
function trustedCapability(name: string): TrustedCapability {
const candidate = join('/usr/bin', name);
let path: string;
try {
path = realpathSync(candidate);
if (!path.startsWith('/usr/')) throw new Error('resolved outside /usr');
const snapshot = readRegularFileSecure(path, {
root: '/',
executable: true,
maxBytes: 64 * 1024 * 1024,
});
const info = lstatSync(path);
if ((info.mode & 0o022) !== 0 || info.uid !== 0)
throw new Error('unsafe capability owner or mode');
return { path, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
} catch (error: unknown) {
throw new Error(
`required trusted fleet capability is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
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): TrustedCapability {
function trustedFleetHelper(mosaicHome: string): string {
const root = resolve(mosaicHome);
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
@@ -535,59 +404,39 @@ function trustedFleetHelper(mosaicHome: string): TrustedCapability {
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
);
}
const snapshot = readRegularFileSecure(checker, {
root,
executable: true,
maxBytes: 1024 * 1024,
});
return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
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.
if (!fleet?.mosaicHome) {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return;
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) process.exit(1);
return;
}
const helper = trustedFleetHelper(fleet.mosaicHome);
const bash = trustedCapability('bash');
const python = trustedCapability('python3');
const node = trustedCapability('node');
const npx = trustedCapability('npx');
const timeout = trustedCapability('timeout');
const checker = fleet?.mosaicHome
? trustedFleetHelper(fleet.mosaicHome)
: fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
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(
bash.path,
checker,
[
'-s',
'--',
'--check',
'--runtime',
runtime,
'--python-bin',
python.path,
'--node-bin',
node.path,
'--npx-bin',
npx.path,
'--timeout-bin',
timeout.path,
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
],
{
input: helper.content,
stdio: ['pipe', 'ignore', 'ignore'],
stdio: 'ignore',
env: {
HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'),
LANG: 'C.UTF-8',
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'] }),
@@ -602,10 +451,8 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
const repairArgs =
fleetClaudeConfig === undefined
? ''
: ` --claude-config-dir ${fleetClaudeConfig} --python-bin ${python.path} --node-bin ${node.path} --npx-bin ${npx.path} --timeout-bin ${timeout.path}`;
console.error(`[mosaic] Fix: ${helper.path} --runtime ${runtime}${repairArgs}`);
fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`;
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`);
process.exit(1);
}
}
@@ -1165,47 +1012,17 @@ function getMissionPrompt(): string {
}
interface RuntimeLaunchContext {
readonly mosaicHome?: string;
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;
}
/**
* Locale for a composed launch.
*
* A seat that inherits the operator's locale behaves differently depending on who happened to
* start it: locale selects message language, collation, and number and date formatting, so the
* same runtime doing the same work emits different text. That is a reproducibility problem for
* the seat and a correctness problem for anything parsing what it prints.
*
* C.UTF-8 rather than C: both are unambiguous, but plain C is ASCII and would mangle non-ASCII
* output, so pinning it would trade one defect for another. A seat that genuinely needs a
* different locale declares LANG or LC_ALL in its profile, and the declared value wins.
*/
const COMPOSED_LAUNCH_LOCALE = 'C.UTF-8';
/**
* The environment a composed (fleet) launch hands its child.
*
* Built from an empty object rather than by subtracting from `process.env`, so the set of names
* that reach the child is a closed list that has to be edited deliberately. An allowlist fails
* safe as the operator's environment grows; a denylist silently passes every variable nobody
* thought of, which is where `BASH_ENV`, `PYTHONSTARTUP`, `NODE_OPTIONS` and `LD_PRELOAD` live --
* names that execute attacker-chosen code inside a process that was otherwise fully validated.
*
* Locale is fixed rather than inherited (above). Everything else here is inherited because the
* child needs the operator's actual value: PATH is resolved and validated separately before use,
* and HOME remains the operator's -- see the residual recorded in the AMD1213-D scratchpad.
*/
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
LANG: COMPOSED_LAUNCH_LOCALE,
LC_ALL: COMPOSED_LAUNCH_LOCALE,
};
const env: NodeJS.ProcessEnv = {};
for (const name of [
'PATH',
'HOME',
@@ -1214,6 +1031,8 @@ function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.Pr
'SHELL',
'TERM',
'COLORTERM',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_RUNTIME_DIR',
]) {
@@ -1223,56 +1042,16 @@ function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.Pr
return { ...env, ...declared };
}
/**
* The PATH the launched child will actually receive.
*
* Resolution has to consult this exact value and not `process.env.PATH`. If the declared
* environment overrides PATH, validating against the launcher's own PATH would check one
* set of directories and hand the child a different set -- a check answering a question
* nobody asked.
*/
function launchSearchPath(
declared: Readonly<Record<string, string>> | undefined,
): string | undefined {
return declared?.['PATH'] ?? process.env['PATH'];
}
function launchRuntime(
runtime: RuntimeName,
args: string[],
yolo: boolean,
context: RuntimeLaunchContext = {},
): never {
const mosaicHome = context.mosaicHome ?? MOSAIC_HOME;
if (context.mosaicHome === undefined) {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
} else {
if (!existsSync(mosaicHome)) throw new Error(`Mosaic home not found: ${mosaicHome}`);
checkFile(join(mosaicHome, 'AGENTS.md'), 'AGENTS.md');
if (!existsSync(join(mosaicHome, 'SOUL.md'))) {
throw new Error(`SOUL.md not found: ${mosaicHome}`);
}
}
// A fleet launch resolves and validates the runtime binary here, once, and reuses that
// exact object below. `checkRuntime`'s ambient `which` stays on the operator path only:
// it proves reachability from the operator's own shell, which is the right question
// there and the wrong one for a seat. Kept in the same position in the sequence so a
// missing runtime still fails before the session lock is written.
let resolvedRuntime: ResolvedExecutable | undefined;
if (context.runtimeCheck) {
context.runtimeCheck(runtime);
} else if (context.fleet) {
try {
resolvedRuntime = resolveExecutableFromPath(runtime, launchSearchPath(context.declaredEnv));
} catch (error: unknown) {
console.error(`[mosaic] ERROR: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
} else {
checkRuntime(runtime);
}
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul(context.fleet);
(context.runtimeCheck ?? checkRuntime)(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
@@ -1281,7 +1060,7 @@ function launchRuntime(
checkResumableSession();
const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : '';
const missionPrompt = getMissionPrompt();
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
@@ -1313,7 +1092,7 @@ function launchRuntime(
const settingsAudit = auditClaudeSettings(context.fleet);
printSettingsWarnings(settingsAudit);
const prompt = composeContract('claude', mosaicHome, contractEnv);
const prompt = buildRuntimePrompt('claude', contractEnv);
const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
@@ -1322,7 +1101,8 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
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'];
}
@@ -1348,7 +1128,7 @@ function launchRuntime(
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime(resolvedRuntime ?? 'codex', cliArgs, {
execRuntime('codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
@@ -1367,7 +1147,7 @@ function launchRuntime(
);
console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime(resolvedRuntime ?? 'opencode', args, {
execRuntime('opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
@@ -1378,7 +1158,7 @@ function launchRuntime(
}
case 'pi': {
const prompt = composeContract('pi', mosaicHome, contractEnv);
const prompt = buildRuntimePrompt('pi', contractEnv);
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension());
@@ -1424,13 +1204,8 @@ function execLeaseGatedRuntime(
): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
const dangerousArgs = dangerous ? ['--dangerous'] : [];
// The interpreter that starts the lease gate must not itself come off an ambient PATH:
// a shim here does not bypass one check, it replaces the process that enforces all of
// them. On the fleet path take the same root-owned capability the helper already
// requires. The operator path keeps name resolution, as it does everywhere else.
const interpreter = fleet ? trustedCapability('python3') : 'python3';
execRuntime(
interpreter,
'python3',
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv,
@@ -1447,44 +1222,39 @@ export function launchFleetRuntime(
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, {
mosaicHome: fleet.mosaicHome,
fleet,
declaredEnv,
runtimeCheck: () => undefined,
finalExecutor,
recordLaunch: false,
});
}
/**
* exec into the runtime, replacing the current process.
*
* `cmd` is either a bare name -- the operator path, where the OS resolves it against the
* caller's own PATH -- or an already-resolved executable, which is what every fleet
* launch passes. In the resolved case the exact validated path is spawned and its
* identity is re-confirmed first, so the thing that was checked is the thing that runs.
*/
function execRuntime(
cmd: string | ResolvedExecutable,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): void {
const label = typeof cmd === 'string' ? cmd : cmd.path;
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try {
let target: string;
if (typeof cmd === 'string') {
target = cmd;
} else {
assertUnchangedSinceValidation(cmd, cmd.path);
target = cmd.path;
}
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(target, args, {
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err);
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}
+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;
}
@@ -3,6 +3,8 @@ import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'n
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 {
@@ -54,7 +56,7 @@ export async function scaffoldFleetAgent(
const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
const agentDir = join(dataHome, 'fleet', 'agents', name);
const homeName = harness === 'claude' ? '.claude' : '.pi';
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
const credentialName = CREDENTIAL_FILE_NAMES[harness];
const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
const profile: Record<string, unknown> = {
schema: 1,
@@ -65,6 +67,7 @@ export async function scaffoldFleetAgent(
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) }],
@@ -73,10 +76,18 @@ export async function scaffoldFleetAgent(
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
{ type: 'file', content: identityBootstrap(name) },
],
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
...(sharesByEnv
? []
: ([[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }]] as [
string,
ExpectedFile,
][])),
[
join(homeName, '.mosaic-managed-links.json'),
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
{
type: 'file',
content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }),
},
],
];
if (harness === 'claude') {
@@ -87,7 +98,19 @@ export async function scaffoldFleetAgent(
}
const files = new Map<string, ExpectedFile>(entries);
const differences = await findDifferences(agentDir, files);
// 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',
@@ -123,9 +146,18 @@ 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 {
@@ -148,6 +180,7 @@ async function findDifferences(
]);
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);
@@ -156,10 +189,16 @@ async function findDifferences(
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() ||
(await readFile(join(agentDir, path), 'utf8')) !== required.content
content === undefined ||
!acceptable.includes(content)
) {
differences.push(path);
}
@@ -253,21 +292,24 @@ function onboardingState(mosaicHome: string): Record<string, unknown> {
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
);
}
if (
typeof authored !== 'object' ||
authored === null ||
Array.isArray(authored) ||
!('mcpServers' in authored) ||
typeof authored.mcpServers !== 'object' ||
authored.mcpServers === null ||
Array.isArray(authored.mcpServers)
) {
if (typeof authored !== 'object' || authored === null || Array.isArray(authored)) {
throw new FleetAgentScaffoldError(
'invalid-request',
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
`canonical Claude settings must be a JSON object: ${settingsPath}`,
);
}
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
// 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 {