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
This commit is contained in:
Kayos 2026-04-29 07:00:25 -07:00
parent 692b48a6b2
commit cb1d8c2c54
3 changed files with 880 additions and 2 deletions

View file

@ -47,6 +47,14 @@ cf upload <path> [--ttl 3600]
cf admin token-mint <name> [--ip-cidrs cidr1,cidr2]
cf admin token-list
cf admin token-revoke <name>
# Sessions (v0.2 — multi-turn)
cf session new [--agent claude] [--meta '{"k":"v"}'] [--json]
cf session turn <id> "<prompt>" [--files tok1,tok2] [--timeout 120] [--json] [--trace path]
cf session turn <id> - # read prompt from stdin
cf session get <id> [--json]
cf session list [--include-closed] [--json]
cf session close <id> [--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 <id>` 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 <id> "<prompt>"` | Send a prompt. Default output = concatenated text events. |
| `cf session get <id>` | 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 <id>` | 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`:

View file

@ -10,9 +10,19 @@
# cf admin token-list
# cf admin token-revoke <name>
#
# Sessions (v0.2 — multi-turn):
# cf session new [--agent claude] [--meta '{"k":"v"}'] [--json]
# cf session turn <id> "<prompt|->" [--files tok1,tok2] [--timeout 120] [--json] [--trace path]
# cf session get <id> [--json]
# cf session list [--include-closed] [--json]
# cf session close <id> [--json]
#
# Sessions don't autoclose — call `cf session close <id>` 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 <id> <prompt|-> [--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 <id> [--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 <id> [--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 <new|turn|get|list|close>" 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

476
clients/bash/test/test_session.sh Executable file
View file

@ -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/<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