Files
ai-app/app/ui-sandbox.sh
T
irisandClaude Fable 5.1 8750ff90cb Build the phone's APK as a signed release, and say so in the render report
The phone has been running the debug build: build-apk.sh assembled it, and
nothing in the app or its report said which build a frame time came from.
A debuggable build runs Compose at a fraction of release speed, so the
tuning so far was measured against the wrong number. On the emulator, the
same fixture and gestures: measure 1.4ms mean / 14.7ms worst on debug,
0.8ms / 6.6ms on release.

build-apk.sh now assembles the release variant, signed with a key it
generates once under ~/.config/ai-app (beside the pinned CA, outside any
checkout). The report's header names the build. The one native library is
declared kept-with-symbols so packaging stops warning about an NDK the
build does not need.

Also: ui-sandbox.sh keep now keeps the config too. The server appends
spawned sessions and enrolled tokens to it, so regenerating it left the
transcripts on disk and the registry empty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:21:30 -04:00

326 lines
13 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 }
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
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"
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