#!/usr/bin/env bash # End-to-end suite for 9agents: the read-only, fresh-from-disk 9P view of # every harness's state. # # Usage: bash 9agents/test/e2e.sh <9agents> [<9ns>] (zig build 9agents-itest) # # Part A always runs, entirely on fixtures: a fake home with fixture roots # pinned by --root, a scratch XDG_RUNTIME_DIR registry, plan9port's 9p as # the client. It proves: the posted name is listed, the roots walk and # read, a transcript comes back byte-identical (cmp), credentials-shaped # files are unreachable, a file appended after the daemon started is # visible at once, writes answer EPERM, and --unix/--fd listen forms work. # # Part B (the money shot) runs against the REAL roots and the REAL # registry only when the `agents` name is free, and only reads: the posted # name in /run/user//9p, 9p walks of the live ~/.claude, a real # transcript byte-identical through the tree, the 9ns --mntgen mount of # /mnt/9p/agents, and the live ~/.claude/.credentials.json unreachable. # It is skipped (not failed) when a live daemon already owns the name. # # Exit 0 on success (or when the machine cannot run a part), 1 on failure. set -u H9=$(realpath "${1:?path to 9agents}") HARNESS_TMP_XDG="${XDG_RUNTIME_DIR:-}" [ $# -ge 2 ] && [ -n "$2" ] && NS=$(realpath "$2") P9P=/usr/lib/plan9/bin/9p TMP=$(mktemp -d "${TMPDIR:-/tmp}/9agents.XXXXXX") PIDS=() FAILED=0 PASSED=0 cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done rm -rf "$TMP" # part B posts into the *real* registry under a scratch name; a crash # between the post and the unpost would otherwise leave a socket there. [ -n "${REAL_SOCKET:-}" ] && rm -f "$REAL_SOCKET" return 0 } trap cleanup EXIT pass() { PASSED=$((PASSED + 1)); echo "ok - $1"; } fail() { FAILED=$((FAILED + 1)); echo "FAIL - $1"; shift; [ $# -gt 0 ] && printf ' %s\n' "$@"; } expect_eq() { # name expected actual if [ "$2" = "$3" ]; then pass "$1"; else fail "$1" "expected: $(printf %q "$2")" "actual: $(printf %q "$3")"; fi } expect_contains() { # name needle haystack case "$3" in *"$2"*) pass "$1" ;; *) fail "$1" "missing: $(printf %q "$2")" "in: $(printf %q "$3")" ;; esac } expect_missing() { # name needle haystack case "$3" in *"$2"*) fail "$1" "found: $(printf %q "$2")" "in: $(printf %q "$3")" ;; *) pass "$1" ;; esac } if [ ! -x "$P9P" ]; then echo "SKIP: plan9port 9p not at $P9P"; exit 0 fi if ! unshare -Urm true 2>/dev/null; then echo "SKIP: unprivileged user namespaces unavailable"; exit 0 fi start_daemon() { # args... -> sets DAEMON_PID, logs to $TMP/daemon.log "$H9" "$@" >"$TMP/daemon.log" 2>&1 & DAEMON_PID=$! PIDS+=("$DAEMON_PID") } wait_posted() { # socket path for _ in $(seq 1 100); do [ -S "$1" ] && return 0; sleep 0.05; done return 1 } p9() { "$P9P" -a "unix!$1" "${@:2}"; } # ============================================================================ echo "# part A: fixture roots, scratch registry" # ============================================================================ export XDG_RUNTIME_DIR="$TMP" REG="$TMP/9p" FX="$TMP/home" mkdir -p "$FX" # The fixture home: five roots, a real-shaped claude project with a # transcript, credentials-shaped files at several depths, codex sessions, # an omp agent dir, hermes logs and dsh profiles. mkfile() { mkdir -p "$(dirname "$1")"; printf '%s' "$2" > "$1"; } mkfile "$FX/claude/projects/-tmp-proj/session-abc.jsonl" '{"type":"user","message":"first line"} {"type":"assistant","message":"second line"} ' mkfile "$FX/claude/projects/-tmp-proj/.credentials.json" 'STAY OUT' mkfile "$FX/claude/projects/-tmp-proj/auth.json" 'STAY OUT' mkfile "$FX/claude/projects/-tmp-proj/token.bin" 'STAY OUT' mkfile "$FX/claude/history.jsonl" '{"display":"claude prompt one"} {"display":"claude prompt two"} ' mkdir -p "$FX/claude/skills/revu" mkfile "$FX/claude/skills/revu/SKILL.md" '# revu skill' mkfile "$FX/codex/sessions/2026/09/21/rollout-x.jsonl" '{"session":"codex one"} ' mkfile "$FX/codex/session_index.jsonl" '{"id":"x"} ' mkfile "$FX/codex/history.jsonl" '{"text":"codex prompt"} ' mkfile "$FX/omp/agent/history.db" 'fake-history-db' mkfile "$FX/omp/agent/config.yml" 'STAY OUT' mkfile "$FX/hermes/auth.json" 'STAY OUT' mkfile "$FX/hermes/logs/app.log" 'hermes log line ' mkfile "$FX/hermes/state.db" 'fake-hermes-state' mkfile "$FX/dsh/profiles/p1.yaml" 'name: p1' mkfile "$FX/dsh/.credentials.yaml" 'STAY OUT' start_daemon --root "claude=$FX/claude" --root "codex=$FX/codex" \ --root "omp=$FX/omp" --root "hermes=$FX/hermes" --root "dsh=$FX/dsh" \ --name agents wait_posted "$REG/agents" || { fail "the daemon posts as agents" "$(cat "$TMP/daemon.log")"; exit 1; } # 1. The posted name is listed. expect_contains "posted name listed in the registry" agents "$(ls "$REG")" # 2. The roots walk; a real transcript reads back byte-identical. expect_contains "ls / shows the roots" claude "$(p9 "$REG/agents" ls /)" expect_contains "ls / shows codex" codex "$(p9 "$REG/agents" ls /)" expect_contains "ls / shows skills" skills "$(p9 "$REG/agents" ls /)" p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl > "$TMP/via-9p.jsonl" 2>"$TMP/read.err" \ || fail "transcript read through the tree" "$(cat "$TMP/read.err")" cmp -s "$TMP/via-9p.jsonl" "$FX/claude/projects/-tmp-proj/session-abc.jsonl" \ && pass "transcript byte-identical through the tree (cmp)" \ || fail "transcript byte-identical through the tree (cmp)" "differs" expect_eq "history file reads" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$REG/agents" read /claude/history)" expect_eq "skills union mirrors the harness tree" "$(cat "$FX/claude/skills/revu/SKILL.md")" \ "$(p9 "$REG/agents" read /skills/claude/revu/SKILL.md)" # 3. A credentials-shaped file is unreachable anywhere in the tree. PROJ_LS=$(p9 "$REG/agents" ls /claude/projects/-tmp-proj) for bad in .credentials.json auth.json token.bin; do expect_missing "credentials-shaped $bad not listed" "$bad" "$PROJ_LS" p9 "$REG/agents" read "/claude/projects/-tmp-proj/$bad" >/dev/null 2>&1 \ && fail "credentials-shaped $bad unreachable" "read succeeded" \ || pass "credentials-shaped $bad unreachable" done expect_missing "hermes auth.json not listed" auth.json "$(p9 "$REG/agents" ls /hermes)" expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/agents" ls /omp)" expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/agents" ls /dsh)" sleep 0.3 expect_contains "hermes logs still served" logs "$(p9 "$REG/agents" ls /hermes)" # 3b. A file at the very top of a mirror root: its relative path is the # bare name, the one case a join onto an empty directory path gets wrong. expect_contains "a file at the top of a mirror root is listed" state.db "$(p9 "$REG/agents" ls /hermes)" expect_eq "a file at the top of a mirror root reads back" "$(cat "$FX/hermes/state.db")" \ "$(p9 "$REG/agents" read /hermes/state.db)" # 3c. A directory bigger than the listing caps fails loudly. A short # listing is indistinguishable from a small directory, so the daemon must # never answer one: the read errors and the client sees it. mkdir -p "$FX/dsh/wide" seq 1 1100 | while read -r i; do : > "$FX/dsh/wide/f$(printf %05d "$i")"; done if p9 "$REG/agents" ls /dsh/wide > "$TMP/wide.out" 2>"$TMP/wide.err"; then fail "an over-cap directory fails instead of truncating" "listed $(wc -l < "$TMP/wide.out") entries" else pass "an over-cap directory fails instead of truncating ($(head -c 80 "$TMP/wide.err"))" fi rm -rf "$FX/dsh/wide" # 4. Live visibility: a file appended after the daemon started. printf '{"type":"assistant","message":"third line"}\n' >> "$FX/claude/projects/-tmp-proj/session-abc.jsonl" expect_eq "append after start is visible immediately" \ "$(cat "$FX/claude/projects/-tmp-proj/session-abc.jsonl")" \ "$(p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl)" mkdir -p "$FX/claude/projects/-tmp-proj2" mkfile "$FX/claude/projects/-tmp-proj2/session-new.jsonl" '{"new":true} ' expect_contains "new session dir appears at once" -tmp-proj2 "$(p9 "$REG/agents" ls /claude/projects)" # 5. The facts and the write refusal. DAEMON_PID_TEXT=$(p9 "$REG/agents" read /pid) expect_eq "pid fact answers the daemon's pid" "$DAEMON_PID" "$DAEMON_PID_TEXT" [ -n "$(p9 "$REG/agents" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty" printf 'x' | p9 "$REG/agents" write /pid >/dev/null 2>&1 \ && fail "write answers EPERM" "write succeeded" \ || pass "write answers EPERM" # 6. The --unix listen form beside the post. "$H9" --unix "$TMP/plain.sock" --no-post --root "claude=$FX/claude" >"$TMP/d2.log" 2>&1 & PIDS+=($!) for _ in $(seq 1 100); do [ -S "$TMP/plain.sock" ] && break; sleep 0.05; done expect_eq "--unix listen form serves" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$TMP/plain.sock" read /claude/history)" # 7. The --fd listen form: one 9P session over a connected stream fd # (the 9ns --spawn / socket-activation shape), driven through a # socketpair: the daemon sees EOF when both ends close and exits. if command -v python3 >/dev/null; then python3 - "$H9" "$FX" <<'EOF' import os, socket, subprocess, sys h9, fx = sys.argv[1], sys.argv[2] a, b = socket.socketpair() pid = os.fork() if pid == 0: a.close() os.dup2(b.fileno(), 7) os.execv(h9, [h9, "--fd", "7", "--root", f"claude={fx}/claude"]) b.close() a.close() os.waitpid(pid, 0) EOF pass "--fd listen form runs a session and exits on hangup" else echo "skip - --fd form: python3 not available" fi kill "$DAEMON_PID" 2>/dev/null wait "$DAEMON_PID" 2>/dev/null sleep 0.2 [ -S "$REG/agents" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name" # ============================================================================ echo "# part C: /active, the derived view" # ============================================================================ # A fake agent: a process whose argv[0] basename is a harness name, with a # session record Claude Code's own shape. The proc root is the real /proc # (the fixture seam is unit-tested); nothing real is ever killed, because # the only process this part touches is the sleep it started itself. ACT="$TMP/act" mkdir -p "$ACT/bin" "$ACT/home/.claude/sessions" "$ACT/home/.claude/projects" "$ACT/rt/9p/zmx" cp /bin/sleep "$ACT/bin/claude" # A zmx that records how it was called and posts the name, as the real one # does; a move must never be tested against the user's live sessions. cat > "$ACT/bin/zmx" < "$ACT/zmx-argv" : > "$ACT/rt/9p/zmx/\$2" exit 0 ZEOF chmod +x "$ACT/bin/zmx" "$ACT/bin/claude" 600 & AGENT=$! PIDS+=("$AGENT") sleep 0.3 # Field 22 of /proc//stat, counted from the last ')' — the executable # name can hold spaces and parentheses, so the line is never just split. AGENT_START=$(sed 's/.*) //' "/proc/$AGENT/stat" | awk '{print $20}') AGENT_CWD=$(readlink "/proc/$AGENT/cwd") printf '{"pid":%d,"sessionId":"sess-e2e","cwd":"%s","name":"e2e-agent","status":"idle","procStart":"%s"}\n' \ "$AGENT" "$AGENT_CWD" "$AGENT_START" > "$ACT/home/.claude/sessions/$AGENT.json" # Its transcript, for the chat. Claude Code's store is named after the cwd, # every byte that is not a letter or a digit dashed; it has to exist before # the scan that finds the agent, as it does for a real one. SLUG=$(printf %s "$AGENT_CWD" | sed 's/[^A-Za-z0-9]/-/g') CT="$ACT/home/.claude/projects/$SLUG/sess-e2e.jsonl" mkdir -p "$(dirname "$CT")" cat > "$CT" <<'CEOF' {"type":"user","message":{"role":"user","content":"list the files"},"timestamp":"2026-09-24T12:51:26.691Z"} {"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}} {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"a\nb"}]}} {"type":"attachment","attachment":{"type":"reminder"}} {"type":"assistant","message":{"content":[{"type":"text","text":"two files: \"a\" and \"b\""}]}} CEOF act_roots() { echo --root "claude=$ACT/home/.claude" --root "codex=$ACT/home/.codex" \ --root "omp=$ACT/home/.omp" --root "hermes=$ACT/home/.hermes" --root "dsh=$ACT/home/.dsh" } # First: the read-only default. No --allow-move, so zmx cannot be written. XDG_RUNTIME_DIR="$ACT/rt" "$H9" --no-post --unix "$ACT/ro.sock" $(act_roots) >"$ACT/ro.log" 2>&1 & PIDS+=("$!") wait_posted "$ACT/ro.sock" || { fail "read-only daemon listens" "$(cat "$ACT/ro.log")"; } expect_contains "the agent lists under its harness" "$AGENT" "$(p9 "$ACT/ro.sock" ls /active/claude)" expect_eq "its session comes from the harness's own record" "sess-e2e" "$(p9 "$ACT/ro.sock" read "/active/claude/$AGENT/session")" expect_eq "the route taken is named" "registry" "$(p9 "$ACT/ro.sock" read "/active/claude/$AGENT/via")" expect_eq "the harness's own name is served" "e2e-agent" "$(p9 "$ACT/ro.sock" read "/active/claude/$AGENT/name")" expect_eq "the harness's own status is served" "idle" "$(p9 "$ACT/ro.sock" read "/active/claude/$AGENT/status")" # The chat: the transcript as one file per message, named -. CHAT="/active/claude/$AGENT/chat" expect_contains "the agent has a chat" "chat" "$(p9 "$ACT/ro.sock" ls "/active/claude/$AGENT")" expect_eq "the chat lists its messages by id and kind, tools by name" "00000000-user 00000001-bash 00000002-bash-result 00000003-assistant" \ "$(p9 "$ACT/ro.sock" ls "$CHAT" | tr '\n' ' ' | sed 's/ $//')" expect_eq "a message reads as its text" "list the files" "$(p9 "$ACT/ro.sock" read "$CHAT/00000000-user")" expect_eq "a tool call reads as its name, then its input" 'Bash {"command":"ls"}' "$(p9 "$ACT/ro.sock" read "$CHAT/00000001-bash")" expect_eq "escapes are undone" 'two files: "a" and "b"' "$(p9 "$ACT/ro.sock" read "$CHAT/00000003-assistant")" # The length a stat reports must be what a read returns, or a kernel # mount would cut the file short. expect_eq "a message reads its whole length" "4" "$(p9 "$ACT/ro.sock" read "$CHAT/00000002-bash-result" | wc -c)" expect_eq "a message's length is what reading it returns" "4" \ "$(p9 "$ACT/ro.sock" ls -l "$CHAT/00000002-bash-result" | awk '{print $6}')" p9 "$ACT/ro.sock" read "$CHAT/00000000-assistant" >/dev/null 2>&1 \ && fail "a name with the wrong kind is no file" || pass "a name with the wrong kind is no file" echo '{"type":"user","message":{"content":"thanks"}}' >> "$CT" expect_eq "a message written later takes the next id" "00000004-user" "$(p9 "$ACT/ro.sock" ls "$CHAT" | tail -1)" expect_eq "and reads at once" "thanks" "$(p9 "$ACT/ro.sock" read "$CHAT/00000004-user")" # Through the kernel: a mount holds a fid for every inode it caches, and a # long chat is thousands of them. Every message must stat, read and match. if [ -n "${NS:-}" ]; then for i in $(seq 1 1000); do printf '{"type":"user","message":{"content":"m%d"}}\n' "$i"; done >> "$CT" KOUT=$("$NS" --unix "$ACT/ro.sock" --name a -- sh -c ' C=$NINE_MOUNT/active/claude/'"$AGENT"'/chat echo "files=$(ls "$C" | wc -l)" ls -l "$C" >/dev/null 2>&1 && echo "lsl=ok" || echo "lsl=failed" echo "read=$(cat "$C"/* 2>/dev/null | wc -c)" echo "stat=$(stat -c %s "$C"/* | paste -sd+ | bc)"' 2>&1) expect_contains "a 1005-message chat lists through a kernel mount" "files=1005" "$KOUT" expect_contains "and every message stats there" "lsl=ok" "$KOUT" expect_eq "and reads there exactly as long as it stats" \ "$(echo "$KOUT" | sed -n 's/^stat=//p')" "$(echo "$KOUT" | sed -n 's/^read=//p')" else echo "SKIP: kernel-mount chat check needs 9ns" fi # A codex agent: found by the rollout it holds open, which the fake holds # as its stdin. RO="$ACT/home/.codex/sessions/2026/09/21/rollout-2026-09-21T10-00-00-01a0bcd2-5653-7cc0-aa36-676f6467a72a.jsonl" mkdir -p "$(dirname "$RO")" cat > "$RO" <<'REOF' {"timestamp":"2026-09-21T10:00:00.000Z","type":"session_meta","payload":{"id":"01a0bcd2-5653-7cc0-aa36-676f6467a72a"}} {"timestamp":"2026-09-21T10:00:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi codex"}]}} {"timestamp":"2026-09-21T10:00:01.000Z","type":"event_msg","payload":{"type":"user_message","message":"hi codex"}} {"timestamp":"2026-09-21T10:00:02.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":[\"ls\"]}","call_id":"c1"}} {"timestamp":"2026-09-21T10:00:03.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"x\n"}} REOF cp /bin/sleep "$ACT/bin/codex" "$ACT/bin/codex" 600 < "$RO" & CODEX=$! PIDS+=("$CODEX") sleep 0.3 expect_eq "a codex agent's chat comes from the rollout it holds" "00000000-user 00000001-shell 00000002-shell-result" \ "$(p9 "$ACT/ro.sock" ls "/active/codex/$CODEX/chat" | tr '\n' ' ' | sed 's/ $//')" expect_eq "its tool call reads as name and arguments" 'shell {"command":["ls"]}' "$(p9 "$ACT/ro.sock" read "/active/codex/$CODEX/chat/00000001-shell")" kill "$CODEX" 2>/dev/null if echo "nope" | p9 "$ACT/ro.sock" write "/active/claude/$AGENT/zmx" 2>/dev/null; then fail "without --allow-move the tree stays read-only" "the write was accepted" else pass "without --allow-move the tree stays read-only" fi expect_eq "a refused move leaves the agent running" "yes" "$([ -d "/proc/$AGENT" ] && echo yes)" # Now a daemon that allows moves. XDG_RUNTIME_DIR="$ACT/rt" "$H9" --no-post --unix "$ACT/rw.sock" $(act_roots) \ --allow-move --zmx "$ACT/bin/zmx" >"$ACT/rw.log" 2>&1 & PIDS+=("$!") wait_posted "$ACT/rw.sock" || { fail "move-allowing daemon listens" "$(cat "$ACT/rw.log")"; } # Every refusal must come before anything is destroyed. for bad in "has space" "../escape" "semi;colon"; do echo "$bad" | p9 "$ACT/rw.sock" write "/active/claude/$AGENT/zmx" 2>/dev/null if [ $? -eq 0 ]; then fail "an illegal zmx name is refused ($bad)"; else pass "an illegal zmx name is refused ($bad)"; fi done : > "$ACT/rt/9p/zmx/occupied" echo "occupied" | p9 "$ACT/rw.sock" write "/active/claude/$AGENT/zmx" 2>/dev/null \ && fail "a name a live session holds is refused" || pass "a name a live session holds is refused" expect_eq "no refusal killed the agent" "yes" "$([ -d "/proc/$AGENT" ] && echo yes)" # The move itself. if echo "e2e-moved" | p9 "$ACT/rw.sock" write "/active/claude/$AGENT/zmx" 2>"$ACT/move.err"; then pass "a legal name moves the agent" else fail "a legal name moves the agent" "$(cat "$ACT/move.err")" fi sleep 0.4 expect_eq "the agent it replaced is gone" "gone" "$([ -d "/proc/$AGENT" ] || echo gone)" # The command is fixed by the harness: the client supplied only the name. expect_eq "zmx ran the harness with its session resumed" \ "run e2e-moved -d claude --resume sess-e2e" "$(cat "$ACT/zmx-argv")" expect_missing "no client byte reached the command" "e2e-moved -d claude --resume sess-e2e;" "$(cat "$ACT/zmx-argv")" # ============================================================================ echo "# part B: the real roots, the real registry (read-only)" # ============================================================================ export XDG_RUNTIME_DIR="${HARNESS_TMP_XDG:-/run/user/$(id -u)}" REAL_REG="$XDG_RUNTIME_DIR/9p" # A scratch name, not `agents`: the whole point of part B is to read the real # roots through the real registry, and that has nothing to do with which name # the tree is posted under. Taking `agents` would mean this suite could only # run while the machine's own 9agents.service was stopped — so it would either # be skipped on any machine that actually uses the daemon, or fight it. REAL_NAME="agents-itest-$$" REAL_SOCKET="$REAL_REG/$REAL_NAME" if [ -e "$REAL_SOCKET" ]; then # $$ collided with a leftover socket from a crashed run of this suite. echo "SKIP: scratch name $REAL_NAME is already taken" else HOME_DIR=$(getent passwd "$(id -u)" | cut -d: -f6) start_daemon --name "$REAL_NAME" if wait_posted "$REAL_SOCKET"; then expect_contains "posted name listed in the real registry" "$REAL_NAME" "$(ls "$REAL_REG")" expect_contains "ls / shows the claude root" claude "$(p9 "$REAL_SOCKET" ls /)" # A real transcript, byte-identical through the tree. REAL_T=$(ls "$HOME_DIR/.claude/projects"/*/*.jsonl 2>/dev/null | head -n 1 || true) if [ -n "$REAL_T" ]; then REL="${REAL_T#"$HOME_DIR/.claude/projects/"}" p9 "$REAL_SOCKET" read "/claude/projects/$REL" > "$TMP/real-via-9p" 2>/dev/null \ && cmp -s "$TMP/real-via-9p" "$REAL_T" \ && pass "real transcript byte-identical through the tree" \ || fail "real transcript byte-identical through the tree" "$REAL_T" else echo "skip - no real claude transcript found" fi # The credentials at ~/.claude are unreachable: no mount serves the # root dir, and the exclusion rule holds everywhere else. p9 "$REAL_SOCKET" read /claude/.credentials.json >/dev/null 2>&1 \ && fail "live .credentials.json unreachable" "read succeeded" \ || pass "live .credentials.json unreachable" CLAUDE_LS=$(p9 "$REAL_SOCKET" ls /claude) expect_missing "no credentials leaked into /claude" credentials "$CLAUDE_LS" # The money shot: the mntgen mount every interactive fish sees. if [ -n "$NS" ]; then OUT=$(timeout 60 "$NS" --mntgen -- sh -c "ls /mnt/9p/$REAL_NAME && head -c 200 /mnt/9p/$REAL_NAME/claude/history" 2>"$TMP/mntgen.err") RC=$? if [ $RC -eq 0 ]; then expect_contains "mntgen mount lists the agents tree" claude "$OUT" expect_contains "mntgen mount reads claude/history" claude "$OUT" else fail "mntgen money shot (exit $RC)" "$(cat "$TMP/mntgen.err")" fi else echo "skip - mntgen money shot: 9ns not provided" fi kill "$DAEMON_PID" 2>/dev/null wait "$DAEMON_PID" 2>/dev/null sleep 0.2 [ -S "$REAL_SOCKET" ] && fail "stop unposts the real name" "socket still there" || pass "stop unposts the real name" else fail "daemon posts into the real registry" "$(cat "$TMP/daemon.log")" fi fi echo "# $PASSED passed, $FAILED failed" [ "$FAILED" -eq 0 ]