#!/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) ;; # Restart the server but keep the sessions and enrolment already there, so a # fixture built over minutes (a long delta-heavy transcript, say) survives a # rebuild of the server binary. Plain `start` wipes them, which is right for # the list-screen fixtures but wrong when the session under test was expensive # to make. keep) KEEP=1 ;; *) echo "ui-sandbox.sh: unknown command '$1' (start, keep, 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 } # The server writes the list back compactly, with the last entry # and the close on one line: " ),],". Reading the close only # at a line start ran past it into `setups`, and the salvage then # carried a second copy of that block into the new config. in_tokens && /\],/ { sub(/\],.*/, "") if ($0 ~ /\),/) { entry = entry $0 "\n" if (entry !~ h) entries = entries entry } 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 PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox if [ -z "${KEEP:-}" ]; then rm -rf "$ROOT/home" "$ROOT/sessions" fi # The server appends to this file: the tokens of phones enrolled against it # and every session spawned. Regenerating it under `keep` was what un-kept # the sessions -- their transcripts survived on disk while the registry that # lists them went back to empty -- so a kept sandbox keeps its config too. regen_config=1 if [ -n "${KEEP:-}" ] && [ -f "$ROOT/config.ron" ]; then regen_config="" fi [ -n "$regen_config" ] && rm -f "$ROOT/config.ron" mkdir -p "$PROJECTS" "$ROOT/sessions" if [ -z "${KEEP:-}" ]; then # 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" # A tree for the file explorer, at the sandbox home's `~/files`, holding # the states that are otherwise only reachable by finding a real machine # in one of them. The default state is the one everybody looks at, so what # is worth having here is the rest: an empty directory, names a shell would # mangle, something that is not text, something too big to send, something # nobody may read, a link that navigates, and one file per language so the # highlighter is exercised rather than assumed. FILES=$ROOT/home/files mkdir -p "$FILES/empty" "$FILES/sub" printf 'one\ntwo\nthree\n' >"$FILES/hello.txt" # A real tab, via printf: `\t` inside double quotes is a backslash and a t, # which is a different (and easier) name than the one worth testing. tabbed=$(printf 'with\ta tab.txt') printf 'a tab in the name\n' >"$FILES/$tabbed" printf "an apostrophe in the name\n" >"$FILES/it's a file.txt" printf 'fn main() {\n // a comment\n println!("hello, {}", 1_000);\n}\n' >"$FILES/main.rs" printf 'fun main() {\n // a comment\n println("hello")\n}\n' >"$FILES/Main.kt" printf 'def main():\n # a comment\n print("hello")\n' >"$FILES/main.py" printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh" chmod +x "$FILES/run.sh" printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json" # Markdown's scanner is line-structured rather than tokens, so the fixture holds one of each # thing it decides by position: a heading, a fence, a list, a quote, a link and a rule. cat >"$FILES/notes.md" <<'MARKDOWN' # Notes A paragraph with `code`, **bold** and a [link](PLAN.md). Not emphasis: a * b * c, and snake_case_name. ## A list - one - two > quoted | column | what it holds | |--------|---------------| | one | a value | A link and a bare https://example.com/a., but run a | b in a paragraph has no table in it. ```rust fn main() { println!("hello"); } ``` --- MARKDOWN # Not UTF-8, so it reads as binary rather than as mojibake. printf '\377\376\000\001binary\n' >"$FILES/picture.bin" # Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers. awk 'BEGIN { for (i = 0; i < 40000; i++) print "a line of a log that nobody is going to read" }' >"$FILES/big.log" # Unreadable on purpose: a directory listing still shows it, and opening it # fails with the machine's own words rather than with an empty file. printf 'secret\n' >"$FILES/unreadable.txt" chmod 000 "$FILES/unreadable.txt" printf 'in a subdirectory\n' >"$FILES/sub/inside.txt" ln -sfn sub "$FILES/link-to-sub" ln -sfn nowhere "$FILES/broken-link" # The sizes the explorer's limits were measured against, so the numbers in # EXPLORER.md can be taken again rather than re-derived. 32k is the largest # the editor handles (EDIT_LIMIT); 128k is where typing loses characters; # 1M is FILE_LIMIT, which the viewer reads fine and the editor refuses. python3 - "$FILES" <<'FIXTURE' import pathlib, sys out = pathlib.Path(sys.argv[1]) block = """/// A doc comment on function number {i}, long enough that the scanner /// has real comment spans to find rather than a token few. fn generated_{i}(input: &str, count: u32) -> String {{ // an ordinary line comment let mut out = String::from("prefix {i}: "); for index in 0..count {{ out.push_str(&format!("{{}}-{{}}", index, input)); }} out }} """ for name, size in (("edit-32k.rs", 32 * 1024), ("edit-128k.rs", 128 * 1024), ("big-source.rs", 1024 * 1024)): parts, written, i = [], 0, 0 while written < size - 400: parts.append(block.format(i=i)) written += len(parts[-1]) i += 1 (out / name).write_text("".join(parts)) FIXTURE fi if [ -n "$regen_config" ]; then 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 <