diff options
Diffstat (limited to '9harness/test/e2e.sh')
| -rwxr-xr-x | 9harness/test/e2e.sh | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/9harness/test/e2e.sh b/9harness/test/e2e.sh new file mode 100755 index 0000000..6e57727 --- /dev/null +++ b/9harness/test/e2e.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# End-to-end suite for 9harness: the read-only, fresh-from-disk 9P view of +# every harness's state. +# +# Usage: bash 9harness/test/e2e.sh <9harness> [<9ns>] (zig build 9harness-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 `harness` 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/harness, 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 9harness}") +HARNESS_TMP_XDG="${XDG_RUNTIME_DIR:-}" +[ $# -ge 2 ] && [ -n "$2" ] && NS=$(realpath "$2") +P9P=/usr/lib/plan9/bin/9p +TMP=$(mktemp -d "${TMPDIR:-/tmp}/9harness.XXXXXX") +PIDS=() +FAILED=0 +PASSED=0 + +cleanup() { + for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done + rm -rf "$TMP" +} +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 harness +wait_posted "$REG/harness" || { fail "the daemon posts as harness" "$(cat "$TMP/daemon.log")"; exit 1; } + +# 1. The posted name is listed. +expect_contains "posted name listed in the registry" harness "$(ls "$REG")" + +# 2. The roots walk; a real transcript reads back byte-identical. +expect_contains "ls / shows the roots" claude "$(p9 "$REG/harness" ls /)" +expect_contains "ls / shows codex" codex "$(p9 "$REG/harness" ls /)" +expect_contains "ls / shows skills" skills "$(p9 "$REG/harness" ls /)" +p9 "$REG/harness" 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/harness" read /claude/history)" +expect_eq "skills union mirrors the harness tree" "$(cat "$FX/claude/skills/revu/SKILL.md")" \ + "$(p9 "$REG/harness" read /skills/claude/revu/SKILL.md)" + +# 3. A credentials-shaped file is unreachable anywhere in the tree. +PROJ_LS=$(p9 "$REG/harness" 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/harness" 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/harness" ls /hermes)" +expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/harness" ls /omp)" +expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/harness" ls /dsh)" +sleep 0.3 +expect_contains "hermes logs still served" logs "$(p9 "$REG/harness" 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/harness" ls /hermes)" +expect_eq "a file at the top of a mirror root reads back" "$(cat "$FX/hermes/state.db")" \ + "$(p9 "$REG/harness" 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/harness" 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/harness" 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/harness" ls /claude/projects)" + +# 5. The facts and the write refusal. +DAEMON_PID_TEXT=$(p9 "$REG/harness" read /pid) +expect_eq "pid fact answers the daemon's pid" "$DAEMON_PID" "$DAEMON_PID_TEXT" +[ -n "$(p9 "$REG/harness" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty" +printf 'x' | p9 "$REG/harness" 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/harness" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name" + +# ============================================================================ +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" +REAL_SOCKET="$REAL_REG/harness" +if [ -e "$REAL_SOCKET" ]; then + echo "SKIP: a live daemon already owns the posted name `harness`" +else + HOME_DIR=$(getent passwd "$(id -u)" | cut -d: -f6) + start_daemon --name harness + if wait_posted "$REAL_SOCKET"; then + expect_contains "posted name listed in the real registry" harness "$(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/harness && head -c 200 /mnt/9p/harness/claude/history' 2>"$TMP/mntgen.err") + RC=$? + if [ $RC -eq 0 ]; then + expect_contains "mntgen mount lists the harness 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 ] |
