summaryrefslogtreecommitdiff
path: root/9agents/test/e2e.sh
blob: fa41bef87b867f9a47fbadf45e8e27acb1e66095 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
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 ]