Files
ai-app/app/ui-sandbox.sh
T
irisandClaude Opus 5 7997eeb7f8 Colour markdown's tables and the addresses written in it
A table is recognised by its delimiter row, the only line of one that cannot
be anything else, and its header is the line above -- the single place the
scanner looks ahead. Colouring every `|` instead would have marked the pipes
of a shell command written in a paragraph.

Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign
inside it and no whitespace, which leaves `<div>` alone; a bare `scheme://`
needs no closer, so where it ends is the decision -- the sentence's trailing
punctuation is given back, and so is a closing bracket unless one opened
inside the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:47:18 -04:00

425 lines
17 KiB
Bash
Executable File

#!/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" <<FAKE
#!/bin/sh
# Slow to start, on purpose. An import against this finishes in
# milliseconds otherwise, so every state on the way -- the row marked
# "importing", the queue behind it, the event that clears them -- is over
# before anything can observe it, and a broken one looks exactly like a
# working one. AI_SANDBOX_SPAWN_DELAY is how long that window is held open.
sleep $SPAWN_DELAY
cat > /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 <https://example.com> 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" <<RON
tokens: [
(
name: "sandbox",
sha256: "$hash",
),
$salvaged],
setups: [
(
id: "local",
name: "sandbox",
providers: [
(
name: "echo",
kind: echo,
),
(
name: "claude-cli",
kind: claude_cli,
command: "$ROOT/fake-claude",
models: [
"haiku",
],
),
],
),
],
sessions: [],
RON
fi
echo "sandbox: building"
(cd "$SERVER_DIR" && cargo build --quiet)
# Fully detached, so it outlives the shell that started it. HOME is the
# whole isolation: the importer's script reads it, and nothing else here
# looks outside the paths passed explicitly below.
HOME=$ROOT/home setsid nohup "$SERVER_DIR/target/debug/ai-server" \
--bind 127.0.0.1 \
--port "$PORT" \
--config "$ROOT/config.ron" \
--data-dir "$ROOT/sessions" \
--models-dir "$ROOT/models" \
--certs "$CERTS" \
--delay "$DELAY" \
>"$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 <<INFO
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
enrol the emulator (once; it survives sandbox restarts):
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
drive it:
./ui-sandbox.sh spawn [title] an echo session; prints its id
./ui-sandbox.sh send SID text|@file a message into it
./ui-sandbox.sh api /sessions/SID any authenticated request
keep sessions across a restart (e.g. after rebuilding the server):
./ui-sandbox.sh keep
stop it:
./ui-sandbox.sh stop
INFO