Boxes runs AI coding-agent sessions in isolated Docker containers and gives you a mobile-friendly web UI to drive them. Each session is a long-lived container with its own workspace directory, its own internal network, and no route out except through a proxy that vets every destination.
Agent turns keep running when your browser goes away. The orchestrator — not the browser — is the agent's client of record, so you can lock your phone mid-task and find the finished thread when you come back.
- Isolated sessions. One container, one network, one workspace per session. Non-root, read-only rootfs, no capabilities, no host mounts, no published ports.
- Vetted egress only. Session networks are
internal; the sole way out is an egress proxy that rejects private addresses, pins the connection to a vetted IP, and can be given a host allowlist. - No credentials in the sandbox. Sessions hold placeholder tokens. The proxy swaps in the real ones on the wire, and refuses any other credential to those hosts — so a leaked placeholder is worth nothing.
- One service, one port. The orchestrator serves the UI, the REST API and
the WebSocket gateway on
:3000. No second origin, nothing to configure. - Claude Code today, other agents later — sessions speak the Agent Client Protocol (ACP).
ARCHITECTURE.md describes how it is built.
- Docker with Compose v2, on Linux or macOS
- A Claude token from
claude setup-token(subscription-based, inference only) - Node 22+ — only if you want to develop on Boxes itself
git clone https://github.com/splitbrain/experiments.git boxes
cd boxes
docker compose up -dThe session image is deliberately not part of compose.yaml — the
orchestrator creates session containers at runtime, so it fetches that image
itself: once at boot when it is missing, and again every
SESSION_IMAGE_PULL_MINUTES so a moving tag moves here too. A session adopts
what has arrived the next time it is started.
To run one you built rather than one you pull, build it and turn the refresh off — there is no registry to pull a local tag from:
docker build -t boxes-session:latest session-image/
echo SESSION_IMAGE_PULL_MINUTES=0 >> .envBoxes is now on http://localhost:3000, bound to loopback because it ships with no authentication of its own. See Behind a reverse proxy before moving it.
SESSION_UID and SESSION_GID are the uid and gid every session process runs
as, and therefore the owner of every file in a workspace. They default to
1020 rather than 1000: 1000 is the ubuntu account the base image
carries and what a host usually gives its first login user, and a service
sharing a uid with a person is what a per-service uid exists to avoid.
One thing has to agree with them: the session image builds its agent user on
the same numbers, through AGENT_UID and AGENT_GID build args whose defaults
match. A session's home is a named volume, and Docker ownership-initialises a
new one from the image's own /home/agent — so an image built on a different
uid leaves the agent unable to write its own home, and nothing outside the
container can chown a named volume afterwards. The orchestrator reads the
image's user back at boot and warns when the two have drifted.
To run on some other uid, set both and build to match:
docker build --build-arg AGENT_UID=1000 --build-arg AGENT_GID=1000 \
-t boxes-session:latest session-image/
echo SESSION_UID=1000 >> .env
echo SESSION_GID=1000 >> .envSessions created before a change keep the old ownership, so changing this on a live deployment means recreating them.
It has needed it for one thing: giving each workspace directory away to the
session uid, which only root can do. Set the orchestrator's own user to
SESSION_UID and there is nothing to give away — chownToAgent returns
immediately — so it can run as an ordinary user:
services:
orchestrator:
user: "1020:1020"
group_add:
- "<the host's docker gid>" # getent group docker | cut -d: -f3group_add is what keeps the Docker socket reachable. Two caveats worth being
clear about: the data directory has to be owned by that uid already
(chown -R 1020:1020 on it), and this is tidiness rather than containment —
a process holding the Docker socket is root-equivalent on the host whatever
uid it runs as, so the authentication in front of /api is still what matters.
Every push to main builds the three images and pushes them to GHCR, so a
deployment needs no checkout and no build:
| Image | Is |
|---|---|
ghcr.io/splitbrain/boxes/orchestrator |
What compose.yaml builds from orchestrator/Dockerfile |
ghcr.io/splitbrain/boxes/egress-proxy |
The same for proxy/Dockerfile |
ghcr.io/splitbrain/boxes/session |
The session image, which is a service in no compose file |
Each carries latest and an immutable sha-<short>. A deployment that wants
to be rolled forward by something like watchtower follows latest; one that
must not move under itself pins the sha.
The session image needs nothing special of the deployment: point
SESSION_IMAGE at latest and the orchestrator keeps it current itself. What
it must not have is an outside updater, because compose does not own those
containers and recreating one loses the id the orchestrator tracks it by and
the proxy attachment that is the session's only way out. Session containers
therefore carry com.centurylinklabs.watchtower.enable=false, which watchtower
and the tools that copy it honour.
.github/workflows/publish.yml is the workflow, and the test suites gate it.
A pull request runs the same suites and builds all three images without
pushing any of them.
A deployment fronted by watchtower can be told to pull rather than left to find out on its next poll. Set two things on the repository and the last job of every publishing run pings it:
| Setting | Is |
|---|---|
WATCHTOWER_URL (variable) |
The update endpoint, such as https://watchtower.example.net/v1/update |
WATCHTOWER_HTTP_API_TOKEN (secret) |
What watchtower was started with as WATCHTOWER_HTTP_API_TOKEN |
Without the variable the job says there is nothing to notify and passes, so a fork publishing to its own namespace needs no changes. With it, a ping that is refused fails the run: the images are already published at that point, and a deployment that quietly stayed where it was is worth a red check.
There is no required configuration — every setting has a working default. To
change one, copy .env.example to .env and edit it. To keep the file out of
the repo, point at it instead:
BOXES_ENV=~/.config/boxes.env docker compose up -dTo actually run an agent turn you need one credential:
claude setup-token # then export it, or put it in ./.env
export PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
docker compose up -dThe two credentials are the only settings compose takes from the shell, so
they need never be written down. That is also why they are the only two a
BOXES_ENV file cannot carry — use the shell or ./.env for those, and the
env file for everything else.
Without it, sessions still start and the UI still works — only inference fails. Alternatively skip the variable and log in inside a session, which keeps the credential in that session's home volume and out of every file:
docker exec -it session-<id> claude /login| Variable | Default | What |
|---|---|---|
BIND_ADDR |
127.0.0.1 |
Interface the port is published on |
HOST_PORT |
3000 |
Published port |
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN |
— | Claude token; without it no turn can run |
PROFILE_DEFAULT_GH_TOKEN |
— | Classic GitHub PAT for the bot account |
PROFILE_DEFAULT_GIT_NAME |
boxes-bot |
Git author name in sessions |
PROFILE_DEFAULT_GIT_EMAIL |
boxes-bot@users.noreply.github.com |
Git author email |
WS_AUTH_TOKEN |
generated | Gateway bearer token; generated on first boot into /data/ws-auth-token and reused |
SESSION_IMAGE |
boxes-session:latest |
Image sessions run |
SESSION_UID |
1020 |
uid session processes run as, and the owner of every workspace file. The session image must be built on it |
SESSION_GID |
1020 |
gid to match |
SESSION_IMAGE_PULL_MINUTES |
60 |
How often that image is pulled again; 0 never, for one built on the host |
SESSION_SUBNET_POOL |
10.200.0.0/16 |
Pool sessions get a /24 from |
SESSION_MEM_LIMIT |
4g |
Per-session memory cap |
SESSION_CPUS |
2 |
Per-session CPU cap |
SESSION_PIDS_LIMIT |
512 |
Per-session pids cap |
IDLE_STOP_MINUTES |
30 |
Idle time before a session container is stopped |
BACKGROUND_POLL_SECONDS |
20 |
How often a box is asked whether work is still running in it |
AGENT_QUIET_SECONDS |
3 |
Silence after which the agent counts as having stopped talking |
AGENT_SETTLE_SECONDS |
30 |
Silence after which you are told the turn is over |
MAX_ATTACHMENT_MB |
25 |
Largest single file a prompt may carry into a workspace |
PERMISSION_FALLBACK |
hold |
hold or deny for an unanswered permission request |
PERMISSION_HOLD_MINUTES |
120 |
How long before that fallback applies |
PUSH_SUBJECT |
project URL | Contact in the VAPID assertion Web Push carries |
DATA_DIR |
/data |
Database, generated token and session workspaces, inside the volume |
HOST_DATA_DIR |
resolved | Host-side path of DATA_DIR, which a workspace bind mount has to name. Resolved at boot by inspecting the orchestrator's own container; set it only where that cannot work |
EGRESS_PROXY_CONTAINER |
boxes-egress-proxy |
Proxy container the orchestrator attaches to session networks |
EGRESS_ALLOWED_HOSTS |
— | Hosts sessions may reach; empty means every public host |
Everything is parsed and validated at boot, so a bad value fails startup
rather than surfacing later. orchestrator/src/config.ts is the full list.
docker compose up -d # start
docker compose logs -f # follow
docker compose down # stopHealth check and verification:
curl localhost:3000/healthz
API_BASE=http://localhost:3000 ./scripts/smoke-test.shThe smoke test needs no credentials: it creates throwaway sessions, asserts the isolation properties from inside a container, and cleans up. Run it after any change to networking, the proxy, or the session image. Where the deployment has configured credentials or an allowlist, it additionally proves that no real credential is inside a session and that the allowlist bites.
Open http://localhost:3000.
Create a session. Give it a name. The session gets its own container, network and storage, and its workspace starts empty — tell the agent what to fetch into it. If the deployment has any agent sets beyond the global one, pick which of them this box gets.
Talk to the agent. Tap a session card to open its thread. That is the whole interface: type, and the turn runs in the container. Close the tab or lock your phone whenever you like; reattaching replays the thread from the session's own history — including a turn that is still running, which comes back mid-flight rather than looking finished. Opening a stopped session starts it again.
The tab's title is the box and the conversation, behind a symbol for what that
thread is doing: ⟳ running a turn, ⚠ waiting for a permission decision,
? waiting for an answer, ○ idle. Several boxes in several tabs is the
normal way to use this, and the symbol is the part a narrow tab still shows.
Watch it think. A turn shows the agent's reasoning as a collapsed Reasoning line above what it does, streaming while it goes. The sliders in the thread's header hold everything the agent offers — its mode, its model, its effort level, and whatever else the adapter advertises. All of it is per conversation.
Watch work that reports back later. A command left running in the background, a subagent, a monitor — none of them answers into the turn that started it. When one has something to say it wakes the agent, and the thread shows that as a row of its own rather than as a message from you: what happened, and what the task said. A monitor's event is there to read; a finished task's answer is folded under its summary and opens on a tap.
Work in two threads at once. A session can hold several conversations on
one workspace, listed under its card, each with its own link. Fork branches
the one you are in, and the button offers to open it in a new tab — so you can
ask the fork about what the original is doing without stopping it or losing
your place. It opens on everything that was said up to the branch, and goes
its own way from there. A fork starts in plan mode, because it shares the original's
checkout and two agents editing the same files at once is a mess neither can
see; flip it to auto under the header's sliders when that is what you want.
Attach a file. The + under the composer takes anything — drop it on the
thread or paste it, on a phone as much as on a desktop. Whatever it is, it is
uploaded into that session's workspace under .boxes/attachments/, and the
prompt tells the agent the path, the type and the size, so it opens a
screenshot, a PDF or a CSV with the tools it already has. Nothing is carried
in the message itself, so an attachment costs context only when the agent
reaches for it. Images still show in the thread, served back from the
workspace, and anything else reads as a chip you can tap — a PDF opens in a
tab. The files stay there afterwards, and a .gitignore keeps them out of
any repository you are working in.
Run a shell command with !. A composer line starting with ! runs as
bash -lc in the session container and never reaches the model — no tokens
spent, no chance of it being read as an instruction:
!npm test
!git diff --stat
Output is printed as it arrives, as a code block ending with the exit code. Commands are capped at 120 seconds and 256 KiB of output.
Review the code. Review on a session card, or the magnifier in a thread's header, opens the session's workspace as a review: browse the files, read one highlighted, and tap a line to leave a comment. Git statuses colour the tree and changed lines are marked in the gutter: tapping the code is how a comment starts, and tapping the gutter beside it shows that change as a diff — including the lines that were deleted, which the file itself cannot show. Compare against a branch, tag or commit to review a whole branch's work rather than only what is uncommitted.
The review is over the whole workspace, not over one repository in it. A box
that holds two clones side by side, a dependency checked out beside them, or a
repository inside a repository browses as one tree, and each file is shown with
the status and diff of the repository it is actually in — repository roots are
marked so you can see where one stops and the next begins. Files that are in no
repository show too, without the git parts. There is nothing to pick and
nothing to switch between. Compare against takes one revision for the whole
workspace and resolves it in each repository separately, so main means
main-in-each; where it landed, and where it named nothing, is on the picker.
Comments are written to a single REVIEW.md at the top of the workspace, with
workspace-relative paths in it. That is the point of having this here rather
than beside it: Hand to agent opens the thread with "Read REVIEW.md and
address the comments in it." waiting in the composer — staged, not sent. It is
the same one line however many repositories the box holds, and because the file
sits above all of them it cannot be committed into one by accident. The agent
can edit and delete it too, and a comment whose code has since moved follows
it, or is marked as no longer matching.
The format is the desktop review
tool's, so a review reads the same in both.
Reviewing needs no running container — the files are a directory on the orchestrator's data volume — so the natural moment, once the agent is done and the box has idled out, costs nothing. Nothing is polled while a review sits open: it refetches when you arrive, when you close a file back to the list, and when you come back to the tab. A session created before Boxes stored workspaces this way says so and asks you to start it once, which migrates it.
Configure the agent. The sliders in the session list header open Agent
configuration: an AGENTS.md, skills and slash commands, managed here rather
than pasted into every box.
They live in sets. The Global set goes into every box and cannot be deleted. Any other set is optional: a box names at most one when it is created, and the two are merged —
| How the two sets combine | |
|---|---|
AGENTS.md |
Concatenated, global first, separated by a blank line |
| Skills | Union by name; the named set's replaces the global one of the same name |
| Slash commands | Same |
— and the set's editor shows the result, so an override is never silent.
The session image is a third layer, underneath both. A skill the image
ships — playwright-cli, for the browser, is the only one today — is installed
into a box only when no set in its merged configuration claims that name. So
the same rule runs all the way down: the more specific configuration wins, with
the image least specific of all. Define a playwright-cli skill in a set and
the box gets yours; delete it again and the image's comes back at the next
start. The one thing the editor cannot show is that bottom layer, because it
lives in the image rather than the database — so if a skill you did not write
turns up in a box, this is where it came from.
Inside the box, the merged set is installed into the agent's own configuration:
the AGENTS.md as its user-level memory, so it applies wherever in the box the
agent is working; a skill as skills/<name>/SKILL.md, which needs YAML front
matter naming and describing it or it is not loaded at all; a command as
commands/<name>.md, invoked as /<name> in the composer.
An edit reaches a box the next time that box starts. Nothing is pushed into a running one. Stop and start it, or create a new one.
Answer permission requests. When the agent asks to do something requiring consent, the request goes to a browser watching the thread that asked. With nobody on that thread it is queued, you are notified, and it is delivered to the next browser to open it. Nothing is ever auto-approved.
Manage the session. The ⓘ corner of a card opens its details and controls: start, stop, delete, the container and network names, and the WebSocket URL and bearer token for attaching your own ACP client. Deleting removes the storage too, so the agent's work and the thread history go with it.
Idle sessions — no turn on any thread, no waiting request, no attached browser
— are stopped after IDLE_STOP_MINUTES. They are never deleted. Work left
running in the background counts as not idle: a command still going or a
monitor still watching holds the stop off. The box is asked what is running in
it rather than told, every BACKGROUND_POLL_SECONDS, so backgrounding a long
build and closing the tab is safe, and a task that ends without reporting —
killed, crashed, or simply quiet — stops holding the box the moment it is
actually gone.
Work still running says so. A thread in a box with something left going says so above the composer, and the session list carries the same count. That is the difference between a thread that has finished and one that is waiting for you with a build still in it — and the composer is yours in both, because a turn the adapter is holding open for a background subagent is not an agent that is still talking.
A box that wants something tells you whenever nobody is watching that thread: a permission request has been queued, or a turn has finished and is waiting for you. A turn finishing in front of you is not announced — that is the screen you are already looking at. "Finished" means the agent has stopped talking rather than that a request came back, so a turn holding a background subagent open is announced when the agent actually goes quiet, and the notification says what is still running.
Web Push is the one channel, and it reaches a browser with no tab open,
which is the case the feature exists for: lock your phone mid-turn and the
answer arrives on the lock screen. Tap Notify me on the session list to
subscribe this browser. Nothing to configure — the deployment generates its
own VAPID keypair into /data/vapid-keys.json on first use, and the tap is
the whole setup.
Two things it needs, both outside Boxes:
- HTTPS. The Push API does not exist on a page served over plain HTTP,
http://localhostexcepted. So push works on the loopback default and behind a TLS reverse proxy, and not at all on a barehttp://192.168.x.x:3000— the toggle says so rather than failing quietly. SetPUSH_SUBJECTto your ownmailto:orhttps:URL so a push service with a problem has somebody to contact. - Add to Home Screen, on iOS. Safari gives a page the Push API only once it has been installed. Share → Add to Home Screen, open it from there, then subscribe. Android and desktop browsers need no install.
Unsubscribing is the same toggle. A subscription the push service reports as finished — permission revoked, browser uninstalled, Safari expiring it on its own schedule — is dropped on the next attempt without anybody doing anything.
Boxes has no authentication and holds the Docker socket, so as shipped it binds
to 127.0.0.1. Anything beyond a single-user machine needs a reverse proxy in
front — Caddy, nginx, Traefik, whatever you already run. Proxy to
127.0.0.1:3000, or join the boxes_default network and use
orchestrator:3000 if the proxy is itself a container. Only widen BIND_ADDR
once something else is doing the authenticating.
Two rules:
- Authenticate
/and/api, and terminate TLS there. Every route is otherwise open, including the one that creates containers. - Do not put HTTP authentication in front of
/ws. Browsers cannot attach Basic credentials to a WebSocket upgrade, so guarding it breaks every thread view. It does not need guarding: the gateway authenticates the upgrade itself againstWS_AUTH_TOKEN.
Also forward Upgrade and Connection on /ws, and give it a long read
timeout — a turn can hold the socket open for minutes with nothing on it.
Two settings shape it, and both default to something safe.
The allowlist. EGRESS_ALLOWED_HOSTS is a comma or whitespace separated
list of exact hostnames and one-label wildcards:
EGRESS_ALLOWED_HOSTS=github.com,*.github.com,*.githubusercontent.com,api.anthropic.com,registry.npmjs.org
*.example.com matches a.example.com, but neither example.com nor
a.b.example.com. An address literal matches only as a literal. Leave it unset
and behaviour is what it has always been: any public host, private ranges still
denied. The hosts of a credential you configured are always reachable, so a
narrow list can never sever inference or GitHub.
Token translation. It is always on, and it applies to whichever credentials you configured:
- Set
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN, andapi.anthropic.combecomes a translated host. - Set
PROFILE_DEFAULT_GH_TOKEN, and the GitHub hosts do. - Leave one unset and its host stays an ordinary tunnel, which is what keeps
the "log in inside a session with
claude setup-token" flow working.
For a translated host the session holds a placeholder of the same shape as the real token, and:
docker exec session-<id> env | grep -c sk-ant-oat01-... # 0
The proxy terminates TLS for that host under a CA generated once for your deployment, swaps the placeholder for the real credential, and refuses any other credential outright — so "api.anthropic.com is allowlisted" no longer implies "any Anthropic account is reachable". Everything else stays an opaque tunnel the proxy cannot read.
The real credentials live in the orchestrator's environment and in the proxy's memory, and nowhere else. The proxy has no config file, no database and no CA on disk: it boots empty and is handed its policy over an authenticated channel on the compose network, which no session can route to. Restart it and the orchestrator's reconciler pushes again within a minute.
The CA certificate reaches each session as BOXES_PROXY_CA, written by the
entrypoint to ~/.boxes/proxy-ca.crt, with NODE_EXTRA_CA_CERTS,
SSL_CERT_FILE, GIT_SSL_CAINFO and CURL_CA_BUNDLE pointing at it.
A tool that honours none of those variables fails TLS against translated hosts only, which is a confusing shape — everything else keeps working. The fix is to point that tool at the same file:
| Tool | Variable | Notes |
|---|---|---|
| node, npm, anything on Node | NODE_EXTRA_CA_CERTS |
Already set |
| curl | CURL_CA_BUNDLE |
Already set |
| git | GIT_SSL_CAINFO |
Already set |
| gh, and most Go tools | SSL_CERT_FILE |
Already set |
Python requests |
REQUESTS_CA_BUNDLE |
Add it, pointing at $BOXES_PROXY_CA's file |
Python httpx, aiohttp |
SSL_CERT_FILE |
Already set |
| Deno | DENO_CERT |
Add it |
Rust reqwest (rustls) |
SSL_CERT_FILE |
Already set |
Rotating the CA is deleting egress-secrets.json from the data volume and
restarting; sessions created before that keep the old certificate and must be
recreated.
apt-get install cannot work inside a session, and not by accident: the root
filesystem is mounted read-only, the agent is not root, and every capability is
dropped, so all three of the things that install needs are missing. Handing any
of them back would hand them to every session, for the sake of the one that
wanted a package.
There are two ways in, and which one you want depends on whether the tool is this session's business or the deployment's. Before either, check whether the toolchain is already in the image.
The image carries Node, Python, Go, Rust and PHP, so a session can work in any of them without installing anything:
| Version | From | Also | |
|---|---|---|---|
| Node | 22 | NodeSource | npm; NPM_CONFIG_PREFIX is ~/.local |
| Python | 3.14 | Ubuntu 26.04 | python3-venv, pipx |
| Go | 1.26 | Ubuntu 26.04 | GOPATH is ~/go, cache ~/.cache/go-build |
| Rust | 1.93 | Ubuntu 26.04 | cargo, rustfmt, cargo-clippy, rust-src |
| PHP | 8.5 | Ubuntu 26.04 | Composer 2.9, and the mbstring, xml, curl, zip, intl, sqlite3, gd and bcmath extensions |
build-essential, cmake, pkg-config and libssl-dev are there too, so a
crate or an extension with a native dependency builds. uv is installed as
well, which is worth knowing under a read-only /usr: uv tool install and
uv python install both write to the home volume, so a session can fetch a
Python it does not have without any privilege at all.
The tools an agent reaches for that are not a language — reading the PDF a
ticket linked to, unpacking whatever a download turned out to be, converting a
document, poking at a database file. A read-only /usr means a tool that is
not here is one the agent has to build or do without mid-task, so the list
leans generous:
pdftotext, pdftoppm, pdfinfo and the rest of poppler; qpdf; mutool; gs; ocrmypdf with tesseract (English) |
|
| Documents | pandoc |
| Images, diagrams | magick (ImageMagick 7), dot and the rest of graphviz |
| Archives | unzip, zip, xz, zstd, 7z, bzip2 |
| Files, text | file, tree, patch, fd, rg, jq, yq, shellcheck, less |
| Data | sqlite3 |
| Network, VCS | curl, wget, git, git-lfs, gh, dig, nc |
Two notes that will otherwise cost someone an hour. fd is a symlink the
image adds, because Debian and Ubuntu install fd-find as fdfind to avoid a
name collision. And ImageMagick's shipped policy refuses PDF and PostScript
input — that is its Ghostscript hardening, left alone here — so rasterise a
PDF with pdftoppm or mutool draw, not magick.
Between the toolchains and these, the image is a few gigabytes; most of the toolchain half is Rust and most of the tool half is pandoc. Worth knowing before a first pull on a slow link, and the reason a deployment that wants a leaner image is better off stripping what it does not need in a derived one than with this image as shipped.
@playwright/cli and a Chromium build are in the image, so a session can drive
a real browser: inspect a page, click and type in it, screenshot it, scrape it,
or check the dev server it just started.
A CLI and a skill rather than an MCP server, which is what Microsoft
themselves recommend for coding agents, and the two share an implementation —
so this gives up nothing on interaction. playwright-cli snapshot renders the
page as an accessibility tree with a [ref=eN] handle on every element, and
click, type, select, hover and the rest take those refs, so the agent
never guesses a CSS selector:
- heading "Sign in" [level=1] [ref=e2]
- textbox "Email" [ref=e4]
- button "Continue" [ref=e5]playwright-cli type e4 user@example.com
playwright-cli click e5The browser lives in a daemon between invocations, so those are three separate
commands acting on one page, and each snapshot is written to a file with
only the path printed — which is the point of the CLI over MCP: the tree does
not land in the model's context unless the agent chooses to read it. Named
sessions (-s=name), tabs, network inspection, console, cookies, storage,
tracing, video and run-code for arbitrary Playwright snippets are all there;
playwright-cli --help lists them.
Its skill comes from the package, and the entrypoint runs
playwright-cli install --skills --global on every start. That writes
~/.claude/skills/playwright-cli/ — a SKILL.md plus nine reference files
maintained by the Playwright team — rather than into the workspace, which is a
git checkout and none of the image's business. Re-running each start means the
copy in the home volume follows the image instead of being frozen at whatever
that volume was initialised with.
It runs after the box's own agent configuration is installed, and defers to
it: a playwright-cli skill in one of the deployment's sets is the one the box
gets, and the image's copy is left out. See
Configure the agent — the image is the bottom layer of that merge.
Three settings the CLI cannot work out for itself, shipped as
/usr/local/share/boxes/playwright-cli.config.json and copied by the entrypoint
to ~/.playwright/cli.config.json, the CLI's documented global config, with the
egress proxy added — that part is only known at runtime. A project's own
.playwright/cli.config.json still overrides everything.
browserName: chromium. The CLI defaults to thechromechannel and looks for real Google Chrome at/opt/google/chrome/chrome. Without this the browser does not open at all.chromiumSandbox: false. Chromium's own sandbox needs a user namespace, and Docker's seccomp profile denies one to a container withoutCAP_SYS_ADMIN— which this container deliberately does not have. Left on, the browser dies at startup withChromium sandboxing failed. Note this is not the Playwright library's default, where it is already false. The container is the sandbox instead: non-root, no capabilities, read-only rootfs, no route out but the proxy.--disable-dev-shm-usage./dev/shmis Docker's default 64 MB, which Chromium exhausts on a substantial page and reports as a closed target. This moves that traffic to/tmp, already a 512 MB tmpfs.
The proxy entry carries NO_PROXY as its bypass list, so a dev server started
in the session is reachable while everything external still goes through the
egress proxy.
TLS. Chromium keeps its own trust store and reads none of the CA variables
the rest of the image is pointed at, so the entrypoint imports the deployment
CA into ~/.pki/nssdb with certutil. Without it the hosts the proxy
intercepts fail TLS in the browser and nowhere else.
Two smaller things. PLAYWRIGHT_BROWSERS_PATH is /opt/playwright, on the
image and so read-only at runtime. A project pinning its own Playwright has
two ways out of that. Either download the build its version wants, once, into
the home volume:
export PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright
npx playwright install chromiumOr use the browser that is already here, at /usr/local/bin/chromium — a
stable link to whatever revision the image installed, which is what a test
suite can hardcode:
chromium.launch({ executablePath: '/usr/local/bin/chromium' });That needs no download and no egress, at the price of a Chromium a couple of
Chrome majors from the one the library pins, which Playwright tolerates until
it does not. Naming it with executablePath is what skips the revision check;
channel: 'chromium' would go looking under PLAYWRIGHT_BROWSERS_PATH again.
Either way, pass --disable-dev-shm-usage: /dev/shm here is Docker's
default 64 MB, which Chromium exhausts on any substantial page and reports as
a closed target. The browser CLI's own config already carries it.
And the CLI writes snapshots and screenshots to .playwright-cli/ in the
working directory, which in a session is the workspace — convenient, since the
review surface then shows them, but worth a .gitignore entry in a repo the
agent works on regularly. Set outputDir in the config to move it.
Ask the agent to read a screenshot back and it is shown in the thread, tap to
zoom it full-screen: a Read of an image file returns the image itself, and
the chat renders one wherever it arrives — in a tool's result, or in what the
agent says. So "screenshot the dev server and show me" is a thing to ask for
from a phone.
Firefox and WebKit are one download away. Their binaries are not in the image — that would roughly double the browser layer for something most sessions never open — but their system libraries are, which is the half a session cannot install for itself:
export PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright
npx playwright install firefox webkitThat is the same route as the Chromium one above and it now ends in a browser
that starts. Without the libraries in the image it did not: apt-get needs a
root and a writable /usr that no session has, so the download reported
success and the launch died on libgtk-3.so.0 — or, for WebKit, on any of
forty-odd gstreamer and flite objects — which reads as a broken image rather
than as a missing package. The image build proves the route by walking it: it
downloads both, launches each and deletes them again, so a version bump that
breaks this stops the build rather than reaching a session.
Libraries rather than binaries is also the half that keeps. A binary in the
image would be pinned to the revision this image's Playwright wants, and a
project pinning its own resolves a different one and downloads it regardless —
the same reason /usr/local/bin/chromium exists. Library names carry no such
revision.
There is still no display, so --headed cannot work in any of the three.
playwright-cli install-browser chromium --only-shell in a derived image
drops the full browser and keeps just the headless shell if image size matters
more than the option of a headed run later. A deployment that only ever drives
Chromium drops the Firefox and WebKit libraries by deleting that block from
its own copy of the Dockerfile — a derived image cannot take them back out,
since an apt-get purge in a later layer removes the files without
recovering the bytes.
/home/agent is a persistent volume, so anything the agent puts there survives
restarts and image updates. The image points the user-level package managers at
/home/agent/.local and puts /home/agent/.local/bin on PATH, so an agent
can equip itself with no privilege at all:
npm install -g <pkg> # prefix is ~/.local, not /usr/local
pipx install <pkg> # PEP 668 clean; binaries land in ~/.local/bin
python3 -m venv ~/.local/venvs/<name>build-essential, python3 and git are already in the image, so packages
that compile on install do compile. ~/.local/bin is ahead of the system path
for both a plain docker exec and a login shell.
A .deb whose contents are all you need takes no root either —
unpacking a .deb into the home volume is an ordinary file write:
apt-get download ripgrep # apt honours the proxy variables
dpkg -x ripgrep_*.deb ~/.local # binaries, libs, no maintainer scriptsThe limits are worth knowing before you rely on it: apt-get download fetches
the one package and not its dependencies (apt-get install --print-uris -qq <pkg> lists those URLs), maintainer scripts never run, and a package that
expects to live under /usr may need LD_LIBRARY_PATH=~/.local/usr/lib. For a
CLI binary it is usually enough; for anything with a postinst it is not.
For a package the sessions on this deployment should simply have — a language
runtime, a headless browser and its libraries, a database client — build an
image on top of the published one and point SESSION_IMAGE at it. Root at
build time, no root at runtime, and none of the container hardening changes:
FROM ghcr.io/splitbrain/boxes/session:latest
# Root here, and only here.
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Back to the agent, on the uid the deployment runs sessions as.
USER 1020Two rules a derived image has to keep:
- End on the agent user, not root. The orchestrator runs the container as
SESSION_UIDregardless, so a strayUSER rootdoes not get root — but it does make the image's own uid wrong for the next rule. - Do not change the uid. A session's home is a named volume Docker
initialises from the image, owned by the uid the image was built on, and
nothing outside the container can chown it afterwards. Build with the same
AGENT_UID/AGENT_GIDasSESSION_UID/SESSION_GID. The orchestrator reads the image's user back at boot and warns when they have drifted.
Set SESSION_IMAGE_PULL_MINUTES=0 for an image built on the host, since there
is no registry to pull it from.
Neither Docker-in-Docker nor a mounted Docker socket. A session with the host's
socket would have root-equivalent control of the host and of this deployment:
it could read every other session's workspace and egress-secrets.json — the
CA key and the placeholder map — which is precisely what token translation
exists to prevent. --privileged for a nested daemon is host root by another
route, and would apply to every session rather than the one that asked. A
session that genuinely needs to run containers wants either an opt-in
sysbox-runc runtime on the host, or a facility where the orchestrator creates
the container on the session's behalf from the same hardened template. Neither
exists today.
Isolation rests on two Docker primitives, with nothing touching the host firewall:
- Session networks are
internal. No NAT, no default route: no L3 path to your LAN, the internet, or another session. - The egress proxy is the only way out. It checks the allowlist, resolves the target, rejects the request if any resolved address is private, then connects to that specific vetted address without re-resolving — which is what closes DNS rebinding. Every connection it makes goes through that one check, including the ones it makes on behalf of a translated host. If the proxy is down, sessions have no egress at all.
- Credentials never enter the sandbox. See What a session can reach.
Session containers additionally run as a non-root user with ReadonlyRootfs,
CapDrop: ALL, no-new-privileges, a tmpfs /tmp, and memory, CPU and pids
limits.
Known residual risks, accepted deliberately:
- Host services bound to
0.0.0.0stay reachable from inside a session at the host's per-bridge IP; Docker's internal-network isolation filters forwarded traffic only. Anything sensitive on the host must have its own auth. - The orchestrator holds the Docker socket, which is root-equivalent. It is
mitigated by a fixed container template that user input never reaches and by
never shell-executing user strings — but the auth you put in front of
/apiis what keeps it yours. GET /api/sessionsreturnsWS_AUTH_TOKEN, behind that same auth.- A compromised proxy sees the credentials it injects. That is true of any injecting proxy; what this one adds is that it leaves nothing at rest.
- A credential you do not configure is not translated. A session that logs
itself in with
claude setup-tokenholds its own token, and prompt injection can leak that one. - Sibling sessions share a deployment's placeholders, so they map to the same real credentials. Per-session placeholders arrive with per-session credentials.
- Protocol behaviour is pinned to
claude-agent-acp0.70.0. Re-check capabilities and WebSocket framing on upgrade.
One toolchain across the repository: every package builds with Vite, tests with
Vitest, and type-checks before its Docker bundle, so an image cannot be built
from code that fails tsc.
cd orchestrator && npm run check && npm test
cd proxy && npm run check && npm test
cd dashboard && npm run check && npm test && npm run buildnpm run dev in dashboard/ serves the SPA with /api, /healthz and /ws
proxied to an orchestrator on port 3000. The dashboard's tests run in two
projects: unit for the framework-free stores, and e2e, which builds the
production bundle and drives it in Chromium against stub backends.
/playground renders every message part kind over a canned store, and the
browser suite asserts that page.
scripts/live-test.sh covers what only real inference can prove — a turn
surviving the browser leaving, thread replay on reattach, a permission request
held with nobody watching — and needs
PROFILE_DEFAULT_CLAUDE_CODE_OAUTH_TOKEN.
Tailwind utilities and shadcn/assistant-ui components only: no inline style=,
no CSS-in-JS, and design tokens defined once in src/globals.css — including
the @theme inline bridge, without which Tailwind silently drops every token
utility. .aui-md-bleed is a hand-written rule rather than utilities because
it needs container-query units and a negative margin computed from them: it
lets a table or a code block in chat output escape the 44rem reading column
out to the width of the thread and scroll horizontally beyond that. The rule
under it withholds content-visibility from the messages that hold such a
block, since paint containment would clip one back into the column.
Components under src/components/assistant-ui/ and src/components/ui/ are
installed by their official CLIs and committed. Our edits carry a comment
saying so. Upgrade by re-running the CLI and reading the diff:
cd dashboard
npx assistant-ui add thread --overwrite # or: npx shadcn add <name> --overwrite
npm run check && npm test| Path | What |
|---|---|
orchestrator/ |
Node 22 + TypeScript: REST API, SQLite, Docker lifecycle, ACP gateway, idle reaper |
dashboard/ |
React SPA — session list and chat — built into the orchestrator image and served at / |
proxy/ |
The egress proxy: allowlist, address vetting and token translation — the security boundary |
session-image/ |
The per-session container image |
shared/types.ts |
REST shapes imported by both orchestrator and dashboard |
scripts/smoke-test.sh |
Security smoke test, no credentials needed |
scripts/live-test.sh |
The checks that need a real Claude token |
ARCHITECTURE.md |
How the system is put together |