Skip to content
dtinthPublic

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

agent-env

docker

A ready-to-use Docker image that turns OpenCode v2 into a hosted, SSO-authenticated workstation.

The agent-env desktop over noVNC: fastfetch, btop and a user-owned pitchfork daemon

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.


Quick start

Published images are on GHCR, built for linux/amd64 and linux/arm64:

docker pull ghcr.io/dtinth/agent-env:latest

compose.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.

The quickest way: let setup.ts write it

deno run -A https://raw.githubusercontent.com/dtinth/agent-env/main/setup.ts

It 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/PGID off a host directory you mount, so files stay yours.
  • Publishes :80 alongside :8443 in 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.

Locally, with basic auth (no OAuth app needed)

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-env

Open 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.

For real, with Google sign-in

  1. In Google Cloud Console → Credentials, create an OAuth 2.0 Client ID of type Web application.
  2. Set its Authorised redirect URI to exactly <PUBLIC_URL>/oauth2/callback, e.g. https://oc.example.com/oauth2/callback.
  3. Copy .env.example to .env, fill in the client ID/secret, PUBLIC_URL, and who is allowed in.
  4. docker compose up -d
cp .env.example .env
$EDITOR .env
docker compose up -d
docker compose logs -f

PUBLIC_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.


Authentication

AUTH_MODE picks the gate on the way in:

  • google (default) — oauth2-proxy handles the sign-in; Caddy checks every request against it with forward_auth. Requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and at least one of ALLOWED_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. Requires GITHUB_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.

GitHub sign-in

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_TEAM

The 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.

The reserved /~env/ prefix

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 validates PITCHFORK_WEB_PATH as 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/daemons redirects there so the prefix is still a complete index. Its logo is a hard-coded absolute /img/logo.png in its JS bundle; the gateway serves that path from pitchfork only when the Referer says 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.

What lives at /

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/terminal exists 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.

How the OpenCode server itself is protected

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.

SSH host keys

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-env

With 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.

Who runs as what

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 dev

That 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.


Configuration

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_secret

Three ways to give it the OAuth client secret

Pick 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 variable

With 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.

Provider credentials for OpenCode

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.json

Operating it

docker 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 shell

Supervision

pitchfork 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 = true

agent-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:changeme

Your own daemons — including the OpenCode server

The 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 opencode

Its 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 terminal

Set 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.toml is 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/pitchfork instead.
  • /tmp/fslock is 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.

Docker inside the container

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 sudo

DOCKER_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 --memory and --cpus on 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 = true

mosh

Better 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@host

The 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.

Volumes

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.

Toolchains

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-arm64

mise 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 boot

MISE_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.

Shims, and where they stop

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 set

Three 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-installed psql, say) does not see them. Use mise x -- psql ... or mise run for those, or eval "$(mise env)" to pull them into the shell.
  • which node reports the shim, not the tool. mise which node gives the real path.
  • The shims reach PATH before your dotfiles run, so a ~/.bashrc that assigns PATH rather than prepending to it drops them, and node falls back to Debian's. Nothing puts the entry back — PATH is 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 PATH rather than failing — so a missing python can silently become the system one. Auto-install is on, which normally prevents that, and the entrypoint pre-installs from your declarations at boot.

Untrusted repositories

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.

The desktop

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=24

An 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.

Files

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-symlink

dufs is installed by mise from mise/config.toml, so it is pinned and checksum-verified in the lockfile like everything else.

Running a GUI app from your own machine

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 xeyes

It 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.

agent-browser

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 container

Because 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).


Notes and limits

  • OpenCode v2 is beta. It installs as opencode2 from @opencode-ai/cli@beta; the image pins the tag, not a version. Set the OPENCODE_VERSION build arg to pin an exact one, and OPENCODE_DISABLE_AUTOUPDATE=1 is 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/desktop does. It gets port 8081 with the same authentication. Set DASHBOARD_PUBLIC_URL if your host port differs from the container's, or AB_DASHBOARD_ENABLE=false to 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/amd64 and linux/arm64; all fetched binaries resolve per TARGETARCH.

Building for both architectures

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.


How it fits together

                    ┌─── 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.

Layout

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.

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages