A ready-to-use Docker image that turns OpenCode v2 into a hosted, SSO-authenticated workstation.
One container gives you:
| What | Where | Notes |
|---|---|---|
| OpenCode v2 web UI + API | <PUBLIC_URL>/ |
opencode2 serve; optional — see below |
| OpenCode v2 TUI in the browser | <PUBLIC_URL>/~env/terminal |
the real TUI, over ttyd |
| XFCE desktop in the browser | <PUBLIC_URL>/~env/desktop |
noVNC over x11vnc |
| agent-browser dashboard | <DASHBOARD_PUBLIC_URL> |
own port; live browser viewports |
| pitchfork dashboard | <PUBLIC_URL>/pitchfork |
start/stop/logs for your own daemons |
| an index of all of it | <PUBLIC_URL>/~env/ |
everything the environment serves |
| file manager | <PUBLIC_URL>/~env/files |
browse, upload, download the workspace |
| SSH + mosh | port 22, UDP 60000-60010 |
key-based by default |
| Google or GitHub sign-in in front of all of it | <PUBLIC_URL>/oauth2/* |
oauth2-proxy behind Caddy |
| mise | /opt/mise |
manages node and anything else you add |
| a usable shell | — | git, ripgrep, fd, jq, fastfetch, btop, ncdu, a compiler |
| pitchfork | PID 1, plus one per user | supervises it all; the OpenCode server is yours, not root's |
| agent-browser + Chromium | on the virtual display | headed, so you can watch it over noVNC |
| rootless Docker (opt-in) | the dev user's own daemon | docker + docker compose for databases and the like; needs host flags |
Everything is configured with environment variables. Nothing needs to be baked into a custom image.
Published images are on GHCR, built for linux/amd64 and linux/arm64:
docker pull ghcr.io/dtinth/agent-env:latestcompose.example.yaml is a deployment example built on
that image — it declares its ports with expose for an ingress controller to
route, and documents the direct-publish and tailscale-serve variants.
deno run -A https://raw.githubusercontent.com/dtinth/agent-env/main/setup.tsIt asks about eight things and writes compose.yaml and .env for you. The
first question decides the rest:
| Where it runs | What you get |
|---|---|
| This machine only | Ports bound to 127.0.0.1. Put your own proxy in front if you want it reachable. |
| A public domain | A Caddy sidecar that gets a certificate from Let's Encrypt. |
| A tailnet | A Tailscale sidecar. The tailnet is the boundary, and SSH and mosh work with nothing published. |
That choice sets PUBLIC_URL, the port layout, which sidecars exist, and the
sensible default for authentication — which is most of what there is to get
wrong. It also does the things that are tedious by hand and easy to forget:
- Generates and persists
OAUTH2_PROXY_COOKIE_SECRET, so sessions survive a restart instead of silently rotating. - Refuses to write a public-domain deployment with
AUTH_MODE=none, or an OAuth one with no allow list. Everything in this container is root-capable. - Emits all four rootless-Docker host flags together, or none — three out of four leaves the daemon down.
- Reads
PUID/PGIDoff a host directory you mount, so files stay yours. - Publishes
:80alongside:8443in domain mode, because ACME only ever validates on 80 or 443 and never on the port you serve from.
Re-running it is the point. It reads back what it wrote, offers those as the
defaults, and keeps the secrets already in .env — so it upgrades a deployment
as well as creating one.
deno run -A .../setup.ts # again, later: answers pre-filled, secrets kept.env.example stays the reference for the fifty-odd keys it does not ask about;
a test asserts the wizard never emits one that is missing from it.
docker build -t agent-env . # or use ghcr.io/dtinth/agent-env:latest
docker run -d --name agent-env --shm-size=2g \
-p 8080:8080 -p 8081:8081 -p 2222:22 \
-e AUTH_MODE=basic \
-e GATEWAY_PASSWORD=changeme \
-e PUBLIC_URL=http://localhost:8080 \
-e SSH_AUTHORIZED_KEYS="$(cat ~/.ssh/id_ed25519.pub)" \
-e ANTHROPIC_API_KEY=sk-ant-... \
-v agent-env-workspace:/workspace \
agent-envOpen http://localhost:8080 and sign in as opencode / changeme.
Then http://localhost:8080/~env/ for an index of everything the environment
serves — the TUI, the desktop, the files, the daemons.
- In Google Cloud Console → Credentials, create an OAuth 2.0 Client ID of type Web application.
- Set its Authorised redirect URI to exactly
<PUBLIC_URL>/oauth2/callback, e.g.https://oc.example.com/oauth2/callback. - Copy
.env.exampleto.env, fill in the client ID/secret,PUBLIC_URL, and who is allowed in. docker compose up -d
cp .env.example .env
$EDITOR .env
docker compose up -d
docker compose logs -fPUBLIC_URL must be the URL users actually type, and it must match the
registered redirect URI. Terminate TLS in front of the container (a load
balancer, Cloudflare, Caddy/nginx on the host) and point it at port 8080.
AUTH_MODE picks the gate on the way in:
google(default) — oauth2-proxy handles the sign-in; Caddy checks every request against it withforward_auth. RequiresGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, and at least one ofALLOWED_EMAILS/ALLOWED_EMAIL_DOMAINS. The container refuses to start without an allow list, because otherwise any Google account on the internet could sign in.github— the same gate with GitHub as the provider. RequiresGITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET, and an allow list — see below.basic— HTTP basic auth (GATEWAY_USER/GATEWAY_PASSWORD). Good for local runs. A password is generated and printed to the log if you omit it.none— no gate at all. Only sane behind your own authenticating proxy.
Optionally restrict google further by Google Workspace group with
GOOGLE_GROUPS (needs GOOGLE_ADMIN_EMAIL and a delegated service-account key).
ALLOWED_EMAIL_DOMAINS=* is accepted and means what it says: anyone with a
Google account. It is deliberately allowed rather than blocked — the check
exists to stop you forgetting an allow list, not to overrule one you wrote on
purpose.
Register an OAuth app at
github.com/settings/developers with
the Authorization callback URL set to exactly <PUBLIC_URL>/oauth2/callback,
then:
AUTH_MODE=github
GITHUB_CLIENT_ID=Iv1.....
GITHUB_CLIENT_SECRET=... # or GITHUB_CLIENT_SECRET_FILE=/run/secrets/...
GITHUB_USERS=octocat,hubot # and/or GITHUB_ORG / GITHUB_TEAMThe allow list is whichever of these you set, and at least one is required:
| Variable | Means |
|---|---|
GITHUB_USERS |
these logins, comma-separated, regardless of org or team |
GITHUB_ORG |
members of this organisation |
GITHUB_TEAM |
these team slugs within GITHUB_ORG; without it, spell each one org:team — an unqualified name there is refused at startup, because oauth2-proxy would reject every login instead |
ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS |
the account's primary verified address |
The consent screen asks for user:email read:org whatever the allow list says.
That is not over-asking: oauth2-proxy reads /user/orgs and /user/teams on
every sign-in, before it looks at any restriction and whether or not an org is
configured, and both need read:org. Narrowing the scope breaks the callback
even for a deployment restricted only by username.
Whichever check lets someone in, oauth2-proxy still validates their email — so
when the allow list is written against accounts rather than addresses the
gateway passes --email-domain=*, and the account restrictions are the whole
boundary. Set ALLOWED_EMAILS or ALLOWED_EMAIL_DOMAINS as well to narrow it
on both axes.
An allow list is counted after the separators and whitespace come out of it, so
GITHUB_USERS=, is refused rather than treated as a restriction that happens to
match nobody.
A GitHub OAuth app belongs to one account or organisation, and organisations can require approval before it may read their membership — so if sign-in works but everyone is rejected, check the app is approved in the org's Third-party access settings.
/~env/healthz is always reachable without auth, so load balancers can probe it.
Everything this image serves for itself lives under /~env/. Nothing else
does, so whatever answers at / owns its entire path space:
| Path | What |
|---|---|
/~env/ |
index of what this environment is running |
/~env/healthz |
health probe, never behind auth |
/~env/terminal |
the OpenCode TUI, over ttyd |
/~env/desktop |
XFCE over noVNC |
/~env/files |
the workspace file manager |
/~env/daemons |
redirects to /pitchfork (see below) |
An unknown path under the prefix is answered with a 404 by the gateway rather than being passed through, so a request addressed to the environment never reaches your application by accident.
Two things sit outside the prefix on purpose:
/oauth2/*— oauth2-proxy's own endpoints. This is where the redirect URI you registered with the provider already points, and moving it would invalidate every existing OAuth client for no gain./pitchfork— pitchfork validatesPITCHFORK_WEB_PATHas a single[A-Za-z0-9_-]segment and bakes it into a<base href>, so its dashboard cannot be nested. It keeps a top-level path, and/~env/daemonsredirects there so the prefix is still a complete index. Its logo is a hard-coded absolute/img/logo.pngin its JS bundle; the gateway serves that path from pitchfork only when theReferersays the request came from the dashboard, so it cannot shadow the same path in your own app. If a referrer policy strips the path, the dashboard loses its logo and nothing else.
~ is in RFC 3986's unreserved set, so it needs no encoding, and no framework
generates it — which is the whole reason for choosing it. The prefix is fixed
rather than configurable: it is a documented contract, and a knob would only
make this table wrong.
OpenCode is the default occupant of /, not a requirement of the image. Set
OPENCODE_ENABLE=false and / proxies to PRIMARY_PORT (default 3000)
instead — whatever the workspace is running:
OPENCODE_ENABLE=false
PRIMARY_PORT=3000
With nothing listening there yet, / serves the /~env/ index instead of a
bare 502, so an empty workstation explains itself. The status code stays 502 —
the page renders, but nothing is pretending there is an application when there
isn't, so an ingress health check pointed at / still reports the truth. Start
your dev server and it takes over / and everything below it immediately; no
gateway restart, and the reserved prefix is untouched.
Caddy falls back only for errors it generates — a refused connection. An app
that is up and answering its own 502 shows its own error, which is what you want
while debugging it. A genuinely broken /~env/ service is likewise never
papered over.
Two other things follow OPENCODE_ENABLE:
- The browser terminal becomes a login shell.
/~env/terminalexists to run the OpenCode TUI; with no server to attach to it would sit on a connect loop, so it gives you a shell instead. - No credential is injected at
/. The gateway normally adds the OpenCode server's own basic-auth header on the way through. That credential exists to reach OpenCode and nothing else — it is never sent to a primary service that isn't OpenCode, and the smoke suite asserts the injection is absent.
The container's HEALTHCHECK follows the same setting: it always probes the
gateway, and probes the OpenCode port only when OpenCode is supposed to be
there. It deliberately does not probe PRIMARY_PORT — nothing is required to
be listening on it.
opencode2 serve --service registers the server as OpenCode's shared service, so
the opencode2 CLI's default discovery finds it instead of spawning a second
one. The service has its own HTTP basic auth (user opencode) with a password it
generates itself and records in ~/.local/state/opencode/service.json; the
gateway reads it from there and injects it on the way through, so users
authenticate once at the gateway and never see it. Inside a login shell the same
value is exported as OPENCODE_SERVER_PASSWORD, and agent-env password prints
it. Setting OPENCODE_SERVER_PASSWORD yourself has no effect.
Only ports 8080, 8081 and 22 listen on external interfaces. The OpenCode server, VNC, websockify, ttyd, the dashboard and oauth2-proxy are all bound to loopback.
The image ships no host keys — Debian's openssh-server generates them at
package-install time, so leaving them in would give every deployment, and
anyone who pulled the image, the same private key. The entrypoint generates a
set on first start instead, under /var/lib/agent-env/ssh.
Mount a volume there. Otherwise the keys live in the container's writable
layer: fine across restart, gone the moment you recreate the container to pick
up a new image, and every client then greets you with a changed-host-key
warning. The entrypoint says so loudly if that directory is not a mount.
volumes:
- agent-env-state:/var/lib/agent-envWith that volume the fingerprint survives both a restart and an image update;
agent-env logs which it did (generated/reusing) and prints the
fingerprint at every boot.
pitchfork is PID 1 and therefore root — it has to be, to reap zombies, run sshd and drop privileges for everything else. It drops them aggressively:
| Runs as | Processes |
|---|---|
root |
pitchfork (PID 1), sshd, the log forwarder, and the wrapper that starts the system D-Bus (dbus-daemon itself drops to messagebus) |
gateway (system account, no shell, no sudo) |
Caddy and oauth2-proxy |
dev (uid 1000, passwordless sudo) |
everything you actually work in — the OpenCode server, Xvfb, the whole XFCE desktop, x11vnc, websockify, ttyd, agent-browser and Chromium, plus your own pitchfork supervisor |
So the desktop and everything in it runs as dev, never root — open a
terminal on the noVNC desktop and you are dev, same as over SSH.
The two network-facing proxies get their own throwaway account precisely
because dev has passwordless sudo: a hole in the edge shouldn't hand over
root. Caddy carries cap_net_bind_service, so it can still bind a low
GATEWAY_PORT without being root.
The virtual display is protected by an MIT-MAGIC-COOKIE in
/home/dev/.Xauthority. Without it, every local account — including gateway —
could attach to the desktop and inject keystrokes into whatever dev has open,
which would have made that account's lack of sudo worth very little. dev owns
the cookie, so GUI applications started over SSH still land on the display with
nothing to configure.
Credentials for the gateway (GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_SECRET,
GATEWAY_PASSWORD, the cookie secret) are unset before the supervisor is
started. oauth2-proxy reads them from root-owned files instead, so they are
absent from the environment of the OpenCode server — the process that runs
whatever the agent was asked to run. Note that a secret passed with -e still
sits in the container's config, where docker inspect and docker exec can
see it; the _FILE form avoids that entirely.
dev has passwordless sudo, so you can install things inside the container
freely — the image prunes apt's package lists, so run sudo apt-get update
first:
sudo apt-get update && sudo apt-get install -y tmux
mise use -g python@3.13 # no sudo needed; mise is owned by devThat also means anyone who gets past the gateway has root in the container — which is why the allow list is mandatory and why each person should get their own container.
See .env.example for the annotated list. The essentials:
| Variable | Default | Purpose |
|---|---|---|
PUBLIC_URL |
http://localhost:8080 |
External base URL; drives OAuth redirects |
AUTH_MODE |
google |
google / github / basic / none |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
— | Required for google |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
— | Required for github |
GITHUB_USERS / GITHUB_ORG / GITHUB_TEAM |
— | The github allow list; at least one, or an ALLOWED_EMAIL* |
ALLOWED_EMAILS, ALLOWED_EMAIL_DOMAINS |
— | Who may sign in |
OAUTH2_PROXY_COOKIE_SECRET |
generated | Set it to survive restarts cleanly |
OPENCODE_ENABLE |
true |
Whether OpenCode occupies / at all |
OPENCODE_WORKDIR |
/workspace |
Where the server and TUI start |
PRIMARY_PORT |
3000 |
What / proxies to when OpenCode is off |
SSH_AUTHORIZED_KEYS |
— | Newline- or ;-separated public keys |
SSH_PASSWORD |
— | Enables password auth (prefer keys) |
DESKTOP_ENABLE |
true |
XFCE + noVNC |
DESKTOP_RESOLUTION |
1920x1080x24 |
Virtual display geometry, WxH or WxHxD |
TTYD_ENABLE |
true |
Browser TUI at /~env/terminal |
TTYD_COMMAND |
tui |
shell for a plain login shell instead of the TUI |
AB_DASHBOARD_ENABLE |
true |
agent-browser dashboard on its own port |
MISE_TOOLS |
— | Extra global tools, e.g. python@3.13 go@latest |
USER_SUPERVISOR_ENABLE |
true |
Run the dev user's own pitchfork at boot |
USER_WEB_ENABLE, USER_WEB_PATH |
true, pitchfork |
pitchfork's web dashboard |
DUFS_ENABLE, DUFS_PATH, DUFS_ROOT |
true, ~env/files, /workspace |
the file manager |
AGENT_ENV_STATE_DIR |
/var/lib/agent-env |
Where the SSH host keys are kept |
X_TCP_ENABLE |
false |
Let the display accept TCP (still cookie-gated) |
TZ, PUID, PGID |
UTC, 1000, 1000 |
Timezone and uid/gid remapping |
Every variable also accepts a <NAME>_FILE form pointing at a file, for Docker
or Kubernetes secrets:
environment:
GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secretPick whichever suits your deployment:
-e GOOGLE_CLIENT_SECRET=GOCSPX-... # plain environment variable
-e GOOGLE_CLIENT_SECRET_FILE=/run/secrets/gcs # read from a file at startup
-e OAUTH2_PROXY_CLIENT_SECRET=GOCSPX-... # oauth2-proxy's own variableWith AUTH_MODE=github the first two are spelled GITHUB_CLIENT_SECRET and
GITHUB_CLIENT_SECRET_FILE; the third is the same variable either way.
All three end up the same way: the entrypoint writes the value to a root-owned
file and hands oauth2-proxy --client-secret-file, so it appears neither in the
process list nor in the environment of the daemons that run your code.
OAUTH2_PROXY_COOKIE_SECRET is treated identically, and any other
OAUTH2_PROXY_* variable is passed through to the process untouched.
The cookie secret has to decode to 16, 24 or 32 bytes. A wrong length is
rejected at startup, by name, rather than left to oauth2-proxy's rather less
helpful missing setting: cookie-secret:
head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '='scripts/auth-matrix.sh boots the image once per combination of these inputs
and checks oauth2-proxy actually starts.
Either pass API keys as environment variables (ANTHROPIC_API_KEY,
OPENAI_API_KEY, …), or sign in interactively with /connect in the TUI — that
is persisted in the opencode-state volume and survives restarts.
You can also drop a config in without rebuilding:
-e OPENCODE_CONFIG_CONTENT='{"model":"anthropic/claude-opus-5"}'
# or mount one at /home/dev/.config/opencode/opencode.jsondocker exec -it agent-env agent-env status # state of every service
docker exec -it agent-env agent-env urls # what this container serves
docker exec -it agent-env agent-env password # the OpenCode server password
docker exec -it agent-env agent-env logs caddy # follow one service
docker exec -it agent-env agent-env top # pitchfork's interactive dashboard
docker exec -it agent-env agent-env config # the rendered gateway config
docker exec -it agent-env agent-env daemons # the rendered daemon definitions
docker exec -it agent-env agent-env restart opencode
docker exec -it agent-env agent-env tui # attach the TUI from a shellpitchfork runs as PID 1 in its container mode, so
zombies are reaped and docker stop shuts every daemon down in order — a clean
exit takes a few seconds, well inside Docker's grace period.
Daemons declare what they need rather than guessing at timing:
[daemons.x11vnc]
run = "/opt/agent-env/bin/run-x11vnc"
depends = ["xvfb"] # start ordering, resolved topologically
ready_port = 5900 # "up" means the port answers
retry = trueagent-env is the way in for system services: it sets the system supervisor's
directories and sudos into it for you. A bare pitchfork resolves to the
invoking user's supervisor, which is the point — but it means
docker exec <container> pitchfork list looks at the dev user's supervisor and
fails on permissions. Use agent-env status there.
pitchfork captures each daemon's output into its own log store, which is what
agent-env logs reads. A small zz-log-forward daemon also streams everything to
PID 1's stdout, so docker logs -f agent-env and your log driver still see the lot,
prefixed with [global/<daemon>].
Run scripts/smoke-test.sh against a live container to check the whole thing:
./scripts/smoke-test.sh http://localhost:8080 opencode:changemeThe system supervisor is root's and stays that way. You get a second,
unprivileged pitchfork supervisor of your own — its own state, socket and
logs — so pitchfork as dev means your daemons and can't touch the
container's.
The OpenCode server runs there, not in the root supervisor. It is the thing
you are here to use rather than part of the plumbing, so you can restart it,
read its logs and watch its memory without sudo — from the terminal, or from
pitchfork's web UI at <PUBLIC_URL>/pitchfork:
pitchfork restart opencode # no sudo
pitchfork logs -f opencodeIts definition lives in a managed block at the end of
~/.config/pitchfork/config.toml, rewritten on every start so an existing
config volume picks up changes. Anything you put above that block is yours and
is left alone.
Add your own alongside it:
$ $EDITOR ~/.config/pitchfork/config.toml # above the managed block
[daemons.api]
run = "npm run dev"
dir = "/workspace/my-app"
ready_port = 3000
boot_start = true # comes up with the container
retry = true
$ pitchfork start api
$ pitchfork list
global/api running
global/opencode running
$ pitchfork logs -f api
$ pitchfork tui # the same dashboard, in the terminalSet USER_WEB_ENABLE=false to drop the web dashboard, or USER_WEB_PATH to
serve it somewhere other than /pitchfork. It has to stay a single path segment
of [A-Za-z0-9_-] — pitchfork rejects anything else, which is why this one
service sits outside /~env/. It binds loopback only; the gateway
is what exposes it, behind the same authentication as everything else. Note that
it can edit the config and stop daemons — no more privilege than the TUI already
gives, but worth knowing.
Project daemons are usually better off in a pitchfork.toml next to your code —
/workspace/pitchfork.toml is on a volume, so it survives a rebuild, and
pitchfork start -l starts everything in it.
agent-env status shows both tables at once. The agent-env commands that touch
system services sudo into root for you; agent-env mine lists just yours.
Set USER_SUPERVISOR_ENABLE=false if you don't want the user supervisor running
at boot — you can still start one on demand.
Because the two supervisors cannot see each other, nothing in the system set
depends on the OpenCode server any more. Caddy is a proxy and does not need
its upstream at startup; the browser terminal waits for the server before
starting the TUI; and the container's health check tests the OpenCode port as
well as the gateway, so "healthy" still means the server is actually up.
Two details worth knowing if you go poking at this:
/etc/pitchfork/config.tomlis deliberately left empty. pitchfork reads it as the system-wide config layer for every supervisor, and a root-only file there is fatal for an unprivileged one — so the system supervisor keeps its config in/opt/agent-env/pitchforkinstead./tmp/fslockis pre-created mode 1777. pitchfork locks there, and whichever supervisor started first would otherwise own the directory and shut everyone else out.
Over SSH, the toolchain is on PATH for interactive and non-interactive
sessions:
ssh -p 2222 dev@host
ssh -p 2222 dev@host 'opencode2 run "summarise this repo"'The image carries one locale, C.UTF-8, and PAM hands it to every session.
sshd deliberately does not accept LANG or LC_* from the client: a
forwarded en_US.UTF-8 is a locale that does not exist here, and every shell
started under it warns about setlocale before doing anything else. TERM and
COLORTERM still come across.
Off by default. Set DOCKER_ROOTLESS_ENABLE=true and the dev user gets their
own rootless Docker engine, supervised alongside their other daemons — so
an agent can bring up Postgres, Redis or anything else in a container without
being handed the host's docker socket:
docker compose up -d # the plugin is in the image
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:17
pitchfork logs -f dockerd # it is your daemon; no sudoDOCKER_HOST is set for you, images land on the home volume so they survive a
recreate, and root inside those containers is your unprivileged dev uid
outside — a container's own users map into dev's subuid range, which is what
lets stock images like postgres drop privileges normally.
The host has to relax this container's sandbox for it. A rootless daemon needs five things a stock container does not get, and all five are required:
| Flag | Why |
|---|---|
--cap-add SYS_ADMIN |
newuidmap is setuid-root, so its euid stops matching the owner of the user namespace it is mapping. That loses the kernel's "namespace owner holds all capabilities in it" shortcut and falls through to CAP_SYS_ADMIN, which docker drops. |
--security-opt seccomp=unconfined |
runc joins a session keyring per container and keyctl is not in the default profile. The daemon starts fine without this; the containers it runs fail with unable to join session keyring. |
--security-opt apparmor=unconfined |
On an AppArmor host the docker-default profile denies mount, and rootlesskit's first act is to remount / shared inside its own mount namespace. Without this the daemon dies with [rootlesskit:child] error: failed to share mount point: /: permission denied. Hosts without AppArmor (most non-Debian/Ubuntu ones) do not need it, and the entrypoint only asks for it where AppArmor is enforcing. |
--security-opt systempaths=unconfined |
dockerd-rootless.sh sets net.ipv4.ip_forward inside its own network namespace, and /proc/sys is read-only in a stock container. |
--device /dev/net/tun |
slirp4netns builds a tap device to give the daemon its network namespace. |
The entrypoint checks for all five at startup. If any is missing it names it, leaves the daemon down and carries on, rather than crash-looping something that cannot work.
docker run -d --name agent-env --shm-size=2g \
--cap-add SYS_ADMIN \
--security-opt seccomp=unconfined \
--security-opt apparmor=unconfined \
--security-opt systempaths=unconfined \
--device /dev/net/tun \
-e DOCKER_ROOTLESS_ENABLE=true \
...Weigh that against the isolation the rest of this image is built on: it is a
real widening of what code in the container can do, and this is a container
that runs code an agent was asked to run. It is still well short of
--privileged — no blanket device access, and no path from container-root to
host-root. Two limits worth knowing:
- No cgroup delegation, so
--memoryand--cpuson inner containers are ignored. Nothing stops a runaway container from taking the whole workstation's memory. - Nested storage. The engine keeps its own overlay tree on the home volume; a few large images will show up in that volume's size, not the image's.
If all you want is a database to develop against, installing it as a normal daemon under your own supervisor needs none of this and no host flags at all:
[daemons.postgres]
run = "postgres -D /workspace/.pgdata"
ready_port = 5432
boot_start = trueBetter than SSH over a flaky connection — it survives roaming and suspend. The client picks the UDP port, so pass a range the container publishes:
mosh -p 60000:60010 --ssh="ssh -p 2222" dev@hostThe image EXPOSEs 60000-60010/udp and compose publishes the same range
(HOST_MOSH_PORTS to change it). One port per concurrent session, so widen the
range if you want more than ten. agent-env urls prints the exact command.
| Path | Holds |
|---|---|
/workspace |
your code |
/home/dev |
everything else you accumulate — see below |
/var/lib/agent-env |
generated SSH host keys |
Three volumes, and you want all three. The home directory is the one that is
easy to under-mount and regret: it holds OpenCode sessions and provider logins,
your own daemon definitions, agent-browser profiles, git config, shell history,
SSH client config, and any tool the agent installs into ~/.local/bin,
~/.cargo, ~/.npm-global and so on. Without it, everything an agent set up for
itself is gone the moment you recreate the container on a new image. The
entrypoint warns if it or the state directory is not a mount.
A named volume is seeded from the image on first use, so the dotfiles the image ships arrive as normal. A bind mount arrives empty, so the entrypoint copies in anything missing without touching what is already there.
The mise toolchain lives in /opt/mise, deliberately outside the home volume:
node, opencode2 and agent-browser are the image's business, so pulling a new
image is what updates them. If they lived in the volume, the first version you
ever ran would be frozen there.
The image's own tools are declared in mise/config.toml and
pinned in mise/mise.lock, which records an exact version and
a SHA256 checksum per architecture. The build installs from the lockfile, so
it gets the same node every time and verifies it — rather than resolving whatever
24.x happens to be newest that day. To move it:
mise lock --global --bump --platform linux-x64,linux-arm64mise reads that file as its system config, so it never collides with what you
declare in ~/.config/mise on the home volume.
The consequence is that tool installs do not survive recreation — but the
declarations do, because mise use -g writes to ~/.config/mise/config.toml
on the home volume, and the entrypoint reinstalls from it at boot:
$ mise use -g jq@1.7 # declared in the home volume
$ jq --version # jq-1.7
# ...recreate the container on a new image...
$ jq --version # jq-1.7, reinstalled at bootMISE_TOOLS does the same thing declaratively from your compose file. Either
way, the first boot after a recreate spends time reinstalling, so heavyweight
toolchains are better put in the image with a FROM ghcr.io/dtinth/agent-env of
your own.
mise runs in shims mode, everywhere — there is no cd hook in any shell.
A shim resolves the tool version and applies the enclosing mise.toml's
[env] to the process it starts, so a project's environment reaches a daemon,
ssh host <command>, an IDE and anything the agent shells out to, none of which
ever display a prompt for a hook to attach to:
$ cat /workspace/api/mise.toml
[env]
DATABASE_URL = "postgres://localhost/api"
$ ssh box 'cd /workspace/api && node -e "console.log(process.env.DATABASE_URL)"'
postgres://localhost/api # no prompt, no hook, still setThree things follow from using shims:
- The vars are set for the process the shim starts, not for the shell around
it.
echo "$DATABASE_URL"shows nothing, and a tool that is not mise-managed (an apt-installedpsql, say) does not see them. Usemise x -- psql ...ormise runfor those, oreval "$(mise env)"to pull them into the shell. which nodereports the shim, not the tool.mise which nodegives the real path.- The shims reach
PATHbefore your dotfiles run, so a~/.bashrcthat assignsPATHrather than prepending to it drops them, andnodefalls back to Debian's. Nothing puts the entry back —PATHis yours. Prepend (PATH="$HOME/.local/bin:$PATH") and mise keeps working. - If a declared tool is not installed, a shim falls back to the next
same-named executable on
PATHrather than failing — so a missingpythoncan silently become the system one. Auto-install is on, which normally prevents that, and the entrypoint pre-installs from your declarations at boot.
A mise.toml in a repository can set environment variables and define tasks, so
mise does not load one until you trust it:
mise WARN /workspace/some-repo/mise.toml is not trusted, run `mise trust` to enable it
That is the right default here, where an agent clones code it has never seen —
and it is asserted by the smoke suite. In shims mode the gate matters more, not
less: an untrusted [env] would otherwise reach every tool run inside the repo,
not just a shell sitting in it. For the same reason MISE_YES is a build-time
ARG rather than an image ENV — left in the environment it auto-answers the
trust prompt, which would hand a freshly cloned repository exactly what the
prompt is there to withhold. mise trust accepts a config once you
have looked at it. Two further knobs if you want more distance from repository
content: MISE_SAFE=1 blocks template functions, hooks and scripts while still
resolving versions, and the paranoid setting requires re-trusting a config
whenever its contents change. Neither is on by default, because an agent that
runs a repository's build is already running its code.
Mounting host directories at /workspace or /home/dev? Set PUID/PGID to
match their owner.
Geometry comes from the environment, either as one value or as parts:
-e DESKTOP_RESOLUTION=2560x1440x24 # WxH or WxHxD (depth 8/15/16/24/30)
-e DESKTOP_RESOLUTION=1600x900 # depth defaults to 24
# or, equivalently
-e DESKTOP_WIDTH=1600 -e DESKTOP_HEIGHT=900 -e DESKTOP_DEPTH=24An unparseable value fails at startup with a message naming the expected form, rather than leaving you with a dead display.
Several people can watch and use the same desktop at once. x11vnc runs with
-shared -forever, and websockify forks a process per browser, so viewers join
the session rather than kicking each other off. scripts/smoke-test.sh asserts
this by opening four concurrent RFB connections.
Resolution is fixed for the life of the display: Xvfb has no dynamic RandR
resizing, so noVNC's client-side scaling is what adapts to your window. Change
DESKTOP_RESOLUTION and agent-env restart xvfb (then desktop, x11vnc) for a
different geometry. Set VNC_PASSWORD for a second factor in front of the
desktop specifically, and VNC_VIEW_ONLY=true for a read-only session.
dufs serves the workspace at
<PUBLIC_URL>/~env/files — browse it, drag files in, download a folder as an
archive, search, rename, delete. Useful when the thing you need to move is not
worth an scp invocation, and when you are working from a tablet or a machine
without your keys.
It is a daemon of your supervisor, not the system's: it runs as dev, writes
files as dev, shows up in /pitchfork beside the OpenCode server, and
pitchfork restart dufs needs no sudo. It binds loopback, so the gateway's
authentication is what stands in front of it.
Upload, delete, search and archive are on. --allow-symlink is deliberately
off, so a symlink inside the workspace cannot be used to browse the rest of the
filesystem — the smoke suite checks that. Add it back, or anything else dufs
takes, with DUFS_ARGS:
DUFS_ENABLE=false # turn it off
DUFS_ROOT=/srv/shared # serve something other than /workspace
DUFS_PATH=~env/files # the path it lives under
DUFS_ARGS=--allow-symlinkdufs is installed by mise from mise/config.toml, so it is pinned and
checksum-verified in the lockfile like everything else.
The desktop is a real X display, and you can point an application on another machine at it. Forward the display socket over SSH and copy its cookie across:
# 1. forward the container's X socket to a spare local display number
ssh -p 2222 -N -L /tmp/.X11-unix/X99:/tmp/.X11-unix/X1 dev@host &
# 2. copy the cookie over, registered against that local number
cookie=$(ssh -p 2222 dev@host 'agent-env x-cookie' | awk '{print $3}')
xauth add :99 MIT-MAGIC-COOKIE-1 "$cookie"
# 3. run something
DISPLAY=:99 xeyesIt then appears on /~env/desktop alongside everything else. Plain X clients work as
they are; GTK applications generally want a session bus, so run them under
dbus-run-session or just start them inside the container.
agent-env x-cookie is there so you do not have to go digging for the cookie.
If you would rather forward a TCP port than a socket, set X_TCP_ENABLE=true
and the display listens on 600<N> — still cookie-protected, but do not publish
that port.
Chromium is installed from Debian and wired up already, as user-level
defaults in ~/.agent-browser/config.json:
{
"headed": true,
"executablePath": "/usr/bin/chromium",
"args": "--no-sandbox,--disable-dev-shm-usage,--disable-gpu"
}So agent-browser open example.com is headed with no flags. It lives in the
config file rather than in AGENT_BROWSER_* environment variables on purpose —
agent-browser's precedence is
~/.agent-browser/config.json < ./agent-browser.json < AGENT_BROWSER_* < CLI flags
so env vars would sit above a project's own agent-browser.json and quietly
override it. As a config file it is a real default: override it per project, per
command, or for the whole container:
agent-browser --headed false snapshot # one command
echo '{"headed": false}' > ./agent-browser.json # one project
docker run -e AGENT_BROWSER_HEADED=false ... # whole containerBecause it runs headed on display :1, you can open /~env/desktop and watch the
browser work. The dashboard on port 8081 shows live viewports and the command
feed.
--no-sandbox is the default because Chromium's sandbox needs privileges most
container runtimes deny. If your runtime allows it, drop that flag and add a
Chromium seccomp profile instead.
Chromium needs a large /dev/shm: keep --shm-size=2g (compose already sets it).
- OpenCode v2 is beta. It installs as
opencode2from@opencode-ai/cli@beta; the image pins the tag, not a version. Set theOPENCODE_VERSIONbuild arg to pin an exact one, andOPENCODE_DISABLE_AUTOUPDATE=1is already set so the container never self-updates under you. - The dashboard needs its own port. It's a Next.js app that serves assets
from absolute paths, so it can't live under a path prefix like
/~env/desktopdoes. It gets port 8081 with the same authentication. SetDASHBOARD_PUBLIC_URLif your host port differs from the container's, orAB_DASHBOARD_ENABLE=falseto turn it off. - Image size is ~3 GB. Most of it is XFCE, Chromium, a compiler toolchain and
the ~150 MB OpenCode binary. Drop
DESKTOP_ENABLE-related packages from the Dockerfile if you don't need the desktop. - pitchfork's container mode is marked experimental by its own docs. It has behaved correctly here — PID 1, reaping, ordered graceful shutdown — but that is the one component whose stability is self-declared as not guaranteed. The daemon definitions are plain TOML rendered by the entrypoint, so swapping in another supervisor is a contained change if you ever need to.
- Single-tenant. You work as
dev, which has passwordless sudo — so anyone past the gate effectively has root in the container. Give each person their own container rather than sharing one. - Multi-arch. Builds for
linux/amd64andlinux/arm64; all fetched binaries resolve perTARGETARCH.
docker buildx build --platform linux/amd64,linux/arm64 -t your-registry/agent-env:latest --push .CI does this on native runners rather than under QEMU — .github/workflows/docker.yml
builds each architecture on its own runner (ubuntu-latest and ubuntu-24.04-arm),
pushes it to GHCR by digest and untagged, runs scripts/smoke-test.sh against
the pushed artifact, and only merges the digests into a tagged manifest list once
both pass. A failed test therefore can't leave a broken :latest behind. Pull
requests build and test without pushing, and a weekly run picks up new OpenCode
v2 beta builds.
┌─── container (pitchfork = PID 1) ──────────────┐
browser ──8080──►│ Caddy ──forward_auth──► oauth2-proxy ──► IdP │
│ │ │
│ ├─ / ──► opencode2 serve :4096 │
│ ├─ /pitchfork ──► pitchfork web :4747 │
│ ├─ /~env/files ──► dufs :5000 │
│ ├─ /~env/terminal ──► ttyd :7681 ──► TUI │
│ ├─ /~env/desktop ──► websockify :6080 │
│ │ └─ x11vnc :5900 ──► Xvfb :1 ──► XFCE
│ ├─ /~env/ ──► index │
│ └─ /~env/healthz │
│ │
browser ──8081──►│ Caddy (same auth) ──► agent-browser dash :4848 │
ssh ────22───►│ sshd ──► dev │
└────────────────────────────────────────────────┘
Xvfb :1 also backs headed Chromium, which is why agent-browser sessions show
up on the noVNC desktop.
Dockerfile image definition
docker-compose.yml ports, volumes, health check
.env.example every setting, annotated
rootfs/usr/local/bin/entrypoint.sh validates config, renders the Caddyfile and
the pitchfork daemons, then exec's PID 1
rootfs/usr/local/bin/agent-env operator helper
rootfs/opt/agent-env/bin/run-* one launcher per service, including the
dev user's own nested supervisor
scripts/smoke-test.sh end-to-end check of a running container
Services are enabled or omitted by the entrypoint when it renders
/etc/pitchfork/config.toml, so disabling one means it never starts rather than
starting and idling.
