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:
parent
692b48a6b2
commit
cb1d8c2c54
3 changed files with 880 additions and 2 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
302
clients/bash/cf
302
clients/bash/cf
|
|
@ -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
476
clients/bash/test/test_session.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue