summaryrefslogtreecommitdiff
path: root/9agents/test/e2e.sh
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 10:30:02 -0300
committerGabriel Schneider <[email protected]>2026-09-25 17:52:33 -0300
commitdf20863879fe2d83077534f4726a985ffc239def (patch)
tree3f13dfc786509d1e2c599894a8beea48695e253c /9agents/test/e2e.sh
parenteb1a104f385f375a74319695e1a59f6f82e6384e (diff)
downloadcloud9-df20863879fe2d83077534f4726a985ffc239def.tar.gz
cloud9-df20863879fe2d83077534f4726a985ffc239def.zip
Rename 9harness to 9agents; README, file-backed qids, worded errors
- 9harness/ becomes 9agents/ (build option -D9agents, package paths). - 9agents serves /README, reports qid paths from the file's (dev, ino) and qid versions that move with the file, and names its refusals.
Diffstat (limited to '9agents/test/e2e.sh')
-rwxr-xr-x9agents/test/e2e.sh358
1 files changed, 358 insertions, 0 deletions
diff --git a/9agents/test/e2e.sh b/9agents/test/e2e.sh
new file mode 100755
index 0000000..fa41bef
--- /dev/null
+++ b/9agents/test/e2e.sh
@@ -0,0 +1,358 @@
+#!/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/<uid>/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" <<ZEOF
+#!/bin/sh
+echo "\$@" > "$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/<pid>/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"
+
+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")"
+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 ]