Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
337 lines
13 KiB
Bash
Executable File
337 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 }
|
|
# 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"
|
|
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
|