The three file sizes the viewer's and the editor's limits were measured against are built by ui-sandbox.sh now, beside the rest of the explorer's fixture tree, so the figures in EXPLORER.md can be taken again instead of each session inventing its own generated file and getting a slightly different one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
397 lines
16 KiB
Bash
Executable File
397 lines
16 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"
|
|
# 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
|