Crabbox on qs-mac-studio
How isolated branch boxes run tests, full stacks, screenshots, and browser tunnels on the Mac Studio · ~17 min read ~– min read · Suggested by Noam engineeringoperations
Crabbox turns qs-mac-studio into a remote box factory: each branch gets an isolated Linux container, reachable over Tailscale, with its own filesystem, processes, ports, and optional inner Docker daemon. The result is a compact ladder of interaction: run gates, boot the full app stack, then tunnel one selected box into a local browser when a human or agent needs to inspect it.
The shape of the system
Crabbox is the direct path from a laptop to isolated dev boxes on qs-mac-studio. The laptop runs the normal Crabbox CLI, the external provider SSHes to the Studio, Docker starts a per-lease container, and Crabbox syncs the current branch into that container over Tailscale.
Setup is split between the laptop and the Studio
The laptop needs Crabbox, Tailscale, SSH access, and trusted user config. The Studio needs a runner image and an hourly reaper. On current Crabbox builds, the external provider block belongs in crabbox config path, not in a repo-root crabbox.yaml, because repository config is untrusted input. The config chooses provider: external, points at studio-docker-provider, sets studio_host and studio_ssh_user, opts in to trusting the provider’s SSH coordinates, and enables privileged: true only for boxes that must run the app’s own Docker stack.
Pure CLI work is the base layer
The first level is just Crabbox. Use crabbox doctor, warm or ensure a box, then pass its slug to every crabbox run --id <slug> -- ... and crabbox release --id <slug>. A bare crabbox run leases a throwaway box, and a bare crabbox release does not release the box you meant. This level is friendly to multiple boxes because every command can target an explicit slug.
Osprey adds app helpers
The repo-level just crabbox helpers select ready boxes, list slugs, capture screenshots, and open browser tunnels. just crabbox screenshot <slug> drives Playwright inside a running app box and copies the PNG back to /tmp/screenshots/ by default.
Claude Code uses crabbox-it
Inside Claude Code, crabbox-it is the operator wrapper. It should ensure or reuse a box, run the requested gates or app commands, collect screenshots or logs, and report the slug and result. It does not change the underlying model: the box still contains a clone of the current branch.
There are three interaction levels
Level 1 runs gates. Level 2 runs the full stack and screenshot automation. Level 3 opens local SSH tunnels to one selected box at localhost:4700 and Keycloak at localhost:4710. Levels 1 and 2 work well with several boxes; Level 3 is practically one active local target per port pair.
Lifecycle is part of the contract
Warm boxes are temporary, but they are not self-explanatory. crabbox run --id reuses a warm box; bare crabbox run creates and destroys an ephemeral one. crabbox release --id is the reliable way to return Studio RAM. crabbox cleanup and the Studio launchd reaper are backstops for orphaned or expired containers, not a substitute for releasing the box you are done with.
The shape of the system
Crabbox is a remote execution surface with a deliberately small control plane. For Osprey Strike, the Crabbox setup in infrastructure/crabbox/ uses the Mac Studio as the Docker host and the developer laptop as the command center. There is no coordinator service in this path, no Cloudflare tunnel, and no AWS dependency. The laptop runs crabbox; Crabbox invokes an external provider script; that script SSHes to qs-mac-studio; Docker starts a container; Crabbox then syncs and executes against the container over SSH.
The important word is per-lease. Two branches, or two developers, do not share a working directory on the Studio. Each warm box gets its own container, its own synced checkout of the current branch, its own processes, and, in app mode, its own inner Docker data volume. That makes the box closer to an ephemeral CI worker than to a shared dev server.
Every box spun up this way starts from the branch you are on when Crabbox syncs. If you want to test a different branch, switch branches locally and create or target the corresponding box deliberately.
Setup is split between the laptop and the Studio
The laptop setup proves reachability. A developer needs the Crabbox CLI, Tailscale access to the tailnet containing qs-mac-studio, and SSH access to the Studio account that can run Docker. The source README calls out the one-time checks: tailscale status, ping qs-mac-studio, and ssh <studio_ssh_user>@qs-mac-studio echo ok.
brew install openclaw/tap/crabbox
tailscale status | grep qs-mac-studio
ssh <studio_ssh_user>@qs-mac-studio echo ok
The Studio setup has two durable pieces. build-runner.sh ships the runner build context to the Studio and overlays the live repo-root .mise.toml, so the image bakes the project toolchain from the same source developers use locally. install-reaper.sh installs an hourly launchd job that runs studio-reap.sh, removing orphaned containers that outlive their hard cap.
cd infrastructure/crabbox
./build-runner.sh <studio_ssh_user>@qs-mac-studio
./install-reaper.sh <studio_ssh_user>@qs-mac-studio
The current setup pivot is where that config lives. Older Crabbox builds around 0.34.0 accepted a repo-root crabbox.yaml. Newer builds moved repository config into an untrusted domain: 0.36.0 blocked repository config from redirecting inherited provider credentials, and 0.37.0 tightened that boundary for external providers. On 0.38.0, the Studio external provider belongs in trusted user config, which crabbox config path prints on your laptop. On macOS that is usually ~/Library/Application Support/crabbox/config.yaml.
The trusted user config chooses the external provider and passes the Studio-specific settings into studio-docker-provider: the host, SSH user, runner image, container user, persistent laptop keypair path, Docker binary path, and max_lifetime_secs. It also has to set external.connection.ssh.trustProviderOutput: true, because the provider creates a container and reports back its ephemeral SSH host and port. Crabbox refuses to inherit personal SSH credentials into a repository-configured external provider for the same reason it refuses most credential redirection from untrusted files: a checkout should not be able to decide where your key is offered.
provider: external
external:
command: /absolute/path/to/osprey-strike/infrastructure/crabbox/studio-docker-provider
workRoot: /work/crabbox
connection:
ssh:
trustProviderOutput: true
config:
studio_host: qs-mac-studio
studio_ssh_user: q
image: crabbox-studio-runner:latest
container_user: crabbox
ssh_key: ~/.config/crabbox-studio/id_ed25519
max_lifetime_secs: 28800
docker: /usr/local/bin/docker
# privileged: true # app boxes only
The trusted config file must be locked down: chmod 0600 "$(crabbox config path)". crabbox doctor fails the file at broader permissions, and that failure is the correct place to catch setup mistakes before spending a container.
privileged: true is chosen when the box is created, not per command. Keep a plain test config for gates and a privileged app config for full-stack work if you want both modes without editing one file back and forth. If you change the flag, release the old box first; just crabbox ensure will otherwise hand you the ready box created under the previous setting.
Pure CLI work is the base layer
The first useful mode does not need Claude Code or app helpers. Once config is in place, Crabbox gives you the normal lifecycle: check the provider, warm a box, name the box you are targeting, run a command, SSH into it, and release it.
crabbox doctor
crabbox warmup
crabbox list
BOX="<slug from list>"
crabbox run --id "$BOX" -- just api generate
crabbox run --id "$BOX" -- just api check
crabbox ssh --id "$BOX"
crabbox release --id "$BOX"
When there is more than one ready box, make the slug explicit. This is the habit that keeps parallel branch work sane.
crabbox list
crabbox run --id <slug> -- just api generate
crabbox run --id <slug> -- just api check
crabbox run --id <slug> -- just mcp check
crabbox ssh --id <slug>
The provider makes this possible by returning normal SSH coordinates to Crabbox: user, host, published port, and key path. The container’s sshd is published on an ephemeral port on the Studio host, bound to a Tailscale-reachable address. Crabbox handles the sync and command execution after that; the provider is only the lifecycle adapter.
Level 1 work is multi-box friendly. Running gates on several branches is just a matter of keeping the slugs straight and passing --id <slug>. On a fresh box, run just api generate before just api test; gqlgen outputs are gitignored and do not sync into the container, so a fresh Crabbox has the same codegen gap as a fresh worktree.
Osprey adds app helpers
The Osprey helper layer starts where plain command execution stops. The infrastructure/crabbox/justfile is meant to be imported from the repo root so operators can list boxes, ensure one exists, capture screenshots, and open browser tunnels without remembering the lower-level SSH plumbing.
just crabbox ensure
just crabbox list
just crabbox screenshot "" <slug>
just crabbox screenshot admin-screen <slug>
just crabbox screenshot /admin/tenants <slug>
just crabbox app-tunnel <slug>
just crabbox ensure prints the slug of a ready box, reusing one if possible unless --fresh is passed. It deliberately does not start the app stack. That separation matters: a ready box means Crabbox can SSH to it; an app-ready box means just dev-headless has already brought up the Osprey services inside it. It also means a config change does not mutate an existing box. If you switch from a test box to a privileged app box, release the old slug before ensuring a new one.
For app work, the normal sequence is to ensure or choose a box, run the full stack inside that box, then use either screenshot automation or an SSH tunnel.
# after setting privileged: true in the trusted config
crabbox release --id "$BOX" # if an old test box exists
BOX="$(just crabbox ensure)"
echo "$BOX" # confirm this is a new slug
crabbox run --id "$BOX" -- just dev-headless
just crabbox screenshot "" "$BOX"
just crabbox screenshot admin-screen "$BOX"
just dev-headless returns after the stack reaches STACK READY; it does not hold the terminal open. The screenshot helper now takes three positional arguments: what, slug, and out. Keep the first argument explicit when you are passing a slug. An empty what runs the built-in goto scenario at /admin/tenants, a leading-slash value runs that goto scenario at a different app path, a bare name runs a committed library scenario from infrastructure/crabbox/scripts/<name>.mjs, and an existing .mjs file runs as either an in-tree scenario or an uploaded scratch scenario. A missing .mjs path is an error, not a route to navigate to.
The screenshot path runs Playwright inside the box against http://localhost:4700, logs in through Keycloak as a seeded user such as sysadmin/sysadmin, drives the selected scenario, writes one or more PNGs in an in-box staging directory, then uses the SSH details from crabbox ssh --id <slug> to copy artifacts back. By default, outputs land under /tmp/screenshots/ rather than in the repo tree, and existing filenames get anti-clobber suffixes.
Level 2 is also multi-box capable. You can keep several full-stack boxes alive, but commands should name the target slug because auto-selection only stays safe when exactly one ready box exists.
Claude Code uses crabbox-it
Claude Code should use Crabbox as a remote workbench, not as a mystery side channel. The crabbox-it skill is the right interface when the agent is already inside a coding task and needs an isolated box to run gates, boot the stack, inspect a page, or capture a screenshot.
The useful contract for crabbox-it is simple:
- Ensure or reuse a Crabbox for the current branch.
- Run the requested command with an explicit slug.
- If app inspection is needed, start
just dev-headlessin the box. - Use
just crabbox screenshot "" <slug>for a route capture, or a named scenario such asjust crabbox screenshot admin-screen <slug>for richer visual proof. - Report the slug, command, result, and any captured artifact.
That wrapper is valuable because it keeps the agent from mixing local host state with branch-specific app state. The box is still just a Crabbox. It still contains the current branch clone. The difference is that Claude Code can treat it as a repeatable environment instead of hand-assembling the lifecycle every time.
Use crabbox-it to run the API gate for this branch.
Use crabbox-it to boot the app and capture /admin/tenants.
Use crabbox-it to verify the fix in a fresh box.
Use the Claude Code path for tasks where the agent needs to iterate. Use pure CLI when a human operator already knows the exact command and only wants the box as remote execution capacity.
There are three interaction levels
Crabbox becomes easier to reason about when the interaction levels are explicit. The same box can support different work, but the concurrency story changes as you climb the ladder.
| Level | What you do | Example | Multiple boxes? |
|---|---|---|---|
| 1 | Run gates and tests | crabbox run --id <slug> -- just api check |
Yes. This is the best multi-branch mode. |
| 2 | Run the full stack and automate screenshots | crabbox run --id <slug> -- just dev-headless; just crabbox screenshot "" <slug>; just crabbox screenshot admin-screen <slug> |
Yes, within Studio resources. Always name slugs. |
| 3 | Open local browser tunnels | just crabbox app-tunnel <slug>; open localhost:4700 |
Practically one active target per local port pair. |
Level 1 is cheap and direct. It is the right default for gates, tests, and commands that do not need an app browser. Level 2 is heavier because privileged app boxes run an inner Docker daemon and the Osprey stack. It is still parallelizable, but bounded by the Studio’s CPU and RAM. Level 3 is human-interactive: the tunnel forwards local :4700 to the app gateway and local :4710 to Keycloak because the login redirect needs both origins.
just crabbox list
just crabbox app-tunnel <slug>
# open http://localhost:4700
# Keycloak is forwarded at http://localhost:4710
Because the tunnel binds fixed local ports, treat Level 3 as a single selected box at a time on a laptop. You can stop one tunnel and open another, or choose different local ports by changing the helper, but the default operator model is one interactive box per 4700/4710 pair.
If local just dev is already using 4700 or 4710, close it before opening the tunnel. The forwarded ports must match the app and Keycloak redirect origins.
Lifecycle is part of the contract
A remote dev box that never goes away is just a shared server with extra steps. Crabbox avoids that by giving boxes a lifecycle, but the commands are easy to misread. crabbox warmup keeps a box available for reuse. crabbox run --id <slug> -- ... reuses that named box. A bare crabbox run -- ... leases a fresh ephemeral box and destroys it when the command exits, so every command pays the cold dependency cost again. crabbox release --id <slug> tears down the selected lease. A bare crabbox release only prints a usage line and leaves the box running. crabbox cleanup lets the provider remove expired or orphaned containers; it is not a housekeeper for a healthy warm box. The Studio reaper is the safety net when a laptop dies mid-lease.
The provider labels containers with the lease, slug, creation time, and max_lifetime_secs. In privileged mode it also creates a lease-named /var/lib/docker volume so the inner Docker daemon gets a real lifecycle: removed on release, swept by cleanup, and pruned by the reaper when orphaned.
crabbox list
crabbox release --id <slug>
crabbox list # confirm the box is gone
crabbox cleanup
ssh qs-mac-studio 'cat ~/.crabbox-studio/reap.log'
The most common scary-looking message is not a Studio failure at all: lease <id> claim changed; retry. The Crabbox source notes this as a benign optimistic-concurrency race in the local claim file, usually right after crabbox warmup. Re-run the command.
Other failures usually sort into a few buckets: the laptop cannot reach qs-mac-studio; the provider cannot find Docker because non-interactive SSH has a minimal PATH; the trusted user config is missing external.connection.ssh.trustProviderOutput: true or is broader than 0600; a fresh box is missing gitignored gqlgen output before just api test; or an app command is running in a non-privileged test box. The first is Tailscale/SSH setup. The second is the docker: absolute path in the trusted config. The third is the config trust boundary. The fourth is crabbox run --id "$BOX" -- just api generate. The fifth is a box-type choice: app boxes need privileged mode before they are created, and changing the flag means releasing the stale box before ensuring a new one.
What to take forward
The Crabbox setup is not merely faster remote hardware. It is a way to give humans and agents branch-scoped, disposable, inspectable environments without turning the Mac Studio into a shared mutable workstation. The clean mental model is: use Level 1 for gates, Level 2 for full-stack proof and screenshots, Level 3 for one selected browser session. Keep slugs explicit once more than one box exists. Rebuild the runner when the toolchain changes. Let the reaper make forgotten boxes temporary.
- Crabbox on
qs-mac-studiois direct-only: laptop CLI, SSH over Tailscale, Docker on the Studio, no coordinator. - Each box gets a synced clone of the current branch, so branch choice happens before or during box creation and sync.
- Current Crabbox builds require the Studio external provider in trusted user config with
trustProviderOutputenabled and permissions locked to0600. - Pure CLI Crabbox commands are the base layer and work well with multiple boxes when slugs are explicit.
- Osprey's
just crabboxhelpers add ensure, list, screenshot, and browser tunnel workflows. - Claude Code should use
crabbox-itwhen the agent needs an isolated branch box for gates, app boot, screenshots, or verification. - The tunnel workflow is intentionally single-target by default because local ports
4700and4710point at one selected box.
Discussion Prompts
- Which gates should become the default Level 1 smoke set for every branch before a PR?
- Should the repo carry a clearly named user-config example, plus one plain test variant and one privileged app variant, so nobody copies provider config into the repo root?
- What screenshots should
crabbox-itcapture by default when validating an app-facing change?
References
- Crabbox - The CLI and provider model used by the Studio Docker external provider.
- Constructured/osprey-strike#1031 - Setup feedback that exposed the current trusted-user-config requirements and warm-box lifecycle traps.
- Tailscale: What is Tailscale? - Background on the private network layer that makes the Studio reachable without exposing it publicly.
- Docker Docs: Running containers - The underlying container lifecycle Crabbox delegates to the Studio provider.
Generated by Cairns · Agent-powered with Claude