#!/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, and # AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is # what makes a spinner visible at all. 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 ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox} TOKEN=${AI_SANDBOX_TOKEN:-sandbox} PORT=${AI_SANDBOX_PORT:-8443} DELAY=${AI_SANDBOX_DELAY:-1200} CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs} SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SERVER_DIR=$SCRIPT_DIR/../server PIDFILE=$ROOT/server.pid LOG=$ROOT/server.log # 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" } if [ "${1:-start}" = stop ]; then stop_server exit 0 fi stop_server 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" <<'FAKE' #!/bin/sh cat > /dev/null FAKE chmod +x "$ROOT/fake-claude" hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) cat >"$ROOT/config.ron" <"$LOG" 2>&1 & pid=$! disown -h "$pid" 2>/dev/null || true echo "$pid" >"$PIDFILE" # 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 cat <