An Ubuntu-based, persistent desktop sandbox for coding agents. A root-owned service exposes REST and MCP execution interfaces, while every requested command and code snippet runs as a runtime-created, unprivileged user.
- Ubuntu 24.04 base image with common shell, network, archive, Git, and text tools.
- Node.js LTS and the newest stable Python installed from apt repositories.
- Globally available uv, plus user-writable npm and uv tool/Python directories.
- Optional
@playwright/clibacked by CloakBrowser's Chromium, available throughexecwhen browser support is enabled. - Noto CJK fonts for correctly rendering Chinese, Japanese, and Korean text.
- Optional xpra/Xvfb desktop with an HTML client on port 14500.
- REST endpoints for foreground and background shell/JavaScript/Python tasks.
- MCP Streamable HTTP tools for executing, inspecting, listing, and killing tasks.
- A persistent, runtime-selected home directory.
Node.js and Python are installed into the image through NodeSource and
deadsnakes apt repositories. uv is installed globally as a standalone binary.
Playwright determines and installs Chromium's Ubuntu system dependencies during
the image build, so the repository does not maintain a duplicate browser
dependency list. @playwright/cli, CloakBrowser, and its Chromium binary are
downloaded into the persisted user home during first browser initialization
when SANDBOX_BROWSER_DISABLED=false, which is the default.
cp .env.example .env
docker compose pull
docker compose up -d
docker compose logs -f sandboxThe provided Compose file uses ghcr.io/devld/agent-sandbox:latest. When
building a custom image from this repository, the following Docker build
arguments are available:
| Variable | Default | Description |
|---|---|---|
UBUNTU_VERSION |
24.04 |
Ubuntu base image version. |
NODEJS_MAJOR |
24 |
Node.js major version installed from NodeSource. |
PYTHON_VERSION |
3.14 |
Python version installed from deadsnakes. |
These variables configure the Compose deployment, container initialization, or running services.
| Variable | Default | Scope | Description |
|---|---|---|---|
SANDBOX_USERNAME |
agent |
Entrypoint, API, Compose | Unprivileged runtime username and persistent-home mount target. Changing it for an existing volume is discouraged. |
SANDBOX_UID |
1000 |
Entrypoint, API | UID used for the sandbox account and child processes. Must be a positive integer. |
SANDBOX_GID |
1000 |
Entrypoint, API | GID used for the sandbox account and child processes. Must be a positive integer. |
SANDBOX_CHOWN_HOME |
true |
Entrypoint | Recursively assigns the persistent home to the configured UID/GID during first home initialization. |
SANDBOX_API_HOST |
0.0.0.0 |
API | Address on which the control-plane HTTP server listens. |
SANDBOX_API_PORT |
3000 |
API, Compose | API listen port when passed to the container. In the provided Compose file it selects the host port published to container port 3000. |
SANDBOX_API_TOKEN |
Empty | API | Bearer token required by execution and MCP routes. Empty disables authentication. |
SANDBOX_BROWSER_DISABLED |
false |
Entrypoint, MCP | When true, skips browser tooling initialization and omits the built-in Playwright guidance. Existing browser files remain unchanged. |
EXEC_INLINE_OUTPUT_LIMIT_BYTES |
65536 |
API | Per-stream threshold after which complete stdout or stderr moves to an output file. |
EXEC_OUTPUT_DIR |
/tmp/agent-sandbox/tasks |
API | Root directory for task output files and completion markers. |
EXEC_OUTPUT_TTL |
12h |
API | Retention duration for completed task output files. Uses Go duration syntax. |
EXEC_CLEANUP_INTERVAL |
10m |
API | Interval between expired-output cleanup passes. Uses Go duration syntax. |
XPRA_AUTO_START |
false |
Entrypoint | Starts the xpra/Xvfb desktop before the API when set to true. |
XPRA_PORT |
14500 |
xpra, Compose | xpra HTML server port when passed to the container. In the provided Compose file it selects the host port published to container port 14500. |
XPRA_LOG_FILE |
$XDG_RUNTIME_DIR/xpra-$DISPLAY.log |
xpra | Overrides the xpra server log path. |
XPRA_READY_TIMEOUT |
30 |
xpra | Seconds to wait for the X11 socket and live xpra session. |
DISPLAY |
:100 |
Entrypoint, xpra, child processes | X11 display used by headed browser commands and the optional desktop. |
TZ |
UTC |
Container, child processes | Container timezone passed through to executions. |
The Compose volume is mounted at /home/$SANDBOX_USERNAME. Changing the username
after creating the volume is discouraged: use a separate Compose project or
volume for each runtime identity. The home mount must allow execution because
npm and uv install user-owned tools and runtimes there; do not mount it with
noexec.
The image defaults to Node.js 24 LTS and Python 3.14, with the latest patch
release supplied by their apt repositories. Set the NODEJS_MAJOR or
PYTHON_VERSION Docker build arguments to choose another available release.
Changing either requires building a custom image.
The sandbox user's npm prefix is $HOME/.local, and $HOME/.local/bin is on
PATH. This makes npm install -g and npx usable without root. uv itself is
global, while uv tool install, uv python install, and uvx store mutable
state under the user's home and expose installed commands through the same
user-writable bin directory.
Set SANDBOX_BROWSER_DISABLED=true to skip installing Playwright CLI,
CloakBrowser, its Chromium binary, and the Playwright configuration into the
persistent home. The switch does not uninstall or modify an existing browser
installation. Enabling it later installs any missing browser tooling. It also
controls whether the built-in MCP server instructions advertise
playwright-cli; it does not control xpra or the browser system libraries
already included in the image.
XPRA_AUTO_START=true starts xpra before the API. DISPLAY is exported to all
agent executions and written to /etc/profile.d/agent-sandbox-display.sh.
start-xpra is idempotent and waits for both the X11 socket and a live xpra
session, avoiding browser startup races.
Open http://localhost:14500/ to access the xpra HTML client. Agent commands can
use the browser directly:
playwright-cli open https://example.com --headed
playwright-cli snapshot
playwright-cli screenshotThe initialized home points Playwright CLI's browser.launchOptions.executablePath
at CloakBrowser's downloaded Chromium instead of Playwright's bundled browser.
Run install-or-update-cloakbrowser as the sandbox user at any time to update
the browser binary and refresh that path as one operation. Running a raw
npx cloakbrowser update command instead does not rewrite the Playwright CLI
config. The CloakBrowser npm wrapper and its Playwright runtime are resolved at
their latest stable tags in an ephemeral npx environment; no global
cloakbrowser command is installed, although npm retains its normal download
cache in the persistent home. Playwright CLI is installed globally at its
latest stable tag. These versions and CloakBrowser licensing cannot be
controlled through environment variables. Chromium's own sandbox is disabled
because the browser already runs as the unprivileged sandbox user inside the
container; the Docker runtime still needs to be configured as the outer
security boundary.
Authentication is disabled when SANDBOX_API_TOKEN is empty, which is the
default. When it is set, all execution routes require
Authorization: Bearer $SANDBOX_API_TOKEN. /healthz is always
unauthenticated.
curl -sS http://localhost:3000/exec \
-H "Content-Type: application/json" \
-d '{"script":"id && printf \"%s\\n\" \"$DISPLAY\""}'
curl -sS http://localhost:3000/exec \
-H "Content-Type: application/json" \
-d '{"mode":"python","script":"print(sum(range(10)))","foregroundWaitMs":0}'mode is shell (the default), javascript, or python. cwd must be an
absolute path and defaults to the sandbox home. foregroundWaitMs defaults to
120000; zero returns a task ID immediately. Reaching that foreground wait does
not stop the task.
Every task is assigned a t<number> ID and enters the in-memory task table.
Tasks that finish during their foreground wait return their result directly and
are removed. Background tasks can be managed with:
curl -sS http://localhost:3000/exec/tasks
curl -sS http://localhost:3000/exec/tasks/t123
curl -sS -X DELETE http://localhost:3000/exec/tasks/t123The list endpoint accepts ?status=running or ?status=completed. Killing a
task sends SIGTERM to its process group and SIGKILL one second later if needed.
There is intentionally no execution-time, concurrency, or output-file size
limit.
stdout and stderr are tracked independently and returned live by the status
endpoint. Each remains inline until EXEC_INLINE_OUTPUT_LIMIT_BYTES (64 KiB by
default), then its complete contents move to a sandbox-user-readable file below
/tmp/agent-sandbox/tasks/<task-id>/. Completed output files expire after
EXEC_OUTPUT_TTL (12 hours by default), with cleanup performed every
EXEC_CLEANUP_INTERVAL (10 minutes by default).
The first status query that observes a completed background task starts a 10-minute in-memory retention period. Repeated queries work during that period; removing the task record does not remove its output files. Environment passed to children is constructed from a small allowlist, so the root service's API token is not exposed.
Use http://localhost:3000/mcp as a Streamable HTTP MCP endpoint and send the
same bearer token as an Authorization header when SANDBOX_API_TOKEN is set.
It publishes:
exec: run shell, JavaScript, or Python.exec_status: inspect status and live stdout/stderr.exec_list: list retained tasks without consuming completed results.exec_kill: terminate a task process group.
The transport is stateless, which works well for these request/response tools.
The MCP initialize response always starts with built-in server instructions.
When SANDBOX_BROWSER_DISABLED=false, those defaults include guidance that
browser automation is available through playwright-cli via exec; the
browser guidance is omitted when the switch is false. To append custom
instructions, mount a UTF-8 text file at the fixed path
/etc/agent-sandbox/mcp-instructions.md. The path is not configurable. If the
file is absent or empty, only the applicable built-in instructions are returned.
For example, a Compose override can inject a repository-managed file:
services:
sandbox:
volumes:
- ./mcp-instructions.md:/etc/agent-sandbox/mcp-instructions.md:roAt container startup, the entrypoint validates SANDBOX_USERNAME,
SANDBOX_UID, and SANDBOX_GID, creates the account, and initializes its home.
The $HOME/.config/agent-sandbox/home-initialized marker records base home
initialization, while
$HOME/.config/agent-sandbox/browser-initialized is written only after
Playwright CLI, CloakBrowser, its Chromium binary, and the Playwright
configuration are ready. This split allows a home first started with browser
support disabled to install it later, without changing an existing installation
when the switch is turned off. Independently of these markers, each startup
checks .bashrc and .bash_profile and appends the user-local PATH, npm/uv
directory configuration, and login-shell sourcing lines only when missing.
Existing shell configuration is preserved and the injection is not duplicated.
The image runs tini as PID 1, so Compose does not need init: true. The
standalone API/MCP control-plane process runs as its root-owned child and does
not depend on Node.js or Python. Each execution is launched through setpriv
with the configured UID, GID, supplementary groups, home, and runtime
directory. Because an empty API token allows unauthenticated command execution,
publish the API only on an isolated trusted network or set a strong token. Do
not mount the Docker socket, host credentials, or other privileged devices into
this container; root inside a container is not a substitute for careful mount
and network policy.