#!/usr/bin/env bash # cf — bash CLI for clawdforge # # Usage: # cf healthz # cf run "" [--model sonnet] [--system "..."] [--timeout 60] [--files token1,token2] # cf run - # read prompt from stdin (any size) # 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 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 + /sessions # CLAWDFORGE_ADMIN_TOKEN — required for admin/* subcommands # # The cf.env file MUST have permissions 0600 or 0400 and be owned by you, # or cf will refuse to source it. # # Output: # - successful command → response JSON to stdout (pipe to jq freely) # - errors → message to stderr, non-zero exit code # # Exit codes: # 0 ok # 1 generic error / curl failure # 2 bad usage # 3 missing token # 4 HTTP 4xx (auth / not-found / bad-request) # 5 HTTP 5xx (server / claude failure) # --- end help --- set -euo pipefail # ---------- config loading ------------------------------------------------- _CFG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/clawdforge/cf.env" # S5: Refuse to source cf.env unless perms are 0600/0400 and owner-matched. _check_cfg_perms() { local f="$1" mode="" owner="" if stat -c '%a %U' "$f" >/dev/null 2>&1; then # GNU stat (Linux) read -r mode owner < <(stat -c '%a %U' "$f") elif stat -f '%Lp %Su' "$f" >/dev/null 2>&1; then # BSD stat (macOS) read -r mode owner < <(stat -f '%Lp %Su' "$f") else echo "cf: cannot stat $f to verify perms — refusing to source" >&2 return 1 fi if [[ "$mode" != "600" && "$mode" != "400" ]]; then echo "cf: refusing to source $f — perms are $mode (must be 0600 or 0400)" >&2 echo "cf: fix with: chmod 600 $f" >&2 return 1 fi if [[ "$owner" != "$(id -un)" ]]; then echo "cf: refusing to source $f — owned by $owner, not $(id -un)" >&2 return 1 fi return 0 } if [[ -f "$_CFG_FILE" ]]; then if _check_cfg_perms "$_CFG_FILE"; then set -a # shellcheck disable=SC1090 . "$_CFG_FILE" set +a else exit 1 fi fi CLAWDFORGE_URL="${CLAWDFORGE_URL:-http://192.168.0.5:8800}" CLAWDFORGE_URL="${CLAWDFORGE_URL%/}" # ---------- helpers -------------------------------------------------------- _die() { echo "cf: $*" >&2; exit "${2:-1}"; } _need_token() { [[ -n "${CLAWDFORGE_TOKEN:-}" ]] || _die "CLAWDFORGE_TOKEN not set (env or ~/.config/clawdforge/cf.env)" 3 } _need_admin_token() { [[ -n "${CLAWDFORGE_ADMIN_TOKEN:-}" ]] || _die "CLAWDFORGE_ADMIN_TOKEN not set" 3 } # JSON-escape a single string value (no surrounding quotes). _json_escape() { local v="$1" v="${v//\\/\\\\}" v="${v//\"/\\\"}" v="${v//$'\n'/\\n}" v="${v//$'\r'/\\r}" v="${v//$'\t'/\\t}" printf '%s' "$v" } # Build a JSON array of strings from a comma-separated list. Values are # JSON-escaped. Globbing is disabled around the comma-split loop. # Args: comma-separated string _json_string_array() { local raw="$1" if [[ -z "$raw" ]]; then printf '[]'; return 0; fi local out='[' local first=1 item esc # B3: disable globbing on the split loop set -f local -a parts=() local IFS=',' # shellcheck disable=SC2206 parts=( $raw ) set +f for item in "${parts[@]}"; do [[ "$first" == 0 ]] && out+=',' first=0 esc="$(_json_escape "$item")" out+="\"$esc\"" done out+=']' printf '%s' "$out" } # URL-encode a string. Prefer jq; fall back to pure-bash. _url_encode() { local v="$1" if command -v jq >/dev/null 2>&1; then printf '%s' "$v" | jq -sRr @uri return 0 fi local i c out="" for (( i=0; i<${#v}; i++ )); do c="${v:i:1}" case "$c" in [a-zA-Z0-9._~-]) out+="$c" ;; *) out+="$(printf '%%%02X' "'$c")" ;; esac done printf '%s' "$out" } # Build a JSON object from --key value flags (each value is JSON-quoted as string). # Numbers passed as $key=number are kept numeric. Booleans true/false kept bool. # Special key syntax: # key=raw:... — value is inserted verbatim (already-JSON, e.g. an array literal) _json_obj_from_assoc() { local first=1 out='{' local kv k v for kv in "$@"; do k="${kv%%=*}" v="${kv#*=}" if [[ "$first" == 0 ]]; then out+=','; fi first=0 local k_esc k_esc="$(_json_escape "$k")" out+="\"$k_esc\":" if [[ "$v" == raw:* ]]; then out+="${v#raw:}" elif [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then out+="$v" elif [[ "$v" == "true" || "$v" == "false" ]]; then out+="$v" else local esc esc="$(_json_escape "$v")" out+="\"$esc\"" fi done out+='}' printf '%s\n' "$out" } # Print the error body's "error" or "detail" field if it's JSON, else the whole body. _extract_error() { local body="$1" if command -v jq >/dev/null 2>&1; then local parsed parsed="$(jq -r '.error // .detail // empty' <<<"$body" 2>/dev/null || true)" if [[ -n "$parsed" ]]; then printf '%s' "$parsed" return 0 fi fi printf '%s' "$body" } # Holds the path of any tmp file created within the current shell process so # the EXIT trap can clean it up even on _die-driven exits. (B5) _CF_TMP="" _cleanup_tmp() { if [[ -n "${_CF_TMP:-}" && -e "$_CF_TMP" ]]; then rm -f "$_CF_TMP" fi } trap _cleanup_tmp EXIT # Fire a curl request, capture body + status, exit on HTTP error. # Args: METHOD URL TOKEN [DATA_OR_- [CONTENT_TYPE]] _request() { local method="$1" url="$2" token="$3" data="${4:-}" ctype="${5:-application/json}" local body status # S1: pass bearer via curl --config <(...) so the token never appears in argv. # B4: only mktemp the stdin tmp file when actually needed. local -a curl_args=( -sS -X "$method" "$url" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' --max-time 600 ) # Auth header file (chmod 600). Always created when token is non-empty so # the bearer never lands in argv via -H. local auth_file="" if [[ -n "$token" ]]; then auth_file="$(mktemp)" chmod 600 "$auth_file" # curl --config syntax: 'header = "Authorization: Bearer "' printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file" curl_args+=(--config "$auth_file") fi if [[ -n "$data" ]]; then if [[ "$data" == "-" ]]; then _CF_TMP="$(mktemp)" cat > "$_CF_TMP" curl_args+=(-H "Content-Type: $ctype" --data-binary "@$_CF_TMP") else curl_args+=(-H "Content-Type: $ctype" --data-binary "$data") fi fi local resp rc=0 resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$? if [[ -n "$auth_file" ]]; then rm -f "$auth_file"; fi if (( rc != 0 )); then _die "curl failed: $resp" 1; fi status="${resp##*__cf_status__=}" body="${resp%__cf_status__=*}" body="${body%$'\n'}" # trim trailing newline before status marker if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then printf '%s\n' "$body" return 0 fi # B10: surface parsed .error/.detail messages instead of raw body. echo "cf: HTTP $status" >&2 local msg msg="$(_extract_error "$body")" if [[ -n "$msg" ]]; then printf '%s\n' "$msg" >&2 else printf '%s\n' "$body" >&2 fi if [[ "$status" -ge 400 && "$status" -lt 500 ]]; then exit 4; fi exit 5 } # ---------- subcommands ---------------------------------------------------- cmd_healthz() { # B8: drop Authorization entirely when no token is set. local tok="${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}" _request GET "$CLAWDFORGE_URL/healthz" "$tok" } cmd_run() { _need_token local prompt="" model="" system="" timeout_secs="" files="" if [[ $# -lt 1 ]]; then _die "usage: cf run [--model] [--system] [--timeout] [--files]" 2; fi prompt="$1"; shift if [[ "$prompt" == "-" ]]; then # B1: refuse to read from a TTY — would hang forever. if [[ -t 0 ]]; then _die "cf run -: stdin is a TTY, expected a pipe or redirect (e.g. 'echo hi | cf run -')" 2 fi prompt="$(cat)" fi while [[ $# -gt 0 ]]; do case "$1" in --model) model="$2"; shift 2;; --system) system="$2"; shift 2;; --timeout) timeout_secs="$2"; shift 2;; --files) files="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done # Build JSON safely. S2/B2: use the shared array composer + raw: pass-through # in _json_obj_from_assoc to avoid hand-splicing the body. local args=("prompt=$prompt") [[ -n "$model" ]] && args+=("model=$model") [[ -n "$system" ]] && args+=("system=$system") [[ -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[@]}")" _request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body" } cmd_upload() { _need_token local path="${1:-}" ttl="" [[ -n "$path" && -f "$path" ]] || _die "usage: cf upload [--ttl 3600]" 2 shift while [[ $# -gt 0 ]]; do case "$1" in --ttl) ttl="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done # S6: reject ';' in upload paths — curl's -F @ syntax interprets # ;type=...;filename=... suffixes, allowing a self-attacker to override # the uploaded filename or content-type. if [[ "$path" == *';'* ]]; then _die "upload path contains ';' which curl @ syntax interprets specially — rename the file" 2 fi # S1: bearer via --config tmpfile, not -H argv. local auth_file auth_file="$(mktemp)" chmod 600 "$auth_file" printf 'header = "Authorization: Bearer %s"\n' "$CLAWDFORGE_TOKEN" > "$auth_file" local -a curl_args=( -sS -X POST "$CLAWDFORGE_URL/files" --config "$auth_file" -H "Accept: application/json" -w '\n__cf_status__=%{http_code}' --max-time 600 -F "file=@$path" ) [[ -n "$ttl" ]] && curl_args+=(-F "ttl_secs=$ttl") # B7: capture stderr like _request does so failures are useful. local resp rc=0 resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$? rm -f "$auth_file" if (( rc != 0 )); then _die "curl failed: $resp" 1; fi local status="${resp##*__cf_status__=}" local body="${resp%__cf_status__=*}"; body="${body%$'\n'}" if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then printf '%s\n' "$body" else echo "cf: HTTP $status" >&2 local msg msg="$(_extract_error "$body")" if [[ -n "$msg" ]]; then printf '%s\n' "$msg" >&2 else printf '%s\n' "$body" >&2 fi [[ "$status" -lt 500 ]] && exit 4 || exit 5 fi } cmd_admin() { local sub="${1:-}"; shift || true # B9: validate the subcommand BEFORE requiring the admin token so typos # don't get swallowed by a "missing token" error. case "$sub" in token-mint|token-list|token-revoke) ;; "") _die "usage: cf admin " 2;; *) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;; esac _need_admin_token case "$sub" in token-mint) local name="${1:-}"; shift || true [[ -n "$name" ]] || _die "usage: cf admin token-mint [--ip-cidrs cidr1,cidr2]" 2 local cidrs="" while [[ $# -gt 0 ]]; do case "$1" in --ip-cidrs) cidrs="$2"; shift 2;; *) _die "unknown flag: $1" 2;; esac done # S3: route via the shared escape pipeline. local -a args=("name=$name") local arr arr="$(_json_string_array "$cidrs")" args+=("ip_cidrs=raw:$arr") local body body="$(_json_obj_from_assoc "${args[@]}")" _request POST "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" "$body" ;; token-list) _request GET "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" ;; token-revoke) local name="${1:-}" [[ -n "$name" ]] || _die "usage: cf admin token-revoke " 2 # S4: URL-encode the name so '/', '?', '#', etc. can't smuggle path/query. local enc enc="$(_url_encode "$name")" _request DELETE "$CLAWDFORGE_URL/admin/tokens/$enc" "$CLAWDFORGE_ADMIN_TOKEN" ;; 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/^# \?//' } # ---------- dispatch ------------------------------------------------------- cmd="${1:-help}" case "$cmd" in -h|--help|help) cmd_help;; healthz) shift; cmd_healthz "$@";; run) shift; cmd_run "$@";; upload) shift; cmd_upload "$@";; admin) shift; cmd_admin "$@";; session) shift; cmd_session "$@";; *) _die "unknown command: $cmd (healthz|run|upload|admin|session|help)" 2;; esac