Compare commits

...
Author SHA1 Message Date
fred 5cce40ff8b docs(amd1213-d): D1 confirmed by existing controls; correct my own survey error
D1 is confirmed, not assumed. The controls exist and are strong: all ten
mutation seams table-driven with byte-for-byte restoration of six artifacts,
new-seat rollback leaving no residue, and ROLLBACK_INTEGRITY escalation across
three parent-substitution attacks with recovery evidence written.

This corrects a wrong finding of mine. I reported that eleven injectFailure
seams existed in production and zero tests used any of them, and that D1's
rollback path had never executed. The grep behind that searched for the
identifier `injectFailure` in the specs; the specs pass the injector as an
inline lambda, so the controls were present and the search could not see them.
I was one step from writing a duplicate spec. Recorded with the method note --
grep the production seam names, not the parameter name.

One narrow gap left standing rather than papered over: 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 does today, so it is
defence-in-depth and is noted for when D2/D4/D6 are confirmed.

Remaining amendment item is now D2/D4/D6 confirmation; D3 and D5 are closed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 15:16:17 -05:00
fred be2d14fb6c docs(amd1213-d): D3 closed both halves; record the measured child env and the deliberate HOME residual 2026-08-15 15:06:31 -05:00
fred 3667a7a77f fix(launch): fix the composed-seat locale and measure the environment the runtime actually gets
Closes the environment half of AMD1213-D defect D3. The executable half landed
in 585dac7a; this is the other thing the card asked for -- a capability-minimal
child environment that is measured rather than asserted.

MEASURED FIRST, THEN CHANGED. I ran the real `fleet launch` route with a shim in
place of the runtime binary and had the shim dump its own environment, so the
subject is what arrives at the far end of the chain -- after composition, after
the lease gate in launch-runtime.py -- and not the object the launcher believed
it was building. Those are different sets and only the first one matters.

What the measurement showed is that most of this defect was already closed by
construction and nobody knew, because nothing tested it. `minimalLaunchEnv`
builds from an empty object over a fixed name list, so BASH_ENV, ENV, PYTHON*,
NODE_*, NPM_CONFIG_*, LD_PRELOAD, LD_LIBRARY_PATH and every provider credential
in the operator's environment are already excluded, and they stay excluded
through the lease gate. I planted all sixteen and none reached the child. An
allowlist that no test names is one careless edit from being a denylist, which
is the actual defect here.

Two real gaps, one fixed and one not:

FIXED -- locale was inherited. A seat picked up the operator's LANG and LC_ALL,
so the same runtime doing the same work emitted different message language,
collation, and number and date formatting depending on who started it. Composed
launches now pin C.UTF-8. C.UTF-8 and not C: both are unambiguous, but plain C
is ASCII and would mangle non-ASCII output, trading one defect for another. A
seat that needs a different locale declares LANG or LC_ALL in its profile and
the declared value still wins -- covered by a test, so the escape hatch cannot
be removed silently. The operator path (no declared env) is untouched.

NOT FIXED, AND DELIBERATELY -- HOME is still the operator's. The card asks for
the seat config root instead, and it is right that this is the remaining leak:
the runtime is pointed at its own config directory, but anything it shells out
to (git, ssh, npm) still reads the operator's dotfiles and therefore the
operator's credentials. I am not changing it inside this amendment. 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 whole proof is a seat carrying a change to a pushed
branch. Moving HOME before the per-agent home is populated would improve the
isolation and break the deliverable. That population is what the harness-homes
design owns, and this is recorded as a residual there rather than half-done
here.

Eight tests. Each one falsified by inverting the property it claims to defend,
and each inversion hit exactly its own test and nothing else:

  - added BASH_ENV to the inherited list  -> permitted-set and loader-hook
                                             killers both red, 2 failed
  - reverted the locale pin               -> locale killer red, 1 failed
  - reused an ambient MOSAIC_LAUNCH_ID    -> launch-id killer red, 1 failed
  - recorded process.env into the ledger  -> ledger-value killer red, 1 failed

The permitted-name list in the spec is written out by hand rather than derived
from the launcher. Deriving it would make the test agree with the code by
construction and detect nothing; the cost is that adding a variable means
editing the test, which is the point.

The launch-id test is worth naming separately. recordLaunch overwrites
MOSAIC_LAUNCH_ID in process.env before it is copied to the child, so a seat
launched from an operator session gets a fresh correlation id rather than
inheriting the operator's. That was already true and is now pinned, along with
the requirement that the child's id matches the one in the ledger -- correlation
is by this value and never by pid, because exec makes the runtime a different
process.

Verification: typecheck RC=0. eslint RC=0. prettier clean. Three consecutive
full-package runs under the sanitized lease environment, RC=0, 87 files / 1627
tests passed, 0 failed -- exactly one file and eight tests more than the 86/1619
baseline, so nothing else moved.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 15:04:44 -05:00
fred d400ec5b9d docs(amd1213-d): record measured defect state, residuals, and remaining D3 environment half
Replaces notes that stopped on 2026-08-13 and understated progress by roughly two
defects. Every row was re-measured against the tree rather than inherited: D1, D2,
D4 and D6 read as substantially implemented; D3 and D5 closed this pass.

Records the three things most likely to be lost or undone: that D2's alias defence
is strictness rather than normalization and breaks if someone normalizes the link
first; that D3 leaves two named residuals (the validation-to-exec window Node
cannot close, and launch-runtime.py re-resolving the binary for claude/pi); and
that the remaining D3 work is the measured child environment, not the executable
resolution already done.

Also records why the full suite needs the sanitized lease env, and the identity
blocker that keeps this commit-only regardless of when the hold lifts.
2026-08-15 14:47:56 -05:00
fred b91b702a53 fix(launch): drop the dead recordLaunch test seam; stop a load-sensitive spec reporting CPU load as a defect
Two changes, both about a test seam that alters production behaviour.

AMD1213-D defect D5 objected that `launchFleetRuntimeForTest` was an exported
production API that also set `recordLaunch:false`, changing a second production
branch beyond the two the card authorized. Most of that is already closed in
this tree: the exported helper is gone, and the specs now enter through the real
`registerFleetLaunchCommand -> apply -> launchFleetRuntime -> launchRuntime`
route on a fixture seat, with the ledger pointed at the fixture and asserted
(`fleet-launch-command.spec.ts` asserts `events.ndjson` contains the record).
The seat-seeded/HOME-empty pass and HOME-seeded/seat-empty fail pair both exist.

What remained was the `recordLaunch?: boolean` context field itself. Nothing in
the package sets it -- it is a dead switch whose only effect was to let a caller
silently disable launch recording on the claude branch while codex, opencode and
pi recorded unconditionally. Removed, so all four branches record the same way
and the asymmetry cannot be reintroduced by passing a flag.

The second change is unrelated to D1-D6 and is called out as such. It is here
because the amend's required evidence includes a green full-package Vitest run,
and one spec made that non-reproducible.

`install-ordering-guard.spec.ts` proves that `guardClaudeSettingsWiring` really
delegates to `leaseEnforcementActivatable()` by comparing the guard's outcome
against its own call to the same predicate. That predicate is not deterministic:
`defaultCapabilityProbe` runs `dist/cli.js` out-of-process with a 2000 ms
timeout. In a full-package run with 86 spec files scheduled at once, one
observation beats that timeout and the next does not, the two disagree, and the
test fails -- reporting machine load as a wiring defect. It passed in isolation
every time, which is why it read as a flake.

Measured rather than assumed. The failure reproduced in three consecutive full
runs and passed 3/3 in isolation. It was NOT caused by the recordLaunch removal
above: reverting only that edit and re-running the full suite still failed, which
is what ruled my own change out.

The guard call is now bracketed by two observations of the predicate, and only a
pair that agrees is used as ground truth; a disagreeing pair is retried, up to
three attempts, and never holding still is itself a failure rather than a skip.
This does not weaken the assertion -- a real delegation failure is stable and
survives every attempt while load noise is not.

Falsified: inverting the guard's default to `!leaseEnforcementActivatable()`
turns the test red (1 failed / 18 passed), so the retry did not blunt what the
test detects. The inversion was reverted and the file confirmed clean.

Verification: typecheck RC=0. Three consecutive full-package runs, RC=0,
86 files / 1619 tests passed, 0 failed, under the sanitized lease environment
(MOSAIC_LEASE_* and MOSAIC_RUNTIME_GENERATION stripped).

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:46:47 -05:00
fred 585dac7a5d fix(launch): resolve the runtime binary once and execute the object that was checked
AMD1213-D defect D3. The fleet launch path 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 in between is not a check.

Measured against the old code before changing it. A world-writable shim named
`codex` prepended to PATH:

    OLD checkRuntime  -> PASSED (which found it)
    OLD execRuntime   -> "SHIM EXECUTED -- this is not the real runtime"

The probe satisfied the check and then supplied the thing that ran.

Three call sites were exposed, not one: `checkRuntime`'s `which`; `execRuntime`
spawning 'codex'/'opencode' by name; and `execLeaseGatedRuntime` spawning
'python3' by name -- the interpreter that starts the lease gate itself, where a
shim does not bypass one check, it replaces the process that enforces all of
them. `minimalLaunchEnv` copies ambient PATH straight through, so the child
inherits the same search.

The fix: `resolveExecutableFromPath` searches only the PATH the child will
actually receive, validates the object the search lands on (regular file,
executable, not group/other-writable, owned by the launching user or root, with
no group/world-writable non-sticky directory and no foreign-owned directory on
its resolved path), and returns that path pinned to its dev/ino. Callers execute
the returned path, never the name again. Rules that are each a hole if dropped:
a relative PATH entry is skipped, since it resolves against wherever the
launcher was started; the first name match decides the outcome and an unsafe
first match is a refusal rather than a reason to keep looking, because falling
through would let a planted binary silently downgrade the search to whatever
came after it; a symlink is followed and the real file is what gets validated
and executed, since validating the link and executing the name repeats the
original bug one level down.

`checkRuntime` is kept unchanged on the operator path. `which` proves
reachability from the operator's own shell, which is the right question there
and the wrong one for a seat. The fleet lease-gate interpreter now comes from
the root-owned `trustedCapability('python3')` the helper already requires.

Two residuals, stated rather than engineered around:

  * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before
    spawn. That narrows the validation-to-exec window; it does not close it.
    Closing it means executing a held descriptor and Node has no portable way to
    exec by descriptor. A same-UID replacement landing inside the remaining
    window is the same accepted boundary already documented for the fleet
    helper.
  * 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.

Twelve tests in launch.spec.ts, each written against a specific hole: safe
resolution; world-writable binary; safe binary under a world-writable
directory; no fall-through past an unsafe first match; relative PATH entry
ignored; symlink followed and real file validated; symlink to an unsafe target
refused; non-executable refused; directory sharing the name refused; a path
rather than a name refused; no PATH declared; not-found reported as not-found
rather than resolving something else.

One of those tests was written wrong first and is worth recording: creating the
open directory with `mkdirSync(path, { mode: 0o777 })` gets masked by the umask
to 0o755, so the case passed while testing nothing. It creates at 0o755 and
chmods after.

Verification: typecheck RC=0. Full package suite 1615 passed / 4 failed / 1619.
The four failures are the pre-existing host lease-identity leak into spawned
hooks, not this change -- the same spec re-run with only the five MOSAIC_LEASE_*
and MOSAIC_RUNTIME_GENERATION variables stripped from the environment, with no
code change, is 20/20.

Scope note: this commit carries the uncommitted D1/D4/D6 work already present in
the tree alongside D3, because it is interleaved in the same files and is one
amend package. D2 and D5 are not yet assessed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:07:57 -05:00
8 changed files with 1991 additions and 381 deletions
@@ -0,0 +1,202 @@
# 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,12 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
# 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.
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; }
@@ -14,273 +19,130 @@ log() { echo "[mosaic-seq] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--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
;;
--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 ;;
esac
done
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
}
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
warm_package() {
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
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
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
}
check_claude_config() {
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
import json
import os
claude_config_python='import json, os, stat, tempfile
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
}
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():
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()
try:
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
}
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)
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 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"]
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():
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)
try:
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
}
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
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
}
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)'
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_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
check_runtime_config
# 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
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
fi
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
apply_runtime_config
log "sequential-thinking MCP configured (${RUNTIME})"
@@ -6,6 +6,7 @@ import {
mkdtempSync,
readFileSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -425,6 +426,158 @@ 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 });
@@ -449,6 +602,26 @@ 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');
@@ -492,10 +665,82 @@ describe('managed plugin and skill links', () => {
expect(() =>
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
).toThrowError(/escapes an approved seat\/store root/);
).toThrowError(/not an exact managed class/);
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');
@@ -664,6 +909,116 @@ describe('fleet launch command outcomes', () => {
);
});
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);
}
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;
}
});
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);
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;
}
});
it('sets a non-zero exit code and never invokes the launcher', () => {
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
const program = new Command().exitOverride();
@@ -1,5 +1,8 @@
import {
closeSync,
constants,
fchmodSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
@@ -23,6 +26,7 @@ import {
type RuntimeName,
} from './launch.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
import { assertNoSymlinkAncestors } from '../fleet/secure-file.js';
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [
@@ -53,7 +57,8 @@ export type FleetLaunchErrorCode =
| 'PROFILE_INVALID'
| 'AGENT_NOT_SCAFFOLDED'
| 'COMPOSITION_FAILED'
| 'FIRST_AUTH_REFUSAL';
| 'FIRST_AUTH_REFUSAL'
| 'ROLLBACK_INTEGRITY';
export class FleetLaunchError extends Error {
constructor(
@@ -592,29 +597,65 @@ function readManagedLinkState(
);
}
const links = new Map<string, string>();
for (const [link, target] of Object.entries(parsed['links'])) {
if (typeof target !== 'string' || !isAbsolute(link) || !isAbsolute(target)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`managed link manifest has invalid entry: ${path}`,
);
const credential = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const 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 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) {
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);
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`managed link manifest entry escapes an approved seat/store root: ${path}`,
`managed link manifest entry is not an exact managed class: ${detail}`,
);
}
links.set(link, target);
@@ -835,29 +876,372 @@ 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): void {
export function applyFleetLaunchComposition(
plan: FleetLaunchComposition,
injectFailure?: (seam: string) => void,
): void {
// All link-state checks must complete before the first filesystem mutation.
// This makes a late foreign/retargeted link refusal leave the seat untouched.
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);
}
mkdirSync(plan.seatHome, { recursive: true });
const preparedManifest = prepareManagedLinkManifest(plan.managedLinks);
let descriptorOpen = true;
let committedManifest = false;
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;
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');
for (const path of plan.prune) {
const info = lstatIfPresent(path);
if (info?.isSymbolicLink()) {
if (info) {
if (!info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object replaced managed symlink before prune: ${path}`,
);
}
const current = currentLinkTarget(path);
if (plan.managedLinks.links.get(path) !== current) {
throw new FleetLaunchError(
@@ -866,26 +1250,61 @@ export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void
);
}
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);
committedManifest = true;
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;
} finally {
if (!committedManifest) {
if (descriptorOpen) closeSync(preparedManifest.descriptor);
rmSync(preparedManifest.path, { force: true });
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.
}
}
}
}
@@ -201,10 +201,34 @@ 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.
const reallyActivatable = leaseEnforcementActivatable();
const outcome = guardClaudeSettingsWiring(fixtureJson());
//
// 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;
if (reallyActivatable) {
if (before) {
expect(outcome.exitCode).toBe(0);
expect(outcome.wired).toBe(true);
} else {
@@ -0,0 +1,338 @@
/**
* 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);
}
});
});
+178 -14
View File
@@ -20,7 +20,7 @@ import {
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
launchFleetRuntimeForTest,
resolveExecutableFromPath,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -136,13 +136,9 @@ describe('checkSequentialThinking', () => {
{ mode: 0o600 },
);
vi.stubEnv('HOME', home);
const final = vi.fn((): never => {
throw new Error('final runtime boundary');
});
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
).toThrow('final runtime boundary');
expect(final).toHaveBeenCalledOnce();
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
).not.toThrow();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
@@ -175,11 +171,9 @@ describe('checkSequentialThinking', () => {
}),
);
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
'process.exit called',
);
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
@@ -253,7 +247,20 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
[
'--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',
],
{
env,
},
@@ -262,7 +269,21 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
[
'--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',
],
{ env },
).status,
).toBe(0);
@@ -604,3 +625,146 @@ 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/,
);
});
});
+316 -70
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 { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path';
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -140,15 +140,18 @@ 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): NormativeFragmentDigest[] {
function normativeFragmentDigests(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['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')],
['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')],
];
return candidates.map(([sourceId, path]) => {
try {
@@ -178,7 +181,10 @@ function recordLaunch(
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
const ledgerDir = fleet?.mosaicHome
? join(fleet.mosaicHome, 'fleet', 'run', 'sessions')
: LAUNCH_LEDGER_DIR;
mkdirSync(ledgerDir, { 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')}`;
@@ -198,13 +204,13 @@ function recordLaunch(
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome),
// names only — values are never recorded
mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
@@ -244,6 +250,125 @@ 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}`);
}
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;
}
}
/**
* 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)) {
@@ -346,16 +471,37 @@ function printSettingsWarnings(audit: SettingsAudit): void {
);
}
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;
interface TrustedCapability {
readonly path: string;
readonly content: Buffer;
readonly dev: number | bigint;
readonly ino: number | bigint;
}
function trustedFleetHelper(mosaicHome: string): string {
/** 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 trustedFleetHelper(mosaicHome: string): TrustedCapability {
const root = resolve(mosaicHome);
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
@@ -389,39 +535,59 @@ function trustedFleetHelper(mosaicHome: string): string {
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return checker;
const snapshot = readRegularFileSecure(checker, {
root,
executable: true,
maxBytes: 1024 * 1024,
});
return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
}
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
// Fleet launch must use the active --mosaic-home installation. Non-fleet
// launches retain the package/deployed helper resolver.
const checker = fleet?.mosaicHome
? trustedFleetHelper(fleet.mosaicHome)
: fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
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 fleetClaudeConfig =
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
const fleetOpenCodeHome =
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
const python = resolveExecutable('python3');
const node = resolveExecutable('node');
const npx = resolveExecutable('npx');
const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':');
const result = spawnSync(
checker,
bash.path,
[
'-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]),
],
{
stdio: 'ignore',
input: helper.content,
stdio: ['pipe', 'ignore', 'ignore'],
env: {
HOME: process.env['HOME'] ?? '',
PATH: capabilityPath,
LANG: process.env['LANG'] ?? 'C.UTF-8',
HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'),
LANG: 'C.UTF-8',
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
? {}
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
@@ -436,8 +602,10 @@ 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}`;
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${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}`);
process.exit(1);
}
}
@@ -997,17 +1165,47 @@ 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 = {};
const env: NodeJS.ProcessEnv = {
LANG: COMPOSED_LAUNCH_LOCALE,
LC_ALL: COMPOSED_LAUNCH_LOCALE,
};
for (const name of [
'PATH',
'HOME',
@@ -1016,8 +1214,6 @@ function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.Pr
'SHELL',
'TERM',
'COLORTERM',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_RUNTIME_DIR',
]) {
@@ -1027,16 +1223,56 @@ 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 {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
(context.runtimeCheck ?? checkRuntime)(runtime);
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);
}
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
@@ -1045,7 +1281,7 @@ function launchRuntime(
checkResumableSession();
const missionPrompt = getMissionPrompt();
const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : '';
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
@@ -1077,7 +1313,7 @@ function launchRuntime(
const settingsAudit = auditClaudeSettings(context.fleet);
printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude', contractEnv);
const prompt = composeContract('claude', mosaicHome, contractEnv);
const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
@@ -1086,8 +1322,7 @@ function launchRuntime(
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
if (context.recordLaunch !== false)
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
@@ -1113,7 +1348,7 @@ function launchRuntime(
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime('codex', cliArgs, {
execRuntime(resolvedRuntime ?? 'codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
@@ -1132,7 +1367,7 @@ function launchRuntime(
);
console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime('opencode', args, {
execRuntime(resolvedRuntime ?? 'opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
@@ -1143,7 +1378,7 @@ function launchRuntime(
}
case 'pi': {
const prompt = buildRuntimePrompt('pi', contractEnv);
const prompt = composeContract('pi', mosaicHome, contractEnv);
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension());
@@ -1189,8 +1424,13 @@ 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(
'python3',
interpreter,
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{
...baseEnv,
@@ -1207,38 +1447,44 @@ export function launchFleetRuntime(
args: string[],
declaredEnv: Readonly<Record<string, string>>,
fleet: FleetHarnessContext,
): never {
return launchRuntime(runtime, args, false, { 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. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
/**
* 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;
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(cmd, args, {
const result = spawnSync(target, args, {
stdio: 'inherit',
env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}