clawdforge/clients/bash/test/test_session.sh
Kayos cb1d8c2c54 clients/bash: v0.2 multi-turn session subcommands
- cf session new / turn / close / list / get
- --json flag mirrors v0.1 convention
- close is idempotent (exit 0 on already-closed)
- Bearer hygiene preserved (regression guard test)
- tests/test_session.sh: ~18 tests, 44 assertions
- README "Sessions (v0.2)" section

v0.1 subcommands unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 07:00:40 -07:00

476 lines
16 KiB
Bash
Executable file

#!/usr/bin/env bash
# test_session.sh — smoke tests for v0.2 session subcommands in clients/bash/cf
#
# Strategy: same fake-curl pattern as test_cf.sh but the fake reads the
# requested URL/method from the captured argv and emits a canned per-endpoint
# response body. The body for each endpoint is configurable via env vars so
# individual tests can drive 200/4xx/5xx and already_closed shapes without
# rewriting the fake.
#
# Run:
# bash clients/bash/test/test_session.sh
#
# Exit 0 on all-pass, 1 on first failure.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CF="$HERE/../cf"
[[ -x "$CF" ]] || { echo "fatal: cf not found or not exec at $CF" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bin" "$WORK/cfgdir"
REQ_LOG="$WORK/req.log"
# Fake curl tuned for /sessions endpoints. Behavior per request:
# * Records argv (and any --config / --data-binary contents) to REQ_LOG.
# * Inspects the URL and METHOD pulled from argv to pick a response body.
# * Body source can be overridden per-endpoint via env vars:
# CF_TEST_SESSIONS_NEW_BODY — POST /sessions
# CF_TEST_SESSIONS_NEW_STATUS — default 200
# CF_TEST_SESSIONS_TURN_BODY — POST /sessions/.../turn
# CF_TEST_SESSIONS_TURN_STATUS — default 200
# CF_TEST_SESSIONS_GET_BODY — GET /sessions/<id>
# CF_TEST_SESSIONS_GET_STATUS — default 200
# CF_TEST_SESSIONS_LIST_BODY — GET /sessions
# CF_TEST_SESSIONS_LIST_STATUS — default 200
# CF_TEST_SESSIONS_CLOSE_BODY — DELETE /sessions/<id>
# CF_TEST_SESSIONS_CLOSE_STATUS — default 200
# * Defaults are sensible canned bodies that pass minimal jq checks.
cat > "$WORK/bin/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
log="${CF_TEST_REQ_LOG:?must export CF_TEST_REQ_LOG}"
{
echo "__ARGV_COUNT__=$#"
for a in "$@"; do
printf '__ARGV__=%s\n' "$a"
done
prev=""
for a in "$@"; do
if [[ "$prev" == "--config" ]]; then
echo "__CONFIG_FILE__=$a"
if [[ -f "$a" ]]; then
echo "__CONFIG_CONTENTS__"
cat "$a"
echo "__END_CONFIG__"
fi
fi
if [[ "$prev" == "--data-binary" && "$a" == @* ]]; then
df="${a#@}"
echo "__DATAFILE__=$df"
if [[ -f "$df" ]]; then
echo "__DATAFILE_CONTENTS__"
cat "$df"
echo "__END_DATAFILE__"
fi
fi
if [[ "$prev" == "--data-binary" && "$a" != @* ]]; then
echo "__DATA_INLINE__"
printf '%s' "$a"
echo
echo "__END_DATA_INLINE__"
fi
prev="$a"
done
} > "$log"
# Pull METHOD and URL out of argv. The cf script uses:
# curl -sS -X METHOD URL ...
# so we can scan for -X.
method="GET"
url=""
prev=""
for a in "$@"; do
if [[ "$prev" == "-X" ]]; then method="$a"; fi
if [[ "$a" == http* && -z "$url" ]]; then url="$a"; fi
prev="$a"
done
# Pick a body + status per endpoint shape. We branch on (method, path).
# Default 200; override via env.
body=""
status=200
# Trim querystring for path matching.
path="${url#http*://*/}"
path="/${path%%\?*}"
# Build default bodies as separate vars so the env-override parameter
# expansions don't have to embed JSON braces (which collide with bash
# brace substitution).
default_new='{"ok":true,"session_id":"sess-aaa-111","agent":"claude","created_at":1714000000,"cwd":"/tmp/sess"}'
default_turn='{"ok":true,"session_id":"sess-aaa-111","turn_index":1,"events":[{"type":"text","content":"hello world"}],"stop_reason":"end_turn","duration_ms":42}'
default_list='{"ok":true,"sessions":[{"session_id":"sess-aaa-111","agent":"claude","turn_count":2,"created_at":1714000000,"closed_at":null},{"session_id":"sess-bbb-222","agent":"claude","turn_count":0,"created_at":1714000050,"closed_at":1714000099}],"count":2}'
default_get='{"ok":true,"session_id":"sess-aaa-111","agent":"claude","cwd":"/tmp/sess","created_at":1714000000,"last_turn_at":1714000050,"turn_count":1,"closed_at":1714000099,"live":false,"meta":null}'
default_close='{"ok":true}'
case "$method:$path" in
POST:/sessions)
body="${CF_TEST_SESSIONS_NEW_BODY:-$default_new}"
status="${CF_TEST_SESSIONS_NEW_STATUS:-200}"
;;
POST:/sessions/*/turn)
body="${CF_TEST_SESSIONS_TURN_BODY:-$default_turn}"
status="${CF_TEST_SESSIONS_TURN_STATUS:-200}"
;;
GET:/sessions)
body="${CF_TEST_SESSIONS_LIST_BODY:-$default_list}"
status="${CF_TEST_SESSIONS_LIST_STATUS:-200}"
;;
GET:/sessions/*)
body="${CF_TEST_SESSIONS_GET_BODY:-$default_get}"
status="${CF_TEST_SESSIONS_GET_STATUS:-200}"
;;
DELETE:/sessions/*)
body="${CF_TEST_SESSIONS_CLOSE_BODY:-$default_close}"
status="${CF_TEST_SESSIONS_CLOSE_STATUS:-200}"
;;
GET:/healthz)
body='{"ok":true,"claude_present":true,"acpx_present":true}'
;;
POST:/run)
body='{"ok":true,"result":"v0.1-pass-through"}'
;;
*)
body='{"ok":true}'
;;
esac
# Print body then the cf-status marker that _request parses.
printf '%s\n__cf_status__=%s' "$body" "$status"
exit 0
FAKE_CURL
chmod +x "$WORK/bin/curl"
# Baseline cf.env (chmod 600).
CF_ENV="$WORK/cfgdir/clawdforge/cf.env"
mkdir -p "$(dirname "$CF_ENV")"
cat > "$CF_ENV" <<EOF
CLAWDFORGE_URL=http://test.invalid:8800
CLAWDFORGE_TOKEN=tok-secret-xyz
CLAWDFORGE_ADMIN_TOKEN=admin-tok-abc
EOF
chmod 600 "$CF_ENV"
run_cf() {
XDG_CONFIG_HOME="$WORK/cfgdir" \
CF_TEST_REQ_LOG="$REQ_LOG" \
PATH="$WORK/bin:$PATH" \
"$CF" "$@"
}
PASS=0
FAIL=0
fail() { echo "FAIL: $*" >&2; FAIL=$((FAIL+1)); }
pass() { echo "PASS: $*"; PASS=$((PASS+1)); }
assert_eq() {
local got="$1" want="$2" name="$3"
if [[ "$got" == "$want" ]]; then pass "$name"
else
fail "$name — want=[$want] got=[$got]"
fi
}
assert_contains() {
local needle="$1" hay="$2" name="$3"
if [[ "$hay" == *"$needle"* ]]; then pass "$name"
else
fail "$name — expected: $needle"
echo "--- haystack ---" >&2
printf '%s\n' "$hay" >&2
echo "--- end ---" >&2
fi
}
assert_not_contains() {
local needle="$1" hay="$2" name="$3"
if [[ "$hay" != *"$needle"* ]]; then pass "$name"
else
fail "$name — found unexpectedly: $needle"
echo "--- haystack ---" >&2
printf '%s\n' "$hay" >&2
echo "--- end ---" >&2
fi
}
# Pull a JSON object body out of REQ_LOG (between __DATA_INLINE__ markers).
read_body() {
local body="" in_block=0 line
while IFS= read -r line; do
if [[ "$line" == "__DATA_INLINE__" ]]; then in_block=1; continue; fi
if [[ "$line" == "__END_DATA_INLINE__" ]]; then in_block=0; continue; fi
if (( in_block )); then body+="$line"$'\n'; fi
done < "$REQ_LOG"
body="${body%$'\n'}"
printf '%s' "$body"
}
# ---- Test 1: cf session new prints just the UUID -------------------------
echo "# Test 1: session new prints UUID only"
: > "$REQ_LOG"
out="$(run_cf session new)"
assert_eq "$out" "sess-aaa-111" "T1.session-new-uuid-only"
# Sanity: the URL we hit was POST /sessions
url_argv="$(grep '^__ARGV__=http' "$REQ_LOG" | head -n1 || true)"
assert_contains "/sessions" "$url_argv" "T1.session-new-url"
# ---- Test 2: cf session new --json prints full JSON ----------------------
echo "# Test 2: session new --json full body"
: > "$REQ_LOG"
out="$(run_cf session new --json)"
if command -v jq >/dev/null 2>&1; then
ok="$(jq -r '.ok' <<<"$out" 2>/dev/null || echo error)"
assert_eq "$ok" "true" "T2.session-new-json-ok"
sid="$(jq -r '.session_id' <<<"$out" 2>/dev/null || echo error)"
assert_eq "$sid" "sess-aaa-111" "T2.session-new-json-sid"
else
assert_contains "session_id" "$out" "T2.session-new-json-fallback"
fi
# ---- Test 3: cf session new --agent / --meta hit body -------------------
echo "# Test 3: session new --agent + --meta in body"
: > "$REQ_LOG"
run_cf session new --agent claude --meta '{"label":"unit-test","n":3}' >/dev/null
body="$(read_body)"
if command -v jq >/dev/null 2>&1; then
agent_val="$(jq -r '.agent' <<<"$body" 2>/dev/null || echo error)"
assert_eq "$agent_val" "claude" "T3.body-agent"
meta_label="$(jq -r '.meta.label' <<<"$body" 2>/dev/null || echo error)"
assert_eq "$meta_label" "unit-test" "T3.body-meta-label"
meta_n="$(jq -r '.meta.n' <<<"$body" 2>/dev/null || echo error)"
assert_eq "$meta_n" "3" "T3.body-meta-n"
fi
# ---- Test 4: --meta rejects non-object literal --------------------------
echo "# Test 4: --meta non-object rejected"
set +e
out="$(run_cf session new --meta '"just-a-string"' 2>&1)"; rc=$?
set -e
if (( rc != 0 )) && [[ "$out" == *"--meta must be a JSON object"* ]]; then
pass "T4.meta-rejects-non-object"
else
fail "T4.meta-rejects-non-object — rc=$rc out=$out"
fi
# ---- Test 5: cf session turn writes text to stdout -----------------------
echo "# Test 5: session turn default = text events"
: > "$REQ_LOG"
out="$(run_cf session turn sess-aaa-111 'hi there')"
assert_eq "$out" "hello world" "T5.turn-prints-text"
# Body sanity
body="$(read_body)"
if command -v jq >/dev/null 2>&1; then
prompt_val="$(jq -r '.prompt' <<<"$body" 2>/dev/null || echo error)"
assert_eq "$prompt_val" "hi there" "T5.turn-body-prompt"
fi
# ---- Test 6: cf session turn --json writes full JSON ---------------------
echo "# Test 6: session turn --json full JSON"
: > "$REQ_LOG"
out="$(run_cf session turn sess-aaa-111 'hi' --json)"
if command -v jq >/dev/null 2>&1; then
ti="$(jq -r '.turn_index' <<<"$out" 2>/dev/null || echo err)"
assert_eq "$ti" "1" "T6.turn-json-turn-index"
evcount="$(jq -r '.events | length' <<<"$out" 2>/dev/null || echo err)"
assert_eq "$evcount" "1" "T6.turn-json-events-count"
fi
# ---- Test 7: cf session turn --files goes through escape pipeline -------
echo "# Test 7: session turn --files JSON injection guard"
: > "$REQ_LOG"
# Quote-escape attempt without a comma
run_cf session turn sess-aaa-111 'go' --files 'tok_a"]:"smug":"y' >/dev/null
body="$(read_body)"
if command -v jq >/dev/null 2>&1; then
has_smug="$(jq -r 'has("smug")' <<<"$body" 2>/dev/null || echo err)"
assert_eq "$has_smug" "false" "T7.turn-no-injection"
files_first="$(jq -r '.files[0]' <<<"$body" 2>/dev/null || echo err)"
assert_eq "$files_first" 'tok_a"]:"smug":"y' "T7.turn-files-roundtrip"
fi
# ---- Test 8: cf session turn --trace writes JSON to file ----------------
echo "# Test 8: session turn --trace writes JSON to file"
TRACE="$WORK/trace.json"
: > "$REQ_LOG"
run_cf session turn sess-aaa-111 'go' --trace "$TRACE" >/dev/null
if [[ -s "$TRACE" ]]; then
if command -v jq >/dev/null 2>&1; then
sid="$(jq -r '.session_id' <"$TRACE" 2>/dev/null || echo err)"
assert_eq "$sid" "sess-aaa-111" "T8.trace-file-contents"
else
assert_contains "sess-aaa-111" "$(cat "$TRACE")" "T8.trace-file-contents"
fi
else
fail "T8.trace-file-contents — trace file empty/missing"
fi
# ---- Test 9: cf session close → "closed" first time ---------------------
echo "# Test 9: session close idempotency"
: > "$REQ_LOG"
out="$(run_cf session close sess-aaa-111)"
assert_eq "$out" "closed" "T9.close-first-time"
# Second call → already-closed (server returns already_closed:true)
: > "$REQ_LOG"
out="$(CF_TEST_SESSIONS_CLOSE_BODY='{"ok":true,"already_closed":true}' \
run_cf session close sess-aaa-111)"
assert_eq "$out" "already-closed" "T9.close-second-time"
# Both calls exited 0 (we'd have failed earlier under set -e).
pass "T9.close-both-exit-0"
# ---- Test 10: cf session list prints expected count ---------------------
echo "# Test 10: session list table"
: > "$REQ_LOG"
out="$(run_cf session list)"
# Header + 2 rows = 3 lines
line_count="$(printf '%s' "$out" | grep -c .)"
assert_eq "$line_count" "3" "T10.list-3-lines"
assert_contains "sess-aaa-111" "$out" "T10.list-row-1"
assert_contains "sess-bbb-222" "$out" "T10.list-row-2"
# --json variant
: > "$REQ_LOG"
out_json="$(run_cf session list --json)"
if command -v jq >/dev/null 2>&1; then
count="$(jq -r '.count' <<<"$out_json" 2>/dev/null || echo err)"
assert_eq "$count" "2" "T10.list-json-count"
fi
# ---- Test 11: cf session get returns state with closed_at ---------------
echo "# Test 11: session get state shape"
: > "$REQ_LOG"
out="$(run_cf session get sess-aaa-111)"
if command -v jq >/dev/null 2>&1; then
closed="$(jq -r '.closed_at' <<<"$out" 2>/dev/null || echo err)"
assert_eq "$closed" "1714000099" "T11.get-closed-at"
sid="$(jq -r '.session_id' <<<"$out" 2>/dev/null || echo err)"
assert_eq "$sid" "sess-aaa-111" "T11.get-session-id"
fi
# ---- Test 12: 404 (cross-token) bubbles up as exit 4 + non-bearer msg ---
echo "# Test 12: cross-token 404 handling"
: > "$REQ_LOG"
set +e
out="$(CF_TEST_SESSIONS_GET_STATUS=404 \
CF_TEST_SESSIONS_GET_BODY='{"detail":"session not found"}' \
run_cf session get sess-aaa-111 2>&1)"
rc=$?
set -e
if (( rc == 4 )); then
pass "T12.404-exit-4"
else
fail "T12.404-exit-4 — rc=$rc out=$out"
fi
assert_contains "session not found" "$out" "T12.404-error-message"
# Hard regression guard: bearer NEVER appears in error stream.
assert_not_contains "tok-secret-xyz" "$out" "T12.404-no-bearer-leak"
# ---- Test 13: 500 turn failure bubbles up as exit 5 + no bearer ---------
echo "# Test 13: 500 turn failure"
: > "$REQ_LOG"
set +e
out="$(CF_TEST_SESSIONS_TURN_STATUS=500 \
CF_TEST_SESSIONS_TURN_BODY='{"detail":"acpx pool full"}' \
run_cf session turn sess-aaa-111 hi 2>&1)"
rc=$?
set -e
if (( rc == 5 )); then
pass "T13.500-exit-5"
else
fail "T13.500-exit-5 — rc=$rc out=$out"
fi
assert_contains "acpx pool full" "$out" "T13.500-error-message"
assert_not_contains "tok-secret-xyz" "$out" "T13.500-no-bearer-leak"
# ---- Test 14: bearer not in argv on /sessions calls (S1 regression) -----
echo "# Test 14: bearer hygiene on /sessions/* (S1 regression guard)"
: > "$REQ_LOG"
run_cf session new >/dev/null
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T14.new-no-bearer-in-argv"
# But it MUST be in the --config file contents.
assert_contains "Bearer tok-secret-xyz" "$(cat "$REQ_LOG")" "T14.new-bearer-in-config"
: > "$REQ_LOG"
run_cf session turn sess-aaa-111 hi >/dev/null
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T14.turn-no-bearer-in-argv"
: > "$REQ_LOG"
run_cf session close sess-aaa-111 >/dev/null
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T14.close-no-bearer-in-argv"
: > "$REQ_LOG"
run_cf session list >/dev/null
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T14.list-no-bearer-in-argv"
: > "$REQ_LOG"
run_cf session get sess-aaa-111 >/dev/null
argv_only="$(grep '^__ARGV__=' "$REQ_LOG" || true)"
assert_not_contains "tok-secret-xyz" "$argv_only" "T14.get-no-bearer-in-argv"
# ---- Test 15: invalid session id rejected before HTTP call --------------
echo "# Test 15: invalid session id (path traversal)"
: > "$REQ_LOG"
set +e
out="$(run_cf session get '../../etc/passwd' 2>&1)"; rc=$?
set -e
if (( rc == 2 )) && [[ "$out" == *"invalid session id"* ]]; then
pass "T15.bad-id-rejected"
else
fail "T15.bad-id-rejected — rc=$rc out=$out"
fi
# And no curl call was made (REQ_LOG should be empty).
if [[ ! -s "$REQ_LOG" ]]; then
pass "T15.bad-id-no-network"
else
fail "T15.bad-id-no-network — REQ_LOG non-empty"
fi
# ---- Test 16: session turn - reads stdin --------------------------------
echo "# Test 16: session turn - reads stdin"
: > "$REQ_LOG"
out="$(echo 'piped-prompt-here' | run_cf session turn sess-aaa-111 -)"
assert_eq "$out" "hello world" "T16.turn-stdin-stdout"
body="$(read_body)"
if command -v jq >/dev/null 2>&1; then
prompt_val="$(jq -r '.prompt' <<<"$body" 2>/dev/null || echo err)"
# cat preserves the trailing newline only if present in input
assert_contains "piped-prompt-here" "$prompt_val" "T16.turn-stdin-body"
fi
# ---- Test 17: v0.1 cf run regression — unchanged path ------------------
echo "# Test 17: v0.1 cf run still works (regression)"
: > "$REQ_LOG"
out="$(run_cf run 'hi' --model sonnet)"
if command -v jq >/dev/null 2>&1; then
result="$(jq -r '.result' <<<"$out" 2>/dev/null || echo err)"
assert_eq "$result" "v0.1-pass-through" "T17.run-pass-through"
fi
url_argv="$(grep '^__ARGV__=http' "$REQ_LOG" | head -n1 || true)"
assert_contains "/run" "$url_argv" "T17.run-hits-/run"
assert_not_contains "/sessions" "$url_argv" "T17.run-doesnt-hit-/sessions"
# ---- Test 18: unknown session subcommand -------------------------------
echo "# Test 18: unknown session subcommand"
set +e
out="$(run_cf session bogus 2>&1)"; rc=$?
set -e
if (( rc == 2 )) && [[ "$out" == *"unknown session subcommand"* ]]; then
pass "T18.unknown-subcmd"
else
fail "T18.unknown-subcmd — rc=$rc out=$out"
fi
echo ""
echo "============================="
echo "PASS: $PASS FAIL: $FAIL"
echo "============================="
if (( FAIL > 0 )); then exit 1; fi
exit 0