fix(launch): resolve the runtime binary once and execute the object that was checked

AMD1213-D defect D3. The fleet launch path asked `which` whether a runtime was
reachable and then spawned the bare name, letting the OS resolve it a second
time against an ambient PATH at a later moment. Two independent resolutions of
an attacker-influenced name with a gap in between is not a check.

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

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

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

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

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

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

Two residuals, stated rather than engineered around:

  * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before
    spawn. That narrows the validation-to-exec window; it does not close it.
    Closing it means executing a held descriptor and Node has no portable way to
    exec by descriptor. A same-UID replacement landing inside the remaining
    window is the same accepted boundary already documented for the fleet
    helper.
  * For claude and pi the runtime binary is still re-resolved inside
    launch-runtime.py after the trusted interpreter starts it. This change does
    not cover that path.

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

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

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

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

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
This commit is contained in:
2026-08-15 14:07:57 -05:00
parent 326a1a58b5
commit 585dac7a5d
5 changed files with 1392 additions and 372 deletions
@@ -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.
}
}
}
}
+178 -14
View File
@@ -20,7 +20,7 @@ import {
piForceSkillNames,
registerRuntimeLaunchers,
checkSequentialThinking,
launchFleetRuntimeForTest,
resolveExecutableFromPath,
type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js';
@@ -136,13 +136,9 @@ describe('checkSequentialThinking', () => {
{ mode: 0o600 },
);
vi.stubEnv('HOME', home);
const final = vi.fn((): never => {
throw new Error('final runtime boundary');
});
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
).toThrow('final runtime boundary');
expect(final).toHaveBeenCalledOnce();
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
).not.toThrow();
} finally {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
@@ -175,11 +171,9 @@ describe('checkSequentialThinking', () => {
}),
);
vi.stubEnv('HOME', home);
expect(() =>
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
throw new Error('must not execute');
}),
).toThrow('process.exit called');
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
'process.exit called',
);
} finally {
exit.mockRestore();
vi.unstubAllEnvs();
@@ -253,7 +247,20 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
[
'--runtime',
'claude',
'--claude-config-dir',
join(agentDir, '.claude'),
'--python-bin',
'/usr/bin/python3',
'--node-bin',
'/usr/bin/node',
'--npx-bin',
'/usr/bin/npx',
'--timeout-bin',
'/usr/bin/timeout',
],
{
env,
},
@@ -262,7 +269,21 @@ describe('checkSequentialThinking', () => {
expect(
spawnSync(
checker,
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
[
'--check',
'--runtime',
'claude',
'--claude-config-dir',
join(agentDir, '.claude'),
'--python-bin',
'/usr/bin/python3',
'--node-bin',
'/usr/bin/node',
'--npx-bin',
'/usr/bin/npx',
'--timeout-bin',
'/usr/bin/timeout',
],
{ env },
).status,
).toBe(0);
@@ -604,3 +625,146 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
expect(mockExit).not.toHaveBeenCalled();
});
});
/**
* Executable resolution for fleet launches (AMD1213-D, D3/D6).
*
* The defect these cover: the launcher proved a runtime existed by running ambient
* `which`, then spawned the bare name and let the OS resolve it a second time against an
* ambient PATH. A directory prepended to PATH satisfied the probe and then supplied the
* binary that actually ran, so the check could pass without ever reading seat state.
*
* Every case below was run against the pre-change resolution first. The shim case is the
* one that matters -- under `which` + bare-name spawn it passes, because that is exactly
* the behaviour being removed.
*/
describe('resolveExecutableFromPath', () => {
let dir: string;
const bin = (root: string, name: string, mode = 0o755): string => {
const p = join(root, name);
writeFileSync(p, '#!/bin/sh\nexit 0\n');
chmodSync(p, mode);
return p;
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mosaic-exec-resolve-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('resolves a safe executable and reports its real path and identity', () => {
const safe = join(dir, 'safe');
mkdirSync(safe, { mode: 0o755 });
const target = bin(safe, 'codex');
const resolved = resolveExecutableFromPath('codex', safe);
expect(resolved.path).toBe(target);
expect(resolved.ino).toBeDefined();
});
it('refuses a world-writable binary planted on PATH', () => {
// The shim case. `which` reports this happily and a bare-name spawn runs it.
const shim = join(dir, 'shim');
mkdirSync(shim, { mode: 0o755 });
bin(shim, 'codex', 0o777);
expect(() => resolveExecutableFromPath('codex', shim)).toThrow(/writable by group or other/);
});
it('refuses a safe binary reached through a world-writable directory', () => {
// The binary itself is fine; anyone can swap it for one that is not.
const open = join(dir, 'open');
mkdirSync(open, { mode: 0o755 });
bin(open, 'codex');
// chmod after mkdir: the mode argument is masked by the process umask, so a
// directory created as 0o777 is really 0o755 and the case tests nothing.
chmodSync(open, 0o777);
expect(() => resolveExecutableFromPath('codex', open)).toThrow(/writable directory/);
});
it('does not fall through to a later PATH entry when the first match is unsafe', () => {
// Falling through would let a planted unsafe binary silently downgrade the search to
// whatever came after it, inverting the precedence PATH exists to express.
const shim = join(dir, 'first');
const good = join(dir, 'second');
mkdirSync(shim, { mode: 0o755 });
mkdirSync(good, { mode: 0o755 });
bin(shim, 'codex', 0o777);
const safeTarget = bin(good, 'codex');
let resolvedPath: string | undefined;
try {
resolvedPath = resolveExecutableFromPath('codex', `${shim}:${good}`).path;
} catch {
resolvedPath = undefined;
}
expect(resolvedPath).not.toBe(safeTarget);
});
it('ignores a relative PATH entry', () => {
// A relative entry resolves against the current directory, so what it names depends
// on where the launcher happened to be started.
expect(() => resolveExecutableFromPath('codex', '.:relative/bin')).toThrow(/not found/);
});
it('follows a symlink and validates the real file behind it', () => {
const safe = join(dir, 'real');
const linkDir = join(dir, 'links');
mkdirSync(safe, { mode: 0o755 });
mkdirSync(linkDir, { mode: 0o755 });
const target = bin(safe, 'codex-real');
symlinkSync(target, join(linkDir, 'codex'));
expect(resolveExecutableFromPath('codex', linkDir).path).toBe(target);
});
it('refuses a symlink whose real target is unsafe', () => {
const open = join(dir, 'openreal');
const linkDir = join(dir, 'links2');
mkdirSync(open, { mode: 0o755 });
mkdirSync(linkDir, { mode: 0o755 });
const target = bin(open, 'codex-real', 0o777);
symlinkSync(target, join(linkDir, 'codex'));
expect(() => resolveExecutableFromPath('codex', linkDir)).toThrow(/writable by group or other/);
});
it('refuses a non-executable file', () => {
const safe = join(dir, 'noexec');
mkdirSync(safe, { mode: 0o755 });
bin(safe, 'codex', 0o644);
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not executable/);
});
it('refuses a directory that merely shares the name', () => {
const safe = join(dir, 'dirname');
mkdirSync(join(safe, 'codex'), { recursive: true, mode: 0o755 });
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not a regular file/);
});
it('refuses a name that is a path rather than a bare command', () => {
expect(() => resolveExecutableFromPath('../evil', dir)).toThrow(/bare command name/);
});
it('refuses when no PATH was declared', () => {
expect(() => resolveExecutableFromPath('codex', undefined)).toThrow(/no PATH was declared/);
expect(() => resolveExecutableFromPath('codex', '')).toThrow(/no PATH was declared/);
});
it('reports not-found rather than resolving something else', () => {
const empty = join(dir, 'empty');
mkdirSync(empty, { mode: 0o755 });
expect(() => resolveExecutableFromPath('codex', empty)).toThrow(
/not found on the declared PATH/,
);
});
});
+284 -64
View File
@@ -20,7 +20,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { isAbsolute, join, dirname, relative, resolve, sep } from 'node:path';
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -140,15 +140,18 @@ function sha256Of(value: string | Buffer): string {
* so an unexpected digest here is a mechanically detectable red flag rather than
* a matter of judgement.
*/
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
function normativeFragmentDigests(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
): NormativeFragmentDigest[] {
const candidates: Array<[string, string]> = [
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
['USER.md', join(MOSAIC_HOME, 'USER.md')],
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')],
['AGENTS.md', join(mosaicHome, 'AGENTS.md')],
['SOUL.md', join(mosaicHome, 'SOUL.md')],
['USER.md', join(mosaicHome, 'USER.md')],
['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')],
['TOOLS.md', join(mosaicHome, 'TOOLS.md')],
[`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')],
];
return candidates.map(([sourceId, path]) => {
try {
@@ -178,7 +181,10 @@ function recordLaunch(
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
const ledgerDir = fleet?.mosaicHome
? join(fleet.mosaicHome, 'fleet', 'run', 'sessions')
: LAUNCH_LEDGER_DIR;
mkdirSync(ledgerDir, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it
// propagates through every `...process.env` / `...baseEnv` spread below.
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
@@ -198,13 +204,13 @@ function recordLaunch(
config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
normative_fragments: normativeFragmentDigests(runtime, fleet?.mosaicHome),
// names only — values are never recorded
mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_'))
.sort(),
};
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
} catch (err) {
@@ -244,6 +250,125 @@ function checkRuntime(cmd: string): void {
}
}
/** An executable located once and pinned by the identity it had when validated. */
interface ResolvedExecutable {
readonly path: string;
readonly dev: number | bigint;
readonly ino: number | bigint;
}
function executableRefusal(name: string, detail: string): Error {
return new Error(`refusing to launch '${name}': ${detail}`);
}
/**
* Reject a directory whose contents someone else could swap under us.
*
* Group- or world-writable is the disqualifier, with the /tmp exception: a sticky
* directory is writable by design but only its owner may replace its entries, so it
* cannot be used to shadow one.
*/
function assertSafeAncestry(path: string, name: string, owner: number | undefined): void {
let cursor = dirname(path);
for (;;) {
const info = lstatSync(cursor);
if (!info.isDirectory() || info.isSymbolicLink()) {
throw executableRefusal(name, `path component is not a real directory: ${cursor}`);
}
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) {
throw executableRefusal(name, `writable directory on the resolved path: ${cursor}`);
}
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
throw executableRefusal(
name,
`directory on the resolved path has a foreign owner: ${cursor}`,
);
}
const parent = dirname(cursor);
if (parent === cursor) return;
cursor = parent;
}
}
/**
* Find one executable named `name`, searching only `searchPath`, and validate the object
* that search lands on.
*
* This exists because `which` answered a different question than the one the launcher
* needed. `which` reported that *something* by that name was reachable; the launcher then
* spawned the bare name and let the OS resolve it a second time, against an ambient PATH,
* at a later moment. Two independent resolutions of an attacker-influenced name, with a
* gap in between, is not a check -- a directory prepended to PATH satisfied the probe and
* then supplied the thing that actually ran. Resolving once here and executing the exact
* path returned is the whole point; callers must not go back to the name.
*
* Rules worth stating because each one is a hole if dropped:
*
* * A relative PATH entry is skipped. It resolves against the current directory, so
* what it names depends on where the launcher happened to be started.
* * The FIRST name match decides the outcome, and an unsafe first match is a refusal
* rather than a reason to keep looking. Falling through to a later entry would let a
* planted unsafe binary silently downgrade the search to whatever came after it,
* which inverts the precedence PATH is supposed to express.
* * A symlink is followed, and the real file it lands on is what gets validated and
* executed. Validating the link and executing the name would repeat the original bug
* one level down.
*/
export function resolveExecutableFromPath(
name: string,
searchPath: string | undefined,
): ResolvedExecutable {
if (name.includes('/')) {
throw executableRefusal(name, 'expected a bare command name, not a path');
}
if (searchPath === undefined || searchPath === '') {
throw executableRefusal(name, 'no PATH was declared for the launch');
}
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
for (const entry of searchPath.split(delimiter)) {
if (entry === '' || !isAbsolute(entry)) continue;
const candidate = join(entry, name);
if (!existsSync(candidate)) continue;
// First match wins, for good or ill. Everything below either returns or throws.
const real = realpathSync(candidate);
const info = lstatSync(real);
if (!info.isFile()) {
throw executableRefusal(name, `${real} is not a regular file`);
}
if ((info.mode & 0o111) === 0) {
throw executableRefusal(name, `${real} is not executable`);
}
if ((info.mode & 0o022) !== 0) {
throw executableRefusal(name, `${real} is writable by group or other`);
}
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
throw executableRefusal(name, `${real} is owned by neither the launching user nor root`);
}
assertSafeAncestry(real, name, owner);
return { path: real, dev: info.dev, ino: info.ino };
}
throw executableRefusal(name, `not found on the declared PATH`);
}
/**
* Re-confirm, immediately before spawning, that the path still names the object that was
* validated.
*
* This narrows the window between validation and exec; it does not close it. Closing it
* would mean executing a held descriptor, and there is no portable way to exec by
* descriptor from Node. The residual is a same-UID replacement landing inside the
* remaining window, which is the same accepted boundary already documented for the fleet
* helper. Stated rather than engineered around, so nobody reads this as a proof.
*/
function assertUnchangedSinceValidation(executable: ResolvedExecutable, name: string): void {
const now = lstatSync(executable.path);
if (now.dev !== executable.dev || now.ino !== executable.ino) {
throw executableRefusal(name, `${executable.path} was replaced after it was validated`);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
@@ -346,16 +471,37 @@ function printSettingsWarnings(audit: SettingsAudit): void {
);
}
function resolveExecutable(name: string): string {
const result = spawnSync('which', [name], { encoding: 'utf8' });
const path = result.status === 0 ? result.stdout.trim() : '';
if (!path || !isAbsolute(path) || !existsSync(path)) {
throw new Error(`required helper executable is unavailable: ${name}`);
}
return path;
interface TrustedCapability {
readonly path: string;
readonly content: Buffer;
readonly dev: number | bigint;
readonly ino: number | bigint;
}
function trustedFleetHelper(mosaicHome: string): string {
/** The fleet helper accepts capabilities only from root-owned /usr/bin. */
function trustedCapability(name: string): TrustedCapability {
const candidate = join('/usr/bin', name);
let path: string;
try {
path = realpathSync(candidate);
if (!path.startsWith('/usr/')) throw new Error('resolved outside /usr');
const snapshot = readRegularFileSecure(path, {
root: '/',
executable: true,
maxBytes: 64 * 1024 * 1024,
});
const info = lstatSync(path);
if ((info.mode & 0o022) !== 0 || info.uid !== 0)
throw new Error('unsafe capability owner or mode');
return { path, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
} catch (error: unknown) {
throw new Error(
`required trusted fleet capability is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function trustedFleetHelper(mosaicHome: string): TrustedCapability {
const root = resolve(mosaicHome);
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
try {
@@ -389,39 +535,59 @@ function trustedFleetHelper(mosaicHome: string): string {
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return checker;
const snapshot = readRegularFileSecure(checker, {
root,
executable: true,
maxBytes: 1024 * 1024,
});
return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
}
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
// Fleet launch must use the active --mosaic-home installation. Non-fleet
// launches retain the package/deployed helper resolver.
const checker = fleet?.mosaicHome
? trustedFleetHelper(fleet.mosaicHome)
: fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
if (!fleet?.mosaicHome) {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return;
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) process.exit(1);
return;
}
const helper = trustedFleetHelper(fleet.mosaicHome);
const bash = trustedCapability('bash');
const python = trustedCapability('python3');
const node = trustedCapability('node');
const npx = trustedCapability('npx');
const timeout = trustedCapability('timeout');
const fleetClaudeConfig =
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
const fleetOpenCodeHome =
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
const python = resolveExecutable('python3');
const node = resolveExecutable('node');
const npx = resolveExecutable('npx');
const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':');
const result = spawnSync(
checker,
bash.path,
[
'-s',
'--',
'--check',
'--runtime',
runtime,
'--python-bin',
python.path,
'--node-bin',
node.path,
'--npx-bin',
npx.path,
'--timeout-bin',
timeout.path,
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
],
{
stdio: 'ignore',
input: helper.content,
stdio: ['pipe', 'ignore', 'ignore'],
env: {
HOME: process.env['HOME'] ?? '',
PATH: capabilityPath,
LANG: process.env['LANG'] ?? 'C.UTF-8',
HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'),
LANG: 'C.UTF-8',
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
? {}
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
@@ -436,8 +602,10 @@ export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarne
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
const repairArgs =
fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`;
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`);
fleetClaudeConfig === undefined
? ''
: ` --claude-config-dir ${fleetClaudeConfig} --python-bin ${python.path} --node-bin ${node.path} --npx-bin ${npx.path} --timeout-bin ${timeout.path}`;
console.error(`[mosaic] Fix: ${helper.path} --runtime ${runtime}${repairArgs}`);
process.exit(1);
}
}
@@ -997,6 +1165,7 @@ 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. */
@@ -1027,16 +1196,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 +1254,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 +1286,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) {
@@ -1113,7 +1322,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 +1341,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 +1352,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 +1398,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 +1421,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);
}
}