#!/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/ # 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/ # 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" <&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