test(sandbox): a disposable Linux machine to run a scenario in (#92)

Some things cannot be tested from a Go test on your Mac. A real `claude`
session needs the real permission classifier and a $HOME it may write agent
hooks into. The systemd user unit only exists on Linux. A reboot needs
processes to die while the filesystem survives. Until now those were tested by
hand, against the real ~/.bdrive and ~/.claude — so testing onboarding from
scratch meant polluting the machine you were testing from, and `bdrive init`
registering hooks user-level made that worse.

This is an ENVIRONMENT, not a suite. It provides a hub on file:// storage, a
seeded account, browserless sign-in (bdrive-signin drives both halves of the
device flow), Claude Code, the binary under test, and a $HOME thrown away with
the container. Scenarios still live where they belong: deterministic ones in
internal/webapp/cli_e2e_test.go, the conversational one in the onboarding-e2e
skill. The rule, written into the Dockerfile so it survives me: if it doesn't
need a conversation or an OS, it's a Go test.

The two scripts it ships are the scenarios with nowhere else to go.
onboarding.sh runs a real `claude -p` following the LOCAL
INSTALL_FOR_AGENTS.md and checks the scope hard gate, hooks-via-init, and that
nothing reaches for a plugin or skill. daemon-linux.sh covers the systemd unit
and the daemon.pid/stop race.

Notes for whoever reads this next:

  - The binary is bind-mounted, not built in, so a code change rebuilds the
    binary and not the image. BDRIVE_SRC=<checkout> tests a branch without
    touching your working tree; BDRIVE_BIN=<binary> skips the build.
  - No `# syntax=` directive in the Dockerfile on purpose: it makes every
    build resolve the frontend from the registry, which turns a slow network
    into a build that hangs with no output. That also rules out RUN heredocs,
    hence boot.sh being a file.
  - Claude auth comes from CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token`).
    The Keychain is deliberately not read: the container would refresh that
    token and rotate it out from under your Mac, logging you out there.
  - The hub lives only as long as the container's command, so the project
    link init prints is dead once a scripted run exits. Use the interactive
    shell to browse it.


Claude-Session: https://claude.ai/code/session_01DgF8JsoeNPVShGYWdooE72

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-30 14:34:33 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent b927e56fab
commit 7b863a4684
7 changed files with 493 additions and 0 deletions
+1
View File
@@ -9,3 +9,4 @@
/docs/
/.review-shots/
.env
sandbox/bdrive
+2
View File
@@ -68,6 +68,8 @@ Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in h
The real coverage is the integration tests in `internal/syncer/syncer_test.go`: each test builds multiple simulated devices (`newDevice`) syncing through a shared `file://` remote (`sharedRemote`), then drives explicit `cycle()` calls to test convergence, offline operation, and concurrent-edit conflicts. Extend these when touching sync behavior — a new sync feature without a multi-device test is untested where it matters.
`sandbox/` is a disposable Linux container to run a scenario in **when it needs one** — an environment, not a test suite (`./sandbox/run.sh`, which cross-builds the binary and takes `BDRIVE_SRC=<other checkout>` to test a branch without disturbing your tree). It provides a hub, a seeded account, browserless sign-in (`bdrive-signin`), Claude Code, and a `$HOME` that is thrown away, so `bdrive init` writes its device identity and its agent hooks somewhere other than yours. Reach for it only when a Go test cannot do the job: a real `claude` session with the real permission classifier, Linux-only paths like the systemd user unit, or a reboot simulated by killing processes while the filesystem survives. Everything deterministic and machine-local belongs in `internal/webapp/cli_e2e_test.go` (which already isolates `HOME` and drives the real binary) or `internal/syncer`**if it doesn't need a conversation or an OS, it's a Go test.** The two scripts it ships (`onboarding.sh`, `daemon-linux.sh`) are the scenarios that cannot live anywhere else; don't grow a suite in here.
## Agent integration
There is no Claude Code plugin and no bundled skill: the integration is
+47
View File
@@ -0,0 +1,47 @@
# A disposable Linux machine to run a scenario in when it needs one.
#
# This is an ENVIRONMENT, not a test suite. Scenarios live where they belong:
# deterministic ones in internal/webapp/cli_e2e_test.go and internal/syncer,
# the conversational one in the onboarding-e2e skill. Reach for the sandbox
# only when a scenario needs something a Go test cannot give it:
#
# - a fresh $HOME, so `bdrive init` writes its device identity and its agent
# hooks (~/.claude/settings.json) somewhere thrown away instead of yours
# - a real `claude` session, with the real permission classifier
# - Linux, for the systemd user unit
# - a reboot, simulated by killing processes while the filesystem survives
#
# It provides a hub, a seeded account, Claude Code, browserless sign-in
# (bdrive-signin / bdrive-approve) and the binary under test. The two scripts
# it ships are the scenarios that cannot live anywhere else.
#
# The bdrive binary is bind-mounted at run time rather than built in, so
# testing a code change rebuilds the binary, not the image. Use run.sh, which
# is the intended entry point.
#
# No `# syntax=` directive on purpose: it makes every build resolve the
# Dockerfile frontend from the registry, which turns a cold or slow network
# into a build that hangs with no output. That also rules out RUN heredocs,
# hence boot.sh being its own file.
FROM node:22-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git less \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force
ENV HOME=/home/tester \
BDRIVE_HOME=/home/tester/.bdrive \
HUB=http://localhost:8080 \
HUB_EMAIL=me@example.com \
HUB_PASSWORD=hunter2hunter2
WORKDIR /work
COPY boot.sh /usr/local/bin/boot
COPY onboarding.sh /usr/local/bin/onboarding
COPY daemon-linux.sh /usr/local/bin/daemon-linux
RUN chmod +x /usr/local/bin/boot /usr/local/bin/onboarding /usr/local/bin/daemon-linux
ENTRYPOINT ["/usr/local/bin/boot"]
CMD ["bash"]
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Container entrypoint: set up Claude Code auth, start a hub, seed an account,
# then hand over to the command (a shell by default). Nothing here touches the
# host — $HOME is inside the container and /data is thrown away with it.
set -e
mkdir -p "$HOME/.claude" /work /data/store
# Claude Code keeps its OAuth token in its config dir, so a fresh $HOME is a
# logged-out one. Copy the read-only mounted credential into the container's
# own HOME: token refresh then writes here and never back to your host file.
if [ -f /run/claude/credentials.json ]; then
cp /run/claude/credentials.json "$HOME/.claude/.credentials.json"
chmod 600 "$HOME/.claude/.credentials.json"
fi
# Pre-trust /work so claude doesn't stop at the trust dialog, and mark
# onboarding done — in a fresh $HOME an interactive `claude` otherwise runs the
# first-run wizard, whose first step is a sign-in screen, even when the token in
# the environment is perfectly good.
[ -f "$HOME/.claude.json" ] || cat > "$HOME/.claude.json" <<'JSON'
{
"hasCompletedOnboarding": true,
"projects": {
"/work": {"hasTrustDialogAccepted": true},
"/src": {"hasTrustDialogAccepted": true}
}
}
JSON
# Report which credential actually arrived. "asked me to sign in" has two very
# different causes — nothing reached the container, or something did and was
# rejected — and without this you cannot tell them apart.
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
CLAUDE_AUTH="CLAUDE_CODE_OAUTH_TOKEN (${#CLAUDE_CODE_OAUTH_TOKEN} chars)"
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
CLAUDE_AUTH="ANTHROPIC_API_KEY (${#ANTHROPIC_API_KEY} chars)"
elif [ -f "$HOME/.claude/.credentials.json" ]; then
CLAUDE_AUTH="host .credentials.json — often stale on macOS"
else
CLAUDE_AUTH="NONE — claude will ask you to sign in"
fi
# Completes `bdrive login --device` without a browser: signs in as the seeded
# hub account and POSTs the approval the browser would. Takes the link the CLI
# printed, or just its token. Approval is POST /auth/device/<token> with a
# session cookie and no body — see BuiltinAuth.pageDevice.
cat > /usr/local/bin/bdrive-approve <<'SH'
#!/bin/bash
set -e
[ -n "$1" ] || { echo "usage: bdrive-approve <link-or-token>" >&2; exit 1; }
token=${1##*/}
jar=$(mktemp)
curl -sf -c "$jar" -d "email=$HUB_EMAIL&password=$HUB_PASSWORD" "$HUB/auth/login" -o /dev/null
curl -sf -b "$jar" -X POST "$HUB/auth/device/$token" -o /dev/null
echo "approved $token"
SH
chmod +x /usr/local/bin/bdrive-approve
# Signs this device in with no browser and no interaction, by driving both
# halves of the device flow. Use it before an unattended `claude -p` run:
# `bdrive init` does its own device login and would otherwise print a link and
# block forever waiting for someone to open it.
cat > /usr/local/bin/bdrive-signin <<'SH'
#!/bin/bash
set -e
log=$(mktemp)
bdrive login --device "$HUB" >"$log" 2>&1 &
for _ in $(seq 1 30); do
grep -qE "/auth/device/[a-f0-9]+" "$log" && break
sleep 1
done
link=$(grep -oE "https?://\S+/auth/device/[a-f0-9]+" "$log" | tail -1)
[ -n "$link" ] || { echo "no sign-in link appeared:" >&2; cat "$log" >&2; exit 1; }
bdrive-approve "$link"
wait
bdrive login --status
SH
chmod +x /usr/local/bin/bdrive-signin
# allow_signup needs a gate or the hub refuses to start, hence allowed_domains.
if [ ! -f /data/hub.json ]; then
cat > /data/hub.json <<'JSON'
{
"remote": "file:///data/store",
"addr": ":8080",
"upload": true,
"projects_db": "/data/projects.json",
"auth": {
"allow_signup": true,
"allowed_domains": ["example.com"],
"users_db": "/data/auth.json",
"admins": ["me@example.com"]
},
"database": {"driver": "file"}
}
JSON
fi
BDRIVE_HOME=/data/hubhome bdrive web -c /data/hub.json >/data/hub.log 2>&1 &
for _ in $(seq 1 40); do
curl -sf -o /dev/null "$HUB/auth/login" && break
sleep 1
done
if ! curl -sf -o /dev/null "$HUB/auth/login"; then
echo "hub failed to start:" >&2
cat /data/hub.log >&2
exit 1
fi
# Already-exists is fine: idempotent across restarts if /data is a volume.
curl -s -d "email=$HUB_EMAIL&password=$HUB_PASSWORD&name=Me" "$HUB/auth/signup" -o /dev/null || true
cat <<BANNER
hub $HUB (also http://localhost:8080 on your Mac —
but only while this container's command runs: exit and it is gone,
which makes the project link init prints dead. To browse it, use the
interactive shell — sandbox/run.sh with no arguments.)
account $HUB_EMAIL / $HUB_PASSWORD
bdrive $(bdrive version 2>&1)
claude $(claude --version 2>&1)
auth $CLAUDE_AUTH
scenarios that need this machine:
onboarding new|join a real claude session following /src/INSTALL_FOR_AGENTS.md
daemon-linux the systemd user unit, and the daemon.pid/stop race
poke at it by hand:
bdrive-signin # sign in, no browser
mkdir shared && bdrive init shared --yes # names the project after the folder
bdrive-approve <link> # if you'd rather watch init
# ask: docker exec -it bdrive-sandbox bash
BANNER
exec "$@"
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# The two daemon checks that need a Linux machine. Everything else about the
# daemon (lock-vs-pid liveness, resume idempotency, stay-stopped) is covered by
# internal/daemon and internal/webapp/cli_e2e_test.go — don't re-test it here.
#
# ./sandbox/run.sh daemon-linux
set -u
pass() { echo " PASS $1"; }
fail() { echo " FAIL $1"; FAILED=$((FAILED+1)); }
info() { echo " .. $1"; }
FAILED=0
bdrive-signin >/dev/null || exit 1
echo "== the systemd user unit =="
# Only reachable on Linux, and only assertable at all because autostart.Install
# never shells out to systemctl — writing the files IS the registration, so
# faking sd_booted(3)'s directory probe exercises the real path.
UNITDIR="$HOME/.config/systemd/user"
S=/work/unit; rm -rf "$S"; mkdir -p "$S"; cd "$S"
if [ -e /run/systemd/system ]; then
info "this container runs systemd; skipping the sd_booted fake"
else
rm -rf "$UNITDIR"
bdrive init --name unittest --yes >/dev/null 2>&1
[ -f "$UNITDIR/beardrive.service" ] \
&& fail "wrote a unit on a machine with no systemd" \
|| pass "no /run/systemd/system -> writes no unit, quietly"
mkdir -p /run/systemd/system
fi
bdrive init --yes >/dev/null 2>&1
if [ -f "$UNITDIR/beardrive.service" ]; then
pass "unit written to $UNITDIR/beardrive.service"
grep -q 'bdrive resume' "$UNITDIR/beardrive.service" \
&& pass "unit runs 'bdrive resume'" || fail "unit does not run 'bdrive resume'"
[ -L "$UNITDIR/default.target.wants/beardrive.service" ] \
&& pass "enabled via default.target.wants" \
|| fail "no default.target.wants symlink — systemd would ignore the unit"
else
fail "no unit written even with /run/systemd/system present"
fi
rmdir /run/systemd/system 2>/dev/null
echo
echo "== daemon.pid must name the lock holder =="
# TestCLIDaemonPidFileNamesTheLockHolder covers this too, but only catches it
# about one run in five on macOS — the window is tight there. On Linux it is
# deterministic, which is why this stays until the pidfile ordering is fixed.
# Scoped to ONE folder on purpose: the section above left its own mount and
# daemon running, and counting "any daemon" or picking "any daemon.pid" made
# this report the other project's process.
live_daemons() { # live_daemons <folder>
for p in /proc/[0-9]*; do
c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null)
case "$c" in *"bdrive daemon"*"$1"*) echo "${p#/proc/}" ;; esac
done
}
S=/work/race; rm -rf "$S"; mkdir -p "$S"; cd "$S"
bdrive init --name racetest --yes >/dev/null 2>&1
bdrive resume >/dev/null 2>&1 # no delay: that is the test
sleep 3
MOUNT=$(node -e 'console.log(require("/work/race/.bdrive/config.json").id)')
PIDFILE="$HOME/.bdrive/volumes/$MOUNT/daemon.pid"
info "mount $MOUNT"
FILEPID=$(cat "$PIDFILE" 2>/dev/null)
LIVE=$(live_daemons "$S" | tr '\n' ' ')
info "daemon.pid says ${FILEPID:-<empty>}; actually alive: ${LIVE:-<none>}"
case " $LIVE " in
*" $FILEPID "*) pass "daemon.pid names a live daemon" ;;
*) fail "daemon.pid names $FILEPID, which is not running" ;;
esac
STOPOUT=$(bdrive stop 2>&1)
sleep 2
STILL=$(live_daemons "$S" | tr '\n' ' ')
case "$STOPOUT" in
*"no such process"*) fail "stop failed: $STOPOUT" ;;
*) pass "stop reported no error" ;;
esac
[ -z "$STILL" ] \
&& pass "nothing syncing after stop" \
|| fail "stop left daemon(s) running: $STILL — sync cannot be turned off"
echo
echo "== done: $FAILED failure(s) =="
exit $((FAILED > 0))
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# Scenario A of the onboarding-e2e skill, run inside the test container: a real
# `claude -p` session following the LOCAL INSTALL_FOR_AGENTS.md against the
# local hub, in a $HOME that is thrown away with the container.
#
# ./sandbox/run.sh onboarding new # create a NEW project in an empty folder
# ./sandbox/run.sh onboarding join # join a project seeded by another device
#
# Needs a Claude credential in the environment — see run.sh's header. The
# mechanical assertions are checked here; the judgment calls (did it recommend
# a subfolder? did it ask before acting?) are for whoever reads the transcript,
# which is the actual deliverable.
set -u
if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}${ANTHROPIC_API_KEY:-}" ]; then
echo "no Claude credential in the environment — see the auth line in the banner" >&2
exit 2
fi
# The case matrix's tool set: enough to stage, inspect and run bdrive, too
# little to install a plugin or a marketplace silently.
TOOLS='Read,Write,Edit,Bash(bdrive:*),Bash(command:*),Bash(git:*),Bash(mkdir:*),Bash(ls:*),Bash(cat:*)'
S=/work/e2e
rm -rf "$S"; mkdir -p "$S/agent1"
json() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(eval("(d=>"+process.argv[1]+")")(JSON.parse(s))??"")}catch(e){console.log("")}})' "$1"; }
pass() { echo " PASS $1"; }
fail() { echo " FAIL $1"; FAILED=$((FAILED+1)); }
FAILED=0
MODE=${1:-new}
# The doc's default recommendation for a folder with no knowledge folder, and
# the name `bdrive init shared` gives the project it creates. In join mode this
# is also the seeded project's name, since the agent is meant to recommend a
# folder matching it.
PROJ=shared
echo "== 1. sign in this device =="
bdrive-signin >/dev/null || { echo "sign-in failed" >&2; exit 1; }
jar=$(mktemp)
curl -sf -c "$jar" -d "email=$HUB_EMAIL&password=$HUB_PASSWORD" "$HUB/auth/login" -o /dev/null
if [ "$MODE" = join ]; then
# Seeded by a SECOND device identity: the agent must join a project that
# already exists and that its own device has never mounted. Same device would
# hit init's one-journal-per-project refusal, which is a different test.
echo "== 2. seed project '$PROJ' as another device =="
mkdir -p "$S/seed/$PROJ"
printf '# Home\n\nStart at [[Runbook]].\n' > "$S/seed/$PROJ/Home.md"
printf '# Runbook\n\nDeploys go through CI.\n' > "$S/seed/$PROJ/Runbook.md"
BDRIVE_HOME=/data/seedhome bdrive-signin >/dev/null || exit 1
BDRIVE_HOME=/data/seedhome bdrive init "$S/seed/$PROJ" --name "$PROJ" --yes >/dev/null || exit 1
PID=$(curl -sf -b "$jar" "$HUB/api/projects" | json "d.projects.find(p=>p.name===\"$PROJ\").id")
[ -n "$PID" ] || { echo "could not resolve the seeded project id" >&2; exit 1; }
echo " project: $PID"
# Verbatim shape of the doc's teammate paste prompt, project name included —
# that name is what the agent is supposed to recommend as the folder.
ASK="to set up BearDrive project $PID on $HUB. Ask me which folder to
sync (the project is named \"$PROJ\")."
else
echo "== 2. no seeding — the agent creates a new project =="
ASK="on $HUB. Create a new project. Ask me which folder to sync."
fi
echo
echo "== 3. turn 1 — the paste prompt, in an empty folder =="
cd "$S/agent1"
ls -a
T1=$(claude -p "Follow /src/INSTALL_FOR_AGENTS.md
$ASK" \
--output-format json --allowedTools "$TOOLS" 2>&1)
SID=$(printf '%s' "$T1" | json 'd.session_id')
printf '%s' "$T1" | json 'd.result'
echo
echo " --- assertions ---"
[ -n "$SID" ] && pass "session_id captured ($SID)" || fail "no session_id — turn 1 did not complete"
if [ -d "$S/agent1/.bdrive" ] || compgen -G "$S/agent1/*/.bdrive" >/dev/null; then
fail "scope hard gate: something was mounted before the folder question was answered"
else
pass "scope hard gate: nothing mounted yet"
fi
printf '%s' "$T1" | grep -qiE 'which folder|recommend' \
&& pass "turn 1 asks / recommends a folder" \
|| fail "turn 1 does not appear to ask which folder syncs"
echo
echo "== 4. turn 2 — answer it, init must run =="
[ -n "$SID" ] || { echo "cannot resume without a session_id" >&2; exit 1; }
T2=$(claude -p --resume "$SID" "Go with your recommendation." \
--output-format json --allowedTools "$TOOLS" 2>&1)
printf '%s' "$T2" | json 'd.result'
echo
echo " --- assertions ---"
MOUNT=$(find "$S/agent1" -maxdepth 3 -name config.json -path '*/.bdrive/*' | head -1)
[ -n "$MOUNT" ] && pass "a mount exists: $MOUNT" || fail "init never ran"
[ -d "$S/agent1/.bdrive" ] \
&& fail "the parent folder itself was mounted (expected a dedicated subfolder)" \
|| pass "parent folder not mounted bare"
grep -q beardrive "$HOME/.claude/settings.json" 2>/dev/null \
&& pass "hooks registered in ~/.claude/settings.json" \
|| fail "no hooks in ~/.claude/settings.json"
# Since #85 there is no plugin and no bundled skill — hooks are the whole
# integration. An agent reaching for either is following a stale doc.
printf '%s%s' "$T1" "$T2" | grep -qE 'plugin (marketplace )?(add|install)|SKILL\.md|bdrive skill' \
&& fail "transcript reaches for a plugin/marketplace/skill — all removed in #85" \
|| pass "no plugin or skill install attempted"
compgen -G "$HOME/.claude/skills/*" >/dev/null \
&& fail "a skill was installed under ~/.claude/skills — #85 removed that" \
|| pass "no skill dir created (correct since #85)"
printf '%s%s' "$T1" "$T2" | grep -q 'bdrive hooks install' \
&& fail "ran a separate 'bdrive hooks install' — init registers hooks inline" \
|| pass "no separate hooks command"
echo
echo "== 5. the payoff link serves real content =="
[ "$MODE" = new ] && PID=$(curl -sf -b "$jar" "$HUB/api/projects" | json "d.projects.find(p=>p.name===\"$PROJ\").id")
if [ -z "$PID" ]; then
fail "no project named '$PROJ' exists on the hub"
else
curl -sf -b "$jar" -o /dev/null -w " /api/p/$PID/tree -> %{http_code}\n" "$HUB/api/p/$PID/tree" \
&& pass "hub serves the project ($PID)" || fail "hub did not serve the project"
curl -sf -b "$jar" "$HUB/api/p/$PID/tree" | json 'JSON.stringify(d).slice(0,300)'
fi
echo
echo "== done: $FAILED mechanical assertion(s) failed =="
echo " resume the session with: claude -p --resume $SID '<next user turn>'"
exit $((FAILED > 0))
Executable
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
# Build and enter the sandbox: a disposable Linux machine to run a scenario in
# when it needs one. See the Dockerfile for what it provides and what does NOT
# belong in here.
#
# ./sandbox/run.sh # interactive shell in a fresh machine
# ./sandbox/run.sh onboarding new # scenario: agent creates a new project
# ./sandbox/run.sh onboarding join # scenario: agent joins a seeded project
# ./sandbox/run.sh daemon-linux # scenario: systemd unit + pidfile race
# ./sandbox/run.sh bash -c '...' # anything else, then exit
#
# Test another checkout (a feature branch worktree) without touching this one:
# BDRIVE_SRC=.claude/worktrees/my-branch ./sandbox/run.sh daemon-linux
# BDRIVE_BIN=/path/to/linux/bdrive ./sandbox/run.sh
#
# The hub is published on :8080 for your host browser, but it only lives as long
# as the container's command. The second form takes the hub down with it the
# moment the command finishes — so if you want to click the project link init
# prints, work inside the interactive shell.
#
# Claude Code auth, first match wins:
#
# 1. $CLAUDE_CODE_OAUTH_TOKEN — mint once on your Mac with `claude setup-token`
# and export it. This is the recommended path.
# 2. $ANTHROPIC_API_KEY — a plain API key.
# 3. ~/.claude/.credentials.json — mounted read-only and copied inside, as a
# fallback. On macOS this file is often a stale
# leftover (the live token lives in Keychain),
# so expect "OAuth session expired" if so.
#
# Deliberately NOT reading the Keychain: the container would refresh that token
# on its own and rotate it out from under your Mac, logging you out there —
# exactly the side effect this container exists to avoid. `claude setup-token`
# mints a separate token instead.
set -e
cd "$(dirname "$0")/.."
case "$(docker info --format '{{.Architecture}}')" in
aarch64|arm64) GOARCH=arm64 ;;
*) GOARCH=amd64 ;;
esac
# $BDRIVE_SRC builds from another checkout (a worktree on a feature branch)
# instead of this one, so a branch can be tested without touching your working
# tree. $BDRIVE_BIN skips the build and uses a linux binary you already have.
if [ -n "${BDRIVE_BIN:-}" ]; then
cp "$BDRIVE_BIN" sandbox/bdrive
else
( cd "${BDRIVE_SRC:-.}" && CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" go build \
-o "$OLDPWD/sandbox/bdrive" ./cmd/bdrive )
fi
docker build -q -t bdrive-sandbox sandbox >/dev/null
AUTH=""
if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
AUTH="-e CLAUDE_CODE_OAUTH_TOKEN"
elif [ -n "$ANTHROPIC_API_KEY" ]; then
AUTH="-e ANTHROPIC_API_KEY"
elif [ -f "$HOME/.claude/.credentials.json" ]; then
AUTH="-v $HOME/.claude/.credentials.json:/run/claude/credentials.json:ro"
echo "note: no CLAUDE_CODE_OAUTH_TOKEN set, falling back to" >&2
echo " ~/.claude/.credentials.json — often stale on macOS. If claude asks" >&2
echo " you to sign in, that is why; see the header of this script." >&2
else
echo "warning: no Claude credential found; claude will ask you to sign in." >&2
echo " Run \`claude setup-token\` on the host, export" >&2
echo " CLAUDE_CODE_OAUTH_TOKEN, and re-run this script." >&2
fi
# -t only when there is a terminal, so this stays scriptable from CI or an agent.
[ -t 0 ] && TTY=-it || TTY=-i
# shellcheck disable=SC2086 # AUTH and TTY are deliberately word-split
# A run killed at the client (Ctrl-C on a pipe, an agent's timeout) leaves the
# container up despite --rm, still holding :8080. Clear it rather than failing
# the next run with "port is already allocated".
docker rm -f bdrive-sandbox >/dev/null 2>&1 || true
# Named so a second shell can approve a device login mid-flow:
# docker exec -it bdrive-sandbox bash
exec docker run --rm --name bdrive-sandbox $TTY \
-v "$PWD/sandbox/bdrive":/usr/local/bin/bdrive:ro \
-v "$PWD":/src:ro \
$AUTH \
-p 8080:8080 \
bdrive-sandbox "$@"