#!/bin/sh # End-to-end check of the WebUI without a real model: a recorded SSE stream # (tests/fixtures/sse) drives a full turn, and the events the browser would # see are read back from /api/events with curl. # # scripts/e2e_webui.sh [replay-dir] [protocol] # # Needs: curl. python3 only for the JSON field checks (they are skipped # without it). Exit code 0 = every assertion passed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPLAY="${1:-$ROOT/tests/fixtures/sse/deepseek-native-eos}" PROTO="${2:-native-eos}" BIN="${CST_AGENT:-}" if [ -z "$BIN" ]; then if [ -x "$ROOT/hermit" ]; then BIN="$ROOT/hermit" elif [ -x "$ROOT/cst-agent" ]; then BIN="$ROOT/cst-agent" fi fi [ -x "$BIN" ] || { echo "build hermit first ($ROOT/hermit)"; exit 1; } WORK="$(mktemp -d "${TMPDIR:-/tmp}/cst-webui-XXXXXX")" 2>/dev/null || { WORK="$ROOT/.build-tmp/e2e-webui"; rm -rf "$WORK"; mkdir -p "$WORK"; } EVENTS="$WORK/events.txt" LOG="$WORK/server.log" cleanup() { [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true rm -rf "$WORK" } trap cleanup EXIT INT TERM echo "== start cst-agent --web (replay $REPLAY)" # --yolo: the recorded stream calls write tools, and a permission question # would wait for a browser nobody is driving here. CST_REPLAY_SSE="$REPLAY" \ CST_REPLAY_CHUNK=512 \ "$BIN" --web --web-port 0 --workspace "$WORK" --new --api-key sk-e2e \ --provider deepseek --model deepseek-chat \ --protocol "$PROTO" --tools default --yolo > "$LOG" 2>&1 & SRV_PID=$! # The port is picked by the OS; the server prints the URL it bound. i=0 PORT="" while [ $i -lt 50 ]; do PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$LOG" | head -1)" [ -n "$PORT" ] && break i=$((i + 1)) sleep 0.2 done [ -n "$PORT" ] || { echo "FAIL: the server did not report a port"; cat "$LOG"; exit 1; } BASE="http://127.0.0.1:$PORT" echo " port $PORT" fail() { echo "FAIL: $1"; echo "--- events"; cat "$EVENTS" 2>/dev/null | head -40; exit 1; } # SIGTERM must shut the server down cleanly, even mid-turn (128+ = a crash) stop_server() { kill "$1" 2>/dev/null || return 0 n=0 while kill -0 "$1" 2>/dev/null && [ $n -lt 200 ]; do n=$((n + 1)); sleep 0.2; done if kill -0 "$1" 2>/dev/null; then kill -9 "$1" 2>/dev/null || true fail "$2 hung on SIGTERM" fi st=0 wait "$1" 2>/dev/null || st=$? [ "$st" -lt 128 ] || fail "$2 died with status $st on SIGTERM" } # 1. the health page and the state endpoint curl -fsS --max-time 5 "$BASE/" | grep -q "Hermit API" || fail "GET / is not the health page" curl -fsS --max-time 5 "$BASE/api/state" > "$WORK/state.json" || fail "GET /api/state" grep -q '"protocol":"cst-webui/1"' "$WORK/state.json" || fail "state has no protocol tag" grep -q '"capabilities"' "$WORK/state.json" || fail "state has no capabilities" grep -q '"session":' "$WORK/state.json" || fail "state has no session id" # 2. an unknown route is a 404, an oversized body a 413 code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/does-not-exist")" [ "$code" = "404" ] || fail "unknown route returned $code" code="$(head -c 2000000 /dev/zero | tr '\0' 'a' | curl -s -o /dev/null -w '%{http_code}' --max-time 20 \ -X POST --data-binary @- "$BASE/api/turn")" [ "$code" = "413" ] || fail "2 MB body returned $code (want 413)" # 3. stream the events while a recorded turn runs curl -sN --max-time 120 "$BASE/api/events" > "$EVENTS" & CURL_PID=$! sleep 0.5 code="$(curl -s -o "$WORK/turn.json" -w '%{http_code}' --max-time 5 \ -X POST -H 'Content-Type: application/json' \ --data '{"text":"e2e: what is in the workspace?"}' "$BASE/api/turn")" [ "$code" = "202" ] || fail "POST /api/turn returned $code" i=0 while [ $i -lt 450 ]; do grep -q "event: turn_end" "$EVENTS" && break i=$((i + 1)) sleep 0.2 done kill "$CURL_PID" 2>/dev/null || true grep -q "event: turn_end" "$EVENTS" || fail "the turn never finished" grep -q "event: turn_begin" "$EVENTS" || fail "no turn_begin event" grep -q "event: assistant_html" "$EVENTS" || fail "no assistant markdown was streamed" grep -q "event: tool" "$EVENTS" || fail "no tool event was streamed" grep -q '"rc":0' "$EVENTS" || fail "the turn did not end with rc=0" # 4. the session on disk matches what the UI would draw curl -fsS --max-time 5 "$BASE/api/session" > "$WORK/session.json" || fail "GET /api/session" grep -q '"role":"assistant"' "$WORK/session.json" || fail "no assistant message stored" curl -fsS --max-time 5 "$BASE/api/sessions" | grep -q '"sessions":' || fail "GET /api/sessions" # 5. a second turn while the first is running is refused with 409 curl -s -o /dev/null -w '' --max-time 5 -X POST -H 'Content-Type: application/json' \ --data '{"text":"x"}' "$BASE/api/turn" || true # 6. asking to stop is accepted curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \ --data '{}' "$BASE/api/cancel" > /dev/null || fail "POST /api/cancel" # 6b. the write endpoints: config, undo, compact and image upload curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \ --data '{"approval":"ask","plan_mode":false}' "$BASE/api/config" | grep -q '"ok":true' || fail "POST /api/config" curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"approval":"ask"' || fail "the config change is not in the state" code="$(curl -s -o "$WORK/undo.txt" -w '%{http_code}' --max-time 5 \ -X POST "$BASE/api/undo")" case "$code" in 200) grep -q . "$WORK/undo.txt" || fail "an undo without a report" ;; 409) ;; # nothing to undo in this run *) fail "POST /api/undo returned $code" ;; esac code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \ -X POST "$BASE/api/compact")" # The recorded stream is spent by now, so a model failure is a valid answer. case "$code" in 200 | 500) ;; *) fail "POST /api/compact returned $code" ;; esac if command -v base64 > /dev/null; then # a real 1x1 PNG: the server sniffs the content, not the extension # (kept with a .bin suffix so the find below only sees the stored copy) echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' | base64 -d > "$WORK/tiny.bin" 2> /dev/null || : > "$WORK/tiny.bin" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST \ --data-binary @"$WORK/tiny.bin" "$BASE/api/attach?name=e2e.png")" [ "$code" = "200" ] || fail "POST /api/attach returned $code" curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"pending_images":\["' || fail "the upload is not pending" # the stored name is the content hash, so ask the disk for it STORED="$(find "$WORK" -name '*.png' -type f 2>/dev/null | head -1)" [ -n "$STORED" ] || fail "the upload did not reach the session directory" REL="${STORED##*/}" curl -fsS --max-time 5 "$BASE/api/attach/$REL" > "$WORK/back.png" || fail "GET /api/attach/$REL" cmp -s "$WORK/tiny.bin" "$WORK/back.png" || fail "the image did not round trip byte for byte" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ "$BASE/api/attach/..%2f..%2fetc%2fpasswd")" case "$code" in 400 | 404) ;; *) fail "a traversal in attach returned $code" ;; esac curl -fsS --max-time 5 -X DELETE "$BASE/api/attach" | grep -q '"ok":true' || fail "DELETE /api/attach" curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"pending_images":\[\]' || fail "the pending image was not cleared" else echo " (no base64 tool: the image upload check is skipped)" fi # 6c. a request with a foreign Host header is refused while no token is set: # that is the DNS-rebinding guard. code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ -H 'Host: evil.example' "$BASE/api/state")" [ "$code" = "401" ] || fail "a foreign Host header returned $code (want 401)" if command -v python3 > /dev/null; then python3 - "$WORK/session.json" <<'PY' || fail "the session JSON is not valid JSON / is empty" import json, sys d = json.load(open(sys.argv[1])) assert isinstance(d.get("messages"), list) and d["messages"], d PY fi # 7. the interactive approval path: a fresh server in ask mode must raise a # permission event, and answering it must let the turn continue. echo "== permission round trip (-A ask)" stop_server "$SRV_PID" "the first server" # its replay is spent rm -rf "$WORK/pw"; mkdir -p "$WORK/pw" P_EVENTS="$WORK/permission-events.txt" P_LOG="$WORK/permission-server.log" # its own log: the port parse must not # pick up the first server's URL CST_REPLAY_SSE="$REPLAY" CST_REPLAY_CHUNK=512 \ "$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \ --provider deepseek --model deepseek-chat \ --protocol "$PROTO" --tools default --approval ask --confirm all \ > "$P_LOG" 2>&1 & P_PID=$! i=0 P_PORT="" while [ $i -lt 50 ]; do P_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$P_LOG" | tail -1)" [ -n "$P_PORT" ] && break i=$((i + 1)); sleep 0.2 done [ -n "$P_PORT" ] || fail "the ask-mode server did not start (see $P_LOG)" P_BASE="http://127.0.0.1:$P_PORT" curl -sN --max-time 60 "$P_BASE/api/events" > "$P_EVENTS" & P_CURL=$! sleep 0.5 curl -s -o /dev/null --max-time 5 -X POST -H 'Content-Type: application/json' \ --data '{"text":"e2e: ask me first"}' "$P_BASE/api/turn" i=0 while [ $i -lt 300 ]; do grep -q "event: permission" "$P_EVENTS" && break i=$((i + 1)); sleep 0.2 done if [ $i -ge 300 ]; then kill "$P_PID" 2>/dev/null || true kill "$P_CURL" 2>/dev/null || true echo " (no permission question within 60 s - skipped; $(grep -c '^event: ' "$P_EVENTS") events seen)" else PID_REQ="$(sed -n 's/.*"id":\([0-9]*\),"tool".*/\1/p' "$P_EVENTS" | head -1)" [ -n "$PID_REQ" ] || fail "a permission event without an id" # Answer while the server is still waiting for it. curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \ --data "{\"id\":$PID_REQ,\"answer\":1}" "$P_BASE/api/permission" > /dev/null || fail "POST /api/permission was refused" i=0 while [ $i -lt 150 ]; do grep -q "event: permission_done" "$P_EVENTS" && break i=$((i + 1)); sleep 0.2 done grep -q "event: permission_done" "$P_EVENTS" || fail "the answer was not acknowledged" stop_server "$P_PID" "the ask-mode server" kill "$P_CURL" 2>/dev/null || true echo " answered request $PID_REQ with 'allow once'" fi # 8. token auth, --web-dev, and a clean shutdown echo "== token auth and --web-dev" T_LOG="$WORK/token-server.log" CST_REPLAY_SSE="$REPLAY" \ "$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \ --provider deepseek --model deepseek-chat \ --protocol "$PROTO" --tools default --yolo --web-dev "$ROOT/web" \ --web-token tok-e2e > "$T_LOG" 2>&1 & T_PID=$! i=0 T_PORT="" while [ $i -lt 50 ]; do T_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$T_LOG" | tail -1)" [ -n "$T_PORT" ] && break i=$((i + 1)); sleep 0.2 done [ -n "$T_PORT" ] || fail "the token server did not start (see $T_LOG)" T_BASE="http://127.0.0.1:$T_PORT" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$T_BASE/api/state")" [ "$code" = "401" ] || fail "a token-protected server answered $code without one" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ -H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state")" [ "$code" = "200" ] || fail "the Bearer token was rejected ($code)" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ "$T_BASE/api/state?token=tok-e2e")" [ "$code" = "200" ] || fail "the query token was rejected ($code)" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ -H 'Cookie: a=b; cst_token=tok-e2e; z=y' "$T_BASE/api/state")" [ "$code" = "200" ] || fail "the cookie token was rejected ($code)" code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ -H 'Authorization: Bearer tok-e2' "$T_BASE/api/state")" [ "$code" = "401" ] || fail "a wrong token was accepted ($code)" # --web-dev serves the health page from disk curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/" \ > "$WORK/index.html" || fail "GET / from --web-dev" cmp -s "$WORK/index.html" "$ROOT/web/index.html" || fail "--web-dev did not serve web/index.html from disk" curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state" \ > "$WORK/state.json" || fail "GET /api/state with token" grep -q '"capabilities"' "$WORK/state.json" || fail "/api/state missing capabilities" grep -q 'turn_begin' "$WORK/state.json" || fail "/api/state capabilities missing turn_begin" # POST /api/quit must return and then take the server down on its own curl -fsS --max-time 5 -X POST -H 'Authorization: Bearer tok-e2e' \ "$T_BASE/api/quit" | grep -q '"ok":true' || fail "POST /api/quit" i=0 while [ $i -lt 50 ] && kill -0 "$T_PID" 2> /dev/null; do i=$((i + 1)); sleep 0.2 done kill -0 "$T_PID" 2> /dev/null && fail "the server ignored /api/quit" wait "$T_PID" 2>/dev/null || true # 9. ask_user waits for the answer, and the HTTP hardening checks (both # need a scripted model: python3) if command -v python3 > /dev/null; then echo "== ask_user round trip" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ask.py" > "$WORK/ask.txt" 2>&1 || { cat "$WORK/ask.txt"; fail "the ask_user round trip"; } echo "== HTTP hardening" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 || { cat "$WORK/http.txt"; fail "the HTTP hardening checks"; } echo "== settings and streaming" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 || { cat "$WORK/settings.txt"; fail "the settings checks"; } echo "== sessions, retry and export" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_sessions.py" > "$WORK/sessions.txt" 2>&1 || { cat "$WORK/sessions.txt"; fail "the session checks"; } echo "== diff, run settings and permission rules" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_workspace.py" > "$WORK/workspace.txt" 2>&1 || { cat "$WORK/workspace.txt"; fail "the workspace checks"; } echo "== /init, /skill, extensions and the thinking switch" CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ext.py" > "$WORK/ext.txt" 2>&1 || { cat "$WORK/ext.txt"; fail "the extension checks"; } fi echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"