Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cce40ff8b | ||
|
|
be2d14fb6c | ||
|
|
3667a7a77f | ||
|
|
d400ec5b9d | ||
|
|
b91b702a53 | ||
|
|
585dac7a5d |
@@ -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 D1–D6 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 D1–D6, 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 D1–D6 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user