A sandboxed Linux computer for agents: a guest Docker image, two HTTP daemons (box-exec and box-host), a grok-box CLI, and connect-only SDKs (Rust, TypeScript, Python).
You start the guest. Then you talk to it over HTTP with a URL pair and a bearer token. This repo does not publish packages to crates.io, npm, or PyPI.
License: MIT. MSRV: Rust 1.85.
| Piece | Role |
|---|---|
Guest image grok-box |
Linux box: shell, files, 1280×800 X desktop, Chromium, Computer Use |
box-exec :1337 |
Exec, files (GET/PUT/DELETE/mkdir), CUA |
box-host :1340 |
Health, ready, identity, desktop/chrome/egress status |
CLI grok-box |
Same surface as the SDKs |
CLI grok-box |
Same surface as the SDKs |
| SDKs | Connect with (execUrl, hostUrl, token) — no docker run helper |
ensurebox/ |
Frozen demo / non-product — sample orchestrator / operator UI (not a supported control plane) |
l1/ |
Frozen demo / non-product — sample human workspace UI (talks only to EnsureBox) |
Windows and macOS are clients or Docker hosts. The box OS is always this Linux image.
This tree implements our own HTTP wire. It is not Cursor’s /exec-daemon or sand-host.
cp .env.example .env # set BOX_TOKEN and BOX_VNC_PASSWORD (required)
bash scripts/write-secrets.sh # turns them into ./secrets/* for Compose to mount
docker compose up --buildThat builds the image and starts exec, host, Xvfb, Chromium, and CUA tools. Compose publishes 1337 / 1340 / 6080 on 127.0.0.1. Reach them from another machine with an SSH tunnel, Tailscale, or similar — do not publish those ports on a public NIC.
Health (no token). Ready requires Bearer:
curl -fsS http://127.0.0.1:1337/v1/health
curl -fsS http://127.0.0.1:1340/v1/health
curl -fsS -H "Authorization: Bearer $BOX_TOKEN" http://127.0.0.1:1340/v1/readyExec (token required):
curl -fsS http://127.0.0.1:1337/v1/exec \
-H "Authorization: Bearer $BOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"command":["echo","ok"]}'Screenshot as JSON (base64 PNG) or raw image/png:
curl -fsS http://127.0.0.1:1337/v1/cua/screenshot \
-H "Authorization: Bearer $BOX_TOKEN" \
-X POST
curl -fsS http://127.0.0.1:1337/v1/cua/screenshot?format=png \
-H "Authorization: Bearer $BOX_TOKEN" \
-H "Accept: image/png" \
-X POST \
-o /tmp/box.pngCUA coordinate space is 1280×800, origin top-left. Known click/type/key sequences should go in one POST /v1/cua/recipe (see RECIPES.md) instead of one HTTP call per action.
A receipt's ok means no step returned an error, not that the recipe achieved anything: a taped plan whose fixed coordinates have drifted onto another window plays every step cleanly. Ask for observe: "input" (or "page") and each step also reports the window it aimed at, where the keys were about to go, and the page URL either side of it, so a caller can tell a run that worked from a run that did not.
Desktop viewer: http://127.0.0.1:6080/vnc.html (loopback publish). Password is BOX_VNC_PASSWORD (x11vnc uses the first 8 characters). It is independent of BOX_TOKEN. 6080 is not Bearer-authenticated — firewall + loopback bind are the control. x11vnc listens on 127.0.0.1:5900 inside the container.
./scripts/smoke.shAfter the guest is up, any orchestrator (yours, or the EnsureBox demo) already has an exec URL, host URL, and token. Point the client at those. The SDKs do not start Docker and ignore /v1/info advertised URLs (those are container-local listen addresses).
cargo run -p grok-box -- \
--exec-url http://127.0.0.1:1337 \
--host-url http://127.0.0.1:1340 \
--token "$BOX_TOKEN" \
exec -- echo okWorkspace packages (not published):
- Rust:
crates/grok-box(library +grok-boxbinary) - TypeScript:
sdk/typescript - Python:
sdk/python
./scripts/run-local.sh
# another terminal
./scripts/smoke-native.sh
cargo test --workspaceNative mode sets BOX_DESKTOP=0. CUA screenshot needs the container (or a local Xvfb).
- Start a container from this image. Inject
BOX_TOKEN,BOX_VNC_PASSWORD, andBOX_ID. Publish 1337 / 1340 / 6080 on loopback (or behind a tunnel). Do not publish 5900 or 9222. Optional laptop egress: EGRESS.md (BOX_EGRESS_TUNNEL=1, publish 8790 only, never 8791). - Wait until
GET <hostUrl>/v1/readyreturns 200 with Bearer. - Call
connect(execUrl, hostUrl, token)in the CLI or an SDK. Do not parse/v1/info.endpointsas the public URLs. - Drive
POST /v1/exec, files,/v1/cua/*, andPOST /v1/cua/recipeyourself.
ensurebox/ is a demo of that pattern. It is not a supported production control plane. l1/ is a demo human UI that talks only to EnsureBox (ENSUREBOX_TOKEN server-side, L1_TOKEN for the browser session). L1 never sees BOX_TOKEN, never SSHes, and never calls box-exec / box-host.
Demo UIs (optional; bind 127.0.0.1):
cd ensurebox && cp .env.example .env && npm install && npm run dev # operator console, :43142
cd l1 && cp .env.example .env && npm install && npm run dev # human workspace, :43141.env.example uses well-known demo tokens with *_ALLOW_INSECURE_DEV=1 for loopback only. Replace them for anything else.
| Port | Published? | Process | Notes |
|---|---|---|---|
| 1337 | host 127.0.0.1 |
box-exec |
exec, files, CUA. Process bind inside the image is 0.0.0.0. |
| 1340 | host 127.0.0.1 |
box-host |
health, ready, info, desktop, chrome, egress. |
| 6080 | host 127.0.0.1 |
websockify / noVNC | Viewer. Not Bearer-authenticated. |
| 8790 | host 127.0.0.1 |
box-egress-tunnel WS |
Laptop client. Off unless BOX_EGRESS_TUNNEL=1. See EGRESS.md. |
| 8791 | no | CONNECT proxy | Chromium only, 127.0.0.1 inside the image. Never publish. |
| 8792 | no | tunnel admin | Status for box-host. Loopback. Never publish. |
| 5900 | no | x11vnc | BOX_VNC_BIND=127.0.0.1:5900 inside the image |
| 9222 | no | Chromium CDP | 127.0.0.1 only (BOX_CDP_PORT). Do not publish. |
Compose also sets cap_drop: [ALL], security_opt: [no-new-privileges:true], pids_limit: 1024, and mem_limit: 4g. That is not a kernel sandbox. The guest is an unprivileged uid-1000 container; Chromium still uses --no-sandbox.
| Variable | Default (image) | Meaning |
|---|---|---|
BOX_TOKEN |
required | Bearer token for exec, CUA, and host /v1/info / /v1/ready. No silent default. |
BOX_TOKEN_FILE |
unset | Path to a file holding BOX_TOKEN. Preferred: wins over BOX_TOKEN, and keeps the value out of /proc/<pid>/environ. |
BOX_HOST_TOKEN |
same as BOX_TOKEN |
Optional split token for host info/desktop/chrome |
BOX_HOST_TOKEN_FILE |
unset | Path to a file holding BOX_HOST_TOKEN |
BOX_ALLOW_INSECURE_DEV |
unset | 1 allows short/well-known tokens only when both daemon binds are loopback |
BOX_ID |
hostname / grok-box |
Reported by /v1/info |
WORKSPACE_ROOT |
/workspace |
Jail root for cwd and file APIs |
BOX_EXEC_BIND |
0.0.0.0:1337 in image; 127.0.0.1:1337 native |
Exec listen address |
BOX_HOST_BIND |
0.0.0.0:1340 in image; 127.0.0.1:1340 native |
Host listen address |
BOX_EXEC_URL |
http://127.0.0.1:1337 |
URL host uses to probe exec (container-local) |
BOX_CORS_ORIGINS |
empty (no browser origins) | Comma-separated allowlist; * is ignored |
BOX_DISPLAY |
:1 |
X display |
BOX_DISPLAY_GEOM |
1280x800x24 |
Xvfb geometry; CUA coordinate space is 1280×800 |
BOX_DESKTOP |
1 |
Start Xvfb + openbox + x11vnc + noVNC |
BOX_DESKTOP_REQUIRED |
1 when desktop on |
/v1/ready waits for the display |
BOX_VNC_BIND |
127.0.0.1:5900 |
x11vnc (localhost only) |
BOX_NOVNC_PORT |
6080 |
noVNC / websockify |
BOX_VNC_PASSWORD |
required when desktop on | Viewer password; independent of BOX_TOKEN; x11vnc uses 8 chars |
BOX_VNC_PASSWORD_FILE |
unset | Path to a file holding BOX_VNC_PASSWORD. Preferred, same reason. |
BOX_CHROME |
1 |
Launch Chromium on :1 |
BOX_CHROME_PROFILE |
/home/box/chrome-profile |
Persistent profile (compose volume) |
BOX_CDP_PORT |
9222 |
CDP on 127.0.0.1 only |
BOX_CUA |
1 |
Enable /v1/cua/* |
BOX_EGRESS_TUNNEL |
0 |
1 starts box-egress-tunnel before Chromium and sets --proxy-server. See EGRESS.md. |
BOX_EGRESS_WS_BIND |
0.0.0.0:8790 in image; 127.0.0.1:8790 native |
WebSocket for the laptop client |
BOX_EGRESS_PROXY_BIND |
127.0.0.1:8791 |
HTTP CONNECT for Chromium. Never publish. |
BOX_EGRESS_ADMIN_BIND |
127.0.0.1:8792 |
Loopback status for box-host |
BOX_EGRESS_TUNNEL_BEARER |
required when tunnel on | WS Bearer. Prefer _FILE. Independent of BOX_TOKEN. |
BOX_EGRESS_TUNNEL_BEARER_FILE |
unset | Preferred file form (same story as BOX_TOKEN_FILE) |
BOX_EGRESS_RELAY_HOSTS |
empty = all | Optional CONNECT host allowlist (*.example.com ok) |
BOX_MAX_CONCURRENT_EXECS |
8 |
Max simultaneous POST /v1/exec (and stream/detach). Extra calls get 429 busy. |
BOX_MAX_CONCURRENT_EXECS |
8 |
Max simultaneous POST /v1/exec (and stream/detach). Extra calls get 429 busy. |
BOX_MAX_DIR_ENTRIES |
4096 |
Cap on directory listings (truncated: true if hit) |
BOX_EXEC_KILL_GRACE_MS |
2000 |
After exec timeout, wait this long after SIGTERM before SIGKILL |
Send Authorization: Bearer <token>. GET /v1/health is public and returns only {"status":"ok"}. GET /v1/ready requires Bearer (Compose healthcheck sends it). A token must be set — as BOX_TOKEN or as BOX_TOKEN_FILE; dev-box-token and other short/well-known values are rejected unless BOX_ALLOW_INSECURE_DEV=1 and both daemon binds are loopback.
Pass each secret as a file, not as a value: BOX_TOKEN_FILE, BOX_HOST_TOKEN_FILE, BOX_VNC_PASSWORD_FILE, BOX_EGRESS_TUNNEL_BEARER_FILE. The file form wins when both are set. Compose does this for you — scripts/write-secrets.sh writes ./secrets/* and Compose mounts them read-only at /run/secrets/.
A secret handed to the container as an environment value is copied into pid 1's environment block at execve. /proc/1/environ serves that block to every process in the box, for the life of the box, and docker inspect shows it on the host. Nothing inside the box can undo that. In particular wipe_secret_environ() does not: unsetenv rewrites the environ pointer array but leaves the original block on the stack, and that block is what /proc reads. What it does do is keep the value out of getenv for the rest of the process, which is worth having but is not the same claim.
What the file form buys, precisely:
grep -a BOX_TOKEN= /proc/*/environfinds nothing — not in pid 1, not in either daemon, not in Chromium.docker inspectdoes not show the token inConfig.Env.- The daemons unlink the staged copies the entrypoint writes for them, so those exist for milliseconds.
What it does not buy: the file the operator mounts stays readable by uid 1000 (the Compose healthcheck needs it to authenticate). Code already executing as the box user — a compromised Chromium renderer, or any binary the agent downloaded and ran — can read /run/secrets/box_token and recover both secrets. Treat any code execution in the box as full compromise of BOX_TOKEN and BOX_VNC_PASSWORD. Rotate on suspicion; do not reuse a box token anywhere else.
Exec children never receive any of these variables, in either form. Do not put BOX_TOKEN in L1.
crates/box-common path jail, bearer compare, error envelope, config, CORS
crates/box-exec exec + files + CUA HTTP daemon
crates/box-host identity / ready / capabilities / desktop + chrome status
crates/box-desktop Xvfb probe, 1280×800 geometry, viewer URL
crates/box-chrome Chromium profile + localhost CDP probe
crates/box-cua screenshot / click / type / key / scroll / double-click / drag / move / recipe
crates/box-egress-tunnel guest WS mux + Chromium CONNECT proxy (laptop client is the same binary)
crates/grok-box typed client + CLI (workspace only, not published)
sdk/typescript TypeScript client (workspace only)
sdk/python Python client (workspace only)
ensurebox/ demo orchestrator (not production)
l1/ demo human UI (EnsureBox only)
| Mount | Role |
|---|---|
/workspace |
Jail root for cwd and file APIs. Persist across hibernate. |
/home/box/chrome-profile |
Chromium --user-data-dir. Persist cookies/session. Must be writable by uid 1000; otherwise the entrypoint falls back to /tmp/box-chrome-profile. |
- Deploy — test-deploy on Akamai Cloud (Linode; not Vultr), then the same pattern on AWS/GCP/Azure. First test is one VM, guest Compose only, private SSH tunnel or Tailscale; CLI/SDK from your laptop. Do not expose 1337/1340/6080 on the public internet.
- Egress — laptop CONNECT tunnel for Chromium in prod (no host-network)
- Architecture
- Startup
- Request processing
- Terminology
- HTTP API · OpenAPI
- CUA recipes — one request, many pointer steps (vs reverse-web-mcp)
- Not a supported production control plane (EnsureBox is a demo)
- Not L4 / not an OpenAI-compatible inference gateway
- Not a re-host of any proprietary exec/sand-host / sand-egress-tunnel binary
- Not a native Windows or macOS box OS
- Not a vendored trycua/cua tree — Linux X11 is the CUA backend
- Not published to crates.io, npm, or PyPI