#!/bin/sh # An ai-server with invented sessions in it, for driving the phone UI. # # The import screen lists whatever Claude Code has on the machine, and in # this VM that is real agent transcripts -- so exercising *delete* against # the ordinary server means deleting somebody's conversation, and exercising # *import* means starting a real `claude --resume` on the owner's account. Both # are the wrong price for looking at a list. # # So this starts a second server that can see neither. `$HOME` is pointed at # a sandbox directory, which is the only thing the importer's own script # consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session # data live there too. What it lists is invented here, and deleting all of # it costs nothing. # # Three things are deliberately shared with the real server, because the # installed APK is built against them: the TLS certificates (the app pins # that CA and would refuse a fresh one) and the port. Run it while the real # server is down. # # Usage: # ./ui-sandbox.sh start it, print the enrolment command # ./ui-sandbox.sh stop stop it # # Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT, # AI_SANDBOX_DELAY -- the server's own `--delay`, which is what makes a # spinner visible at all -- and AI_SANDBOX_SPAWN_DELAY, which holds an # import open for that many seconds. On loopback every request is back in # under a millisecond, so a busy state that is correct is still a busy state # nobody can see. set -eu SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SERVER_DIR=$SCRIPT_DIR/../server # The checkout's name, because several checkouts of this repo run sessions # at once and each has its own emulator: the root and the port both carry # it, so one checkout's sandbox (and the phone enrolled against it) can # never reach another's. Same rule as the per-checkout AVDs. CHECKOUT=$(basename "$(dirname "$SCRIPT_DIR")") ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox-$CHECKOUT} # Derived, not chosen: stable for this checkout across sessions (so an # enrolled emulator app keeps working), different between checkouts, and # away from 8443 where real dev servers get started. if [ -n "${AI_SANDBOX_PORT:-}" ]; then PORT=$AI_SANDBOX_PORT elif [ -f "$ROOT/port" ]; then # Whatever the running (or last) server was actually started on, so the # driving verbs below reach it even when it was started with an override. PORT=$(cat "$ROOT/port") else PORT=$((8500 + $(printf %s "$CHECKOUT" | cksum | cut -d' ' -f1) % 80)) fi DELAY=${AI_SANDBOX_DELAY:-1200} SPAWN_DELAY=${AI_SANDBOX_SPAWN_DELAY:-0} BIG_MB=${AI_SANDBOX_BIG_MB:-40} CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs} # Generated once and kept outside the repo (it is a credential, however # small the stakes), so the emulator app enrolled against the sandbox stays # enrolled across restarts and VM reboots instead of every session # re-deriving why the server says "invalid bearer token". URL-safe # characters only, so the enrolment deep link needs no encoding. TOKEN_FILE=${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token if [ -z "${AI_SANDBOX_TOKEN:-}" ] && [ ! -f "$TOKEN_FILE" ]; then mkdir -p "$(dirname "$TOKEN_FILE")" (umask 077 && head -c 24 /dev/urandom | base64 | tr '+/' '-_' >"$TOKEN_FILE") fi TOKEN=${AI_SANDBOX_TOKEN:-$(cat "$TOKEN_FILE")} hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) PIDFILE=$ROOT/server.pid LOG=$ROOT/server.log # An authenticated request to the running sandbox, so nothing driving it # has to re-derive the port and token: `./ui-sandbox.sh api /sessions`. api() { api_path=$1 shift curl -sk "https://127.0.0.1:$PORT$api_path" -H "Authorization: Bearer $TOKEN" "$@" } # By pid rather than by pattern: a `pkill -f` for something as generic as # "ai-server" also matches the shell running this script, which kills the # script mid-flight and leaves the restart never having happened. stop_server() { [ -f "$PIDFILE" ] || return 0 pid=$(cat "$PIDFILE") if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true echo "sandbox: stopped server $pid" fi rm -f "$PIDFILE" } case "${1:-start}" in stop) stop_server exit 0 ;; # The driving verbs live here rather than in each session's /tmp scripts, # because every UI investigation needs the same three: a session to point # the phone at, a message in it (often a large one, hence @file), and an # arbitrary authenticated request for everything else. api) # ./ui-sandbox.sh api /path [curl args...] shift api "$@" echo exit 0 ;; spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id api /sessions -X POST -H 'content-type: application/json' \ -d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' exit 0 ;; send) # ./ui-sandbox.sh send SID text... (or: send SID @file) sid=$2 shift 2 python3 -c 'import json, sys arg = sys.argv[1] text = open(arg[1:]).read() if arg.startswith("@") else " ".join(sys.argv[1:]) print(json.dumps({"text": text}))' "$@" >"$ROOT/send.json" api "/sessions/$sid/message" -X POST -H 'content-type: application/json' \ --data-binary "@$ROOT/send.json" echo exit 0 ;; start) ;; *) echo "ui-sandbox.sh: unknown command '$1' (start, stop, api, spawn, send)" >&2 exit 2 ;; esac stop_server # Tokens the server's own enrolment flow appended to the old config are the # phones enrolled against this sandbox; a restart regenerates the fixtures # but must not orphan those, or the app greets the next session with # "server rejected this device's token" and an afternoon of why. salvaged="" if [ -f "$ROOT/config.ron" ]; then salvaged=$(awk ' /^tokens: \[/ { in_tokens = 1; next } in_tokens && /^\],/ { exit } in_tokens { entry = entry $0 "\n" if ($0 ~ /\),/) { if (entry !~ h) entries = entries entry entry = "" } } END { printf "%s", entries }' h="$hash" "$ROOT/config.ron") fi rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron" PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox mkdir -p "$PROJECTS" "$ROOT/sessions" # Eight of them, because the point of the screen is a list long enough that # picking rows one at a time is the annoyance being fixed. Ids are the same # shape the CLI writes (a uuid, and the file name *is* the session id), and # each carries a `cwd` and a few user turns so the row has a title, a path # and a line count to show. i=1 while [ "$i" -le 8 ]; do id="0000000${i}-5eed-4a11-9c0d-000000000${i}00" file=$PROJECTS/$id.jsonl cwd="/home/bob/repos/sandbox/project-$i" : >"$file" turn=1 while [ "$turn" -le $((i + 2)) ]; do printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \ "$cwd" "$i" "$turn" >>"$file" turn=$((turn + 1)) done # A usage record on the last line, which is where the importer reads the # context figure from. Left off two of them on purpose: "no turn has # recorded any" is a state the row has to be able to show, and a list # where every row has a number never exercises it. if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \ "$((i * 9000))" >>"$file" fi i=$((i + 1)) done # A CLI that does nothing, so importing one of these is free and safe. # Everything the spawn path cares about is here: it holds the fifo open, # records a real pid, writes nothing, and dies on a signal. A real # `claude --resume` against an invented session id would either fail in a # way that tests nothing or start a turn on somebody's account. cat >"$ROOT/fake-claude" < /dev/null FAKE chmod +x "$ROOT/fake-claude" # One big one, because size is what makes importing take any time at all. # A spawn replays the whole file into this app's transcript, so against the # four-line sessions above it is over in milliseconds and every state on the # way is unobservable -- which is how a row that should have been marked # "importing" went unnoticed for not being marked at all. AI_SANDBOX_BIG_MB # sets how large. big=$PROJECTS/0000000b-5eed-4a11-9c0d-00000000b000.jsonl awk -v mb="$BIG_MB" 'BEGIN { target = mb * 1000000 line = "{\"type\":\"user\",\"cwd\":\"/home/bob/repos/sandbox/big\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"a long sandbox turn, number %d, with enough text on it that the file reaches a realistic size rather than a token one\"}]}}" written = 0 for (i = 1; written < target; i++) { out = sprintf(line, i) print out written += length(out) + 1 } print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}" }' > "$big" cat >"$ROOT/config.ron" <"$LOG" 2>&1 & pid=$! disown -h "$pid" 2>/dev/null || true echo "$pid" >"$PIDFILE" echo "$PORT" >"$ROOT/port" # Waited for rather than assumed: the enrolment below fails silently against # a server that has not bound yet, and the app then shows a network error # that has nothing to do with what is being tested. tries=0 while [ "$tries" -lt 50 ]; do if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; } tries=$((tries + 1)) sleep 0.2 done # Percent-encoded because the app URL-decodes the deep link's query: a # token with '+' in it enrols as one with a space, and nothing reports it. enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN") cat <