From 7b863a468465fc84bc9f941c486699eafbaa7176 Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Thu, 30 Jul 2026 14:34:33 +0900 Subject: [PATCH] test(sandbox): a disposable Linux machine to run a scenario in (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= tests a branch without touching your working tree; BDRIVE_BIN= 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) --- .gitignore | 1 + CLAUDE.md | 2 + sandbox/Dockerfile | 47 ++++++++++++++ sandbox/boot.sh | 135 ++++++++++++++++++++++++++++++++++++++++ sandbox/daemon-linux.sh | 91 +++++++++++++++++++++++++++ sandbox/onboarding.sh | 130 ++++++++++++++++++++++++++++++++++++++ sandbox/run.sh | 87 ++++++++++++++++++++++++++ 7 files changed, 493 insertions(+) create mode 100644 sandbox/Dockerfile create mode 100755 sandbox/boot.sh create mode 100755 sandbox/daemon-linux.sh create mode 100755 sandbox/onboarding.sh create mode 100755 sandbox/run.sh diff --git a/.gitignore b/.gitignore index bcffd91..5169a26 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /docs/ /.review-shots/ .env +sandbox/bdrive diff --git a/CLAUDE.md b/CLAUDE.md index 163a790..536688b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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 diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 0000000..2c8dded --- /dev/null +++ b/sandbox/Dockerfile @@ -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"] diff --git a/sandbox/boot.sh b/sandbox/boot.sh new file mode 100755 index 0000000..d448641 --- /dev/null +++ b/sandbox/boot.sh @@ -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/ 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 " >&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 <&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 # if you'd rather watch init + # ask: docker exec -it bdrive-sandbox bash + +BANNER + +exec "$@" diff --git a/sandbox/daemon-linux.sh b/sandbox/daemon-linux.sh new file mode 100755 index 0000000..929a9d6 --- /dev/null +++ b/sandbox/daemon-linux.sh @@ -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 + 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:-}; actually alive: ${LIVE:-}" +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)) diff --git a/sandbox/onboarding.sh b/sandbox/onboarding.sh new file mode 100755 index 0000000..c7e1317 --- /dev/null +++ b/sandbox/onboarding.sh @@ -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 ''" +exit $((FAILED > 0)) diff --git a/sandbox/run.sh b/sandbox/run.sh new file mode 100755 index 0000000..e08aada --- /dev/null +++ b/sandbox/run.sh @@ -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 "$@"