Files
ai-app/app/ui-sandbox.sh
T
irisandClaude Opus 5 cc7e4f63ef Read and change a machine's files from the backend
The first half of EXPLORER.md: server/src/files.rs, which lists a
directory, reads a file, writes one, and creates a file or a directory on
whichever machine a setup names.

Each operation is one small POSIX script run through `Transport`, the way
the import listing and the usage fetch already ask a machine a question,
so the local and the ssh case are one implementation rather than two that
drift. The path crosses as a positional argument and never as script
text; `PATH_PRELUDE` is the one line that gives a leading `~` its
meaning, because a shell expands a tilde in text and not in an argument,
and it is the far machine's home that has to answer.

A read has four answers -- text, binary, tooBig, or the machine's own
error -- because a binary file drawn as text and a big one cut off
silently are both wrong in ways the reader cannot see. A write carries
the sha256 the read reported and is refused with a 409 when the file has
moved on, which is what happens whenever an agent is editing the file
somebody is reading.

`Transport::capture_with_input` is the one description of "run this
there, with this on stdin", and `ship_attachment` moves onto it rather
than assembling a second ssh invocation of its own. It is also the only
capture that hands back the exit status, which is how the write says
"this is not the file you read" without that answer looking like a
failure.

Exercised on both transports against the sandbox -- ssh to this VM with a
throwaway key, since the quoting and the stdin path are what that proves
-- including a filename with an apostrophe, one with a tab, an unreadable
file, a binary one, one over the limit, and the 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:28:00 -04:00

370 lines
15 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"
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