mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
b927e56fab
commit
7b863a4684
@@ -9,3 +9,4 @@
|
||||
/docs/
|
||||
/.review-shots/
|
||||
.env
|
||||
sandbox/bdrive
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Executable
+135
@@ -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 "$@"
|
||||
Executable
+91
@@ -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))
|
||||
Executable
+130
@@ -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
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user