From cb1d8c2c549086372d0b65794c422805684783c1 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 29 Apr 2026 07:00:25 -0700 Subject: [PATCH] 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 --- clients/bash/README.md | 104 +++++++ clients/bash/cf | 302 ++++++++++++++++++- clients/bash/test/test_session.sh | 476 ++++++++++++++++++++++++++++++ 3 files changed, 880 insertions(+), 2 deletions(-) create mode 100755 clients/bash/test/test_session.sh diff --git a/clients/bash/README.md b/clients/bash/README.md index 8c14d1c..2bd6e08 100644 --- a/clients/bash/README.md +++ b/clients/bash/README.md @@ -47,6 +47,14 @@ cf upload [--ttl 3600] cf admin token-mint [--ip-cidrs cidr1,cidr2] cf admin token-list cf admin token-revoke + +# Sessions (v0.2 — multi-turn) +cf session new [--agent claude] [--meta '{"k":"v"}'] [--json] +cf session turn "" [--files tok1,tok2] [--timeout 120] [--json] [--trace path] +cf session turn - # read prompt from stdin +cf session get [--json] +cf session list [--include-closed] [--json] +cf session close [--json] ``` Output is JSON to stdout — pipe to `jq` for shaping. Errors go to stderr. @@ -103,6 +111,102 @@ cf admin token-mint johnny5 --ip-cidrs 172.24.0.0/16 # → {"name":"johnny5","token":"cf_...","ip_cidrs":["172.24.0.0/16"]} ``` +## Sessions (v0.2) + +Multi-turn sessions backed by [ACPX](https://github.com/openclaw/acpx) on +the server. Use these when you need context to persist across turns — +"build this with me step by step", "now try X… now try Y", etc. For +one-shot prompts, stick with `cf run`. + +> Sessions don't autoclose — call `cf session close ` when done. +> They time out server-side after 1h idle anyway. + +### The 5 subcommands + +| Command | Purpose | +|---|---| +| `cf session new` | Create a session. Prints just the UUID to stdout for piping. | +| `cf session turn ""` | Send a prompt. Default output = concatenated text events. | +| `cf session get ` | Print the session's state JSON (turn count, timestamps, closed_at). | +| `cf session list` | List the calling token's sessions as a tab-separated table (or JSON with `--json`). | +| `cf session close ` | Soft-close the session. Idempotent — exits 0 even on already-closed. | + +The `--json` flag mirrors the v0.1 convention: when present, every +subcommand prints the full server response JSON to stdout instead of +its default human-readable shape. + +### End-to-end: build a feature across 3 turns + +```sh +sid=$(cf session new --agent claude --meta '{"task":"add-cache-layer"}') + +cf session turn "$sid" "Read src/store.go and tell me where caching should live." + +cf session turn "$sid" "Now write a Redis-backed cache wrapping the existing Get/Put." + +cf session turn "$sid" "Add tests for cache hit / miss / TTL expiry." + +cf session close "$sid" +# → "closed" +``` + +### Default vs JSON output + +`cf session turn` defaults to concatenating just the `text` events to +stdout (matches `cf run` style). Use `--json` for the full event batch +including `thinking` and `tool_call` frames, plus `stop_reason` and +`duration_ms`: + +```sh +cf session turn "$sid" "summarize" --json | jq '.events | map(.type) | unique' +# → ["text","thinking","tool_call"] +``` + +`--trace path` writes the full JSON response to a file regardless of +`--json`. Useful when you want the human text on stdout AND a structured +audit trail in the same call: + +```sh +cf session turn "$sid" "explain the PR" --trace ./traces/turn-$(date +%s).json +``` + +### Listing and inspecting + +```sh +cf session list +# SESSION_ID AGENT TURNS CREATED_AT CLOSED_AT +# 9f1c... claude 3 1714000000 - +# c71e... claude 1 1714003600 1714005400 + +cf session list --json | jq '.sessions[] | select(.closed_at == null) | .session_id' + +cf session get "$sid" | jq '.turn_count' +``` + +### Idempotent close in cleanup paths + +`cf session close` exits 0 whether the session was already closed or not, +so it's safe to call from `trap` handlers: + +```sh +sid=$(cf session new) +trap 'cf session close "$sid" >/dev/null 2>&1 || true' EXIT + +cf session turn "$sid" "long-running task..." +# trap fires on exit; "closed" first time, "already-closed" any subsequent. +``` + +### Errors + +The same exit codes as v0.1 apply: + +- `2` — bad usage (unknown flag, invalid session id, malformed `--meta`) +- `3` — `CLAWDFORGE_TOKEN` not set +- `4` — HTTP 4xx (404 for cross-token access — server doesn't leak existence) +- `5` — HTTP 5xx (acpx pool full, internal failure, etc.) + +Bearer tokens never appear in error output, the same hardening as v0.1. + ## Pattern: bash-only no-jq fallback If you can't install `jq`, parse with `read`: diff --git a/clients/bash/cf b/clients/bash/cf index 3b5e95f..bf438f7 100755 --- a/clients/bash/cf +++ b/clients/bash/cf @@ -10,9 +10,19 @@ # cf admin token-list # cf admin token-revoke # +# Sessions (v0.2 — multi-turn): +# cf session new [--agent claude] [--meta '{"k":"v"}'] [--json] +# cf session turn "" [--files tok1,tok2] [--timeout 120] [--json] [--trace path] +# cf session get [--json] +# cf session list [--include-closed] [--json] +# cf session close [--json] +# +# Sessions don't autoclose — call `cf session close ` when you're done. +# Server-side TTL is 1h idle by default. +# # Configuration (env or ~/.config/clawdforge/cf.env, env wins): # CLAWDFORGE_URL — default http://192.168.0.5:8800 -# CLAWDFORGE_TOKEN — required for /run + /files +# CLAWDFORGE_TOKEN — required for /run + /files + /sessions # CLAWDFORGE_ADMIN_TOKEN — required for admin/* subcommands # # The cf.env file MUST have permissions 0600 or 0400 and be owned by you, @@ -410,6 +420,293 @@ cmd_admin() { esac } +# ---------- session subcommands (v0.2) ------------------------------------- +# +# Per-app multi-turn sessions backed by ACPX. Server endpoints: +# POST /sessions → create +# POST /sessions/{id}/turn → send a turn +# GET /sessions/{id} → state +# DELETE /sessions/{id} → soft-close (idempotent) +# GET /sessions → list (per-token) +# +# Session subcommands inherit the same v0.1 hygiene: +# - bearer never lands in argv (S1) — same _request helper +# - JSON injection via prompt/agent/files is impossible (S2/S3) — every +# value goes through _json_escape or _json_string_array +# - --json flag mirrors v0.1 convention; default cmd_session_turn output +# is the concatenated text-event stream (matches `cf run` ergonomics) + +# Validate a session id looks like a server-assigned identifier. Permissive +# but blocks the obvious smuggling vectors (path traversal, query, frag, +# whitespace, control chars). Server canonicalizes UUIDs but we don't pin +# the shape here in case format evolves. +_validate_session_id() { + local id="$1" + [[ -n "$id" ]] || return 1 + # Reject path / query / frag / whitespace / NUL. NOTE: shell case patterns + # treat unquoted ? and * as glob meta — match each char one at a time via + # a substring search instead of a glob class so '?' (literal) and friends + # are caught reliably. + local ch + for (( i=0; i<${#id}; i++ )); do + ch="${id:i:1}" + case "$ch" in + '/'|'?'|'#'|' '|$'\t'|$'\n'|$'\r'|$'\0') return 1 ;; + esac + done + # Length sanity — UUIDs are 36; allow up to 128 for future-proofing. + if (( ${#id} < 1 || ${#id} > 128 )); then return 1; fi + return 0 +} + +cmd_session_new() { + _need_token + local agent="" meta_raw="" json_out=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --agent) agent="$2"; shift 2;; + --meta) meta_raw="$2"; shift 2;; + --json) json_out=1; shift;; + *) _die "unknown flag: $1" 2;; + esac + done + + # Default agent server-side is "claude"; only send the field if user set it. + local -a args=() + [[ -n "$agent" ]] && args+=("agent=$agent") + + if [[ -n "$meta_raw" ]]; then + # --meta accepts a JSON object literal. Validate via jq when available + # so we don't ship a malformed body. Pass through raw: so it isn't + # double-quoted as a string. + if command -v jq >/dev/null 2>&1; then + if ! printf '%s' "$meta_raw" | jq -e 'type == "object"' >/dev/null 2>&1; then + _die "--meta must be a JSON object literal (e.g. '{\"k\":\"v\"}')" 2 + fi + fi + args+=("meta=raw:$meta_raw") + fi + + local body + if [[ ${#args[@]} -eq 0 ]]; then + body='{}' + else + body="$(_json_obj_from_assoc "${args[@]}")" + fi + + local resp + resp="$(_request POST "$CLAWDFORGE_URL/sessions" "$CLAWDFORGE_TOKEN" "$body")" + + if (( json_out )); then + printf '%s\n' "$resp" + return 0 + fi + # Default: machine-pipeable — just the UUID on stdout. + if command -v jq >/dev/null 2>&1; then + local sid + sid="$(jq -r '.session_id // empty' <<<"$resp")" + [[ -n "$sid" ]] || _die "session_id missing from server response" 1 + printf '%s\n' "$sid" + else + # Best-effort fallback parser when jq isn't installed. + local sid + sid="$(printf '%s' "$resp" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -n1 | sed -E 's/.*"session_id"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')" + [[ -n "$sid" ]] || _die "session_id missing from server response" 1 + printf '%s\n' "$sid" + fi +} + +cmd_session_turn() { + _need_token + local sid="${1:-}" prompt="${2:-}" + if [[ -z "$sid" || -z "$prompt" ]]; then + _die "usage: cf session turn [--files] [--timeout] [--json] [--trace path]" 2 + fi + _validate_session_id "$sid" || _die "invalid session id: $sid" 2 + shift 2 + + if [[ "$prompt" == "-" ]]; then + if [[ -t 0 ]]; then + _die "cf session turn -: stdin is a TTY, expected a pipe or redirect" 2 + fi + prompt="$(cat)" + fi + + local files="" timeout_secs="" json_out=0 trace_path="" + while [[ $# -gt 0 ]]; do + case "$1" in + --files) files="$2"; shift 2;; + --timeout) timeout_secs="$2"; shift 2;; + --json) json_out=1; shift;; + --trace) trace_path="$2"; shift 2;; + --trace=*) trace_path="${1#--trace=}"; shift;; + *) _die "unknown flag: $1" 2;; + esac + done + + # Build body via the shared escape pipeline (S2 hygiene applies). + local -a args=("prompt=$prompt") + [[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs") + + if [[ -n "$files" ]]; then + local farr + farr="$(_json_string_array "$files")" + args+=("files=raw:$farr") + fi + + local body + body="$(_json_obj_from_assoc "${args[@]}")" + + # URL-encode the session id so any future non-UUID format can't smuggle + # path/query parts. + local enc + enc="$(_url_encode "$sid")" + + local resp + resp="$(_request POST "$CLAWDFORGE_URL/sessions/$enc/turn" "$CLAWDFORGE_TOKEN" "$body")" + + # Emit the trace if requested. Trace = full JSON response, regardless of + # --json. Non-fatal write errors surface to stderr but don't change exit. + if [[ -n "$trace_path" ]]; then + if ! printf '%s\n' "$resp" > "$trace_path" 2>/dev/null; then + echo "cf: warning: failed to write trace to $trace_path" >&2 + fi + fi + + if (( json_out )); then + printf '%s\n' "$resp" + return 0 + fi + # Default: concatenated text events to stdout (matches `cf run` style). + if command -v jq >/dev/null 2>&1; then + # Print every text event's content concatenated, then a trailing newline + # so terminal output is well-formed. Strip embedded NULs defensively. + jq -r '.events[]? | select(.type == "text") | .content // empty' <<<"$resp" | tr -d '\0' + else + # No jq → print the whole body. Better than silent loss. + printf '%s\n' "$resp" + fi +} + +cmd_session_get() { + _need_token + local sid="${1:-}" + [[ -n "$sid" ]] || _die "usage: cf session get [--json]" 2 + _validate_session_id "$sid" || _die "invalid session id: $sid" 2 + shift + # Accept --json silently — get always returns JSON (no other rendering). + while [[ $# -gt 0 ]]; do + case "$1" in + --json) shift;; + *) _die "unknown flag: $1" 2;; + esac + done + local enc + enc="$(_url_encode "$sid")" + _request GET "$CLAWDFORGE_URL/sessions/$enc" "$CLAWDFORGE_TOKEN" +} + +cmd_session_list() { + _need_token + local json_out=0 include_closed="" + while [[ $# -gt 0 ]]; do + case "$1" in + --json) json_out=1; shift;; + --include-closed) include_closed=1; shift;; + --no-include-closed) include_closed=0; shift;; + *) _die "unknown flag: $1" 2;; + esac + done + local url="$CLAWDFORGE_URL/sessions" + if [[ "$include_closed" == "0" ]]; then + url="$url?include_closed=false" + elif [[ "$include_closed" == "1" ]]; then + url="$url?include_closed=true" + fi + + local resp + resp="$(_request GET "$url" "$CLAWDFORGE_TOKEN")" + + if (( json_out )); then + printf '%s\n' "$resp" + return 0 + fi + + # Default: human table. Falls back to JSON when jq is unavailable. + if command -v jq >/dev/null 2>&1; then + # Header + one row per session. Tab-separated; pipe to column -t for + # alignment if you like. + printf 'SESSION_ID\tAGENT\tTURNS\tCREATED_AT\tCLOSED_AT\n' + jq -r '.sessions[]? | + [(.session_id // ""), + (.agent // ""), + ((.turn_count // 0) | tostring), + ((.created_at // 0) | tostring), + (if .closed_at == null then "-" else (.closed_at | tostring) end) + ] | @tsv' <<<"$resp" + else + printf '%s\n' "$resp" + fi +} + +cmd_session_close() { + _need_token + local sid="${1:-}" + [[ -n "$sid" ]] || _die "usage: cf session close [--json]" 2 + _validate_session_id "$sid" || _die "invalid session id: $sid" 2 + shift + local json_out=0 + while [[ $# -gt 0 ]]; do + case "$1" in + --json) json_out=1; shift;; + *) _die "unknown flag: $1" 2;; + esac + done + + local enc + enc="$(_url_encode "$sid")" + + # Idempotency: server returns 200 with already_closed:true on a second + # call. Transport / 4xx / 5xx still propagate non-zero via _request. + local resp + resp="$(_request DELETE "$CLAWDFORGE_URL/sessions/$enc" "$CLAWDFORGE_TOKEN")" + + if (( json_out )); then + printf '%s\n' "$resp" + return 0 + fi + + local already="" + if command -v jq >/dev/null 2>&1; then + already="$(jq -r '.already_closed // false' <<<"$resp" 2>/dev/null || echo false)" + else + case "$resp" in + *'"already_closed"'*'true'*) already="true" ;; + *) already="false" ;; + esac + fi + + if [[ "$already" == "true" ]]; then + echo "already-closed" + else + echo "closed" + fi +} + +cmd_session() { + local sub="${1:-}"; shift || true + case "$sub" in + new|create) cmd_session_new "$@" ;; + turn) cmd_session_turn "$@" ;; + get) cmd_session_get "$@" ;; + list|ls) cmd_session_list "$@" ;; + close|delete) cmd_session_close "$@" ;; + "") _die "usage: cf session " 2 ;; + *) _die "unknown session subcommand: $sub (new|turn|get|list|close)" 2 ;; + esac +} + cmd_help() { # U3: dedicated sentinel comment so help-text extraction survives reorgs. sed -n '2,/^# --- end help ---/{/^# --- end help ---/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//' @@ -424,5 +721,6 @@ case "$cmd" in run) shift; cmd_run "$@";; upload) shift; cmd_upload "$@";; admin) shift; cmd_admin "$@";; - *) _die "unknown command: $cmd (healthz|run|upload|admin|help)" 2;; + session) shift; cmd_session "$@";; + *) _die "unknown command: $cmd (healthz|run|upload|admin|session|help)" 2;; esac diff --git a/clients/bash/test/test_session.sh b/clients/bash/test/test_session.sh new file mode 100755 index 0000000..913bf1f --- /dev/null +++ b/clients/bash/test/test_session.sh @@ -0,0 +1,476 @@ +#!/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