Dev containers for AI harnesses
A dev container is a description of a throwaway development environment — a base image plus a list of features — that your editor builds and drops you into. Your project source is bind-mounted in; the toolchain, the AI harness, and everything else live in the container and can be rebuilt from scratch at any time.
For AI-assisted development this is exactly the shape you want:
- The harness runs in the container, not on your laptop. Claude Code, Antigravity, or whatever agent you use only ever sees the container’s filesystem and the container’s tools.
- Compile and test happen in the container too, so the agent’s edits are validated against the same toolchain your editor uses — no “works on my machine” gap between the human and the AI.
- Credentials stay on the host. With the
wraptoolfeature, the agent reaches git, gcloud, and kubectl through wraptool over MCP — it never holds a token or an SSH key.
This page is a short tour of how to assemble such a container. If you just want to get coding, wraptool up builds and enters one for you — see Getting started.
On a Guix host, wraptool up doesn’t use a dev container at all — it uses a native Guix container built from your manifest.scm (no Docker, no image build). This page covers the devcontainer path, which up falls back to off Guix (Arch, macOS, …) or under --runtime=devcontainer. The isolation guarantees are the same; only the mechanism differs.
Anatomy of a devcontainer.json
A dev container is one JSON file at .devcontainer/devcontainer.json in your repo. The two fields that matter most:
{
"name": "myproject",
// The base image: an OS + maybe a preinstalled runtime.
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
// Composable add-ons, each pulled from an OCI registry.
"features": {
"ghcr.io/devcontainers/features/go:1": {}
}
}
Open it with VS Code → “Reopen in Container”, the JetBrains dev container support, or the CLI:
devcontainer up --workspace-folder .The image is built once and cached; features layer on top of it.
Prefer a base image + language features
You can pick a fat, batteries-included image (.../devcontainers/go:1-bookworm ships Go preinstalled). But the pattern that scales better across projects is:
Start from
mcr.microsoft.com/devcontainers/base:ubuntuand add the languages you need as features.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/go:1": { "version": "1.22" },
"ghcr.io/devcontainers/features/java:1": {
"version": "21",
"jdkDistro": "tem"
}
}
}
Why this way:
- One base, many stacks. A polyglot repo (a Go service with a Java client, say) is just two feature lines — no hunting for an image that happens to bundle both.
- Pinned, explicit versions.
"version": "1.22"in the feature options is clearer and easier to bump than a buried image tag. - Consistent tooling. Every project starts from the same well-maintained Ubuntu base, so shell, user, and
common-utilsbehave the same everywhere.
The devcontainers/features collection maintains language features for Go, Java, Node, Python, Rust, and more. Each feature’s README lists its options (version, distribution, extra tools):
- Go — https://github.com/devcontainers/features/tree/main/src/go
- Java — https://github.com/devcontainers/features/tree/main/src/java
Features are ordered by their dependencies, not by their position in the file. If you need a specific install order, use the overrideFeatureInstallOrder property. For most language + tool combinations the defaults are fine.
The wraptool feature
One feature does both jobs, keyed on a single harness choice so the name can’t drift between two places:
provision(defaulttrue) — installs the harness binary into the image (npm i -gfor claude/opencode/pi, the harness’s own script for agy), plus pinsHOMEto a host-mounted shared pool so the harness’s auth, skills, and config persist across every project (the developer toolbox — installed and logged-in once, not per session).connect(defaulttrue) — wires the container to a host-side wraptool MCP server, so the agent’s git/gcloud/kubectl calls execute on the host, where the credentials live, while the container itself stays credential-free.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
// Language toolchains (npm harnesses need node — see below)
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers/features/node:1": {},
// Provision the harness binary + wire it to the host wraptool
"forge.snamellit.com/pti/wraptool/wraptool:0": { "harness": "claude" }
},
// Host workspace path, so the server scopes tools to this worktree.
"containerEnv": { "WRAPTOOL_CWD": "${localWorkspaceFolder}" },
// The token is a secret → remoteEnv keeps it out of image layers.
"remoteEnv": { "WRAPTOOL_TOKEN": "${localEnv:WRAPTOOL_TOKEN}" },
// Share the host harness-state pool (login/skills once, every project).
"mounts": [
"source=${localEnv:HOME}/.local/share/wraptool/harness-pool/home,target=/home/wraptool-harness,type=bind"
]
}
The ~/.local/share/... mount source is the Linux pool location. Match it to your OS or the bind mount fails on a non-existent source. Use forward slashes under ${localEnv:HOME} on every OS (backslashes are invalid in a JSON string): macOS ${localEnv:HOME}/Library/Application Support/wraptool/harness-pool/home, Windows ${localEnv:HOME}/AppData/Local/wraptool/harness-pool/home. wraptool init devcontainer and wraptool up write/mount the platform-correct path for you; only a hand-written devcontainer.json needs this adjustment.
connect is image-agnostic: it adds no tools of its own, only the MCP client config. At container-create it writes .mcp.json pointing at wraptool, auto-detecting the host as the container’s default-route gateway — so there’s no host.docker.internal or --add-host to manage, and it works across docker networks and git worktrees.
The feature reads two environment variables from the top-level config (it can’t inject them itself, because feature-level containerEnv is not variable-substituted):
| Var | Where | Set to | Purpose |
|---|---|---|---|
WRAPTOOL_TOKEN |
remoteEnv |
${localEnv:WRAPTOOL_TOKEN} |
Bearer token (secret → remoteEnv, not baked into the image). |
WRAPTOOL_CWD |
containerEnv |
${localWorkspaceFolder} |
Host workspace path, sent to the server as ?cwd= so it scopes tools to this worktree. |
npm harnesses need node. claude/opencode/pi install via npm, so the image needs a node runtime — add ghcr.io/devcontainers/features/node:1. The scaffolder (below) adds it for you. Set "provision": false to bring your own binary (baked into your base image), or "connect": false to wire the harness yourself. agy installs via its own script (no node); gemini has no automated installer, so use "provision": false and bake it in.
See the feature definition for every option, and Isolated environments for the threat model. The older wraptool-connect feature (wiring only) is deprecated but stays published for existing configs.
For container use the server must listen where the container can reach it — bind the docker gateway or 0.0.0.0 (e.g. listen: 0.0.0.0:8717), not 127.0.0.1.
Scaffold it
Rather than hand-write the config, generate a starting point:
wraptool init devcontainer --harness claude --lang go,javaIt writes .devcontainer/devcontainer.json from the base:ubuntu + features model above: the language features you list, the wraptool feature with your harness, and the WRAPTOOL_* env + shared-pool mount prefilled. npm harnesses auto-pull the node feature so the build won’t fail for lack of npm. It writes a fresh file and refuses to clobber an existing one (pass --force); it does not merge into a hand-authored config. Edit the result freely — it’s a starting point.
A complete example
Putting the pieces together — an Ubuntu base, Go and Java toolchains, and a credential-isolated Claude Code (this is what the scaffolder emits, plus editor extensions):
{
"name": "polyglot-agent-box",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/go:1": { "version": "1.22" },
"ghcr.io/devcontainers/features/java:1": { "version": "21" },
"ghcr.io/devcontainers/features/node:1": {},
"forge.snamellit.com/pti/wraptool/wraptool:0": { "harness": "claude" }
},
"containerEnv": { "WRAPTOOL_CWD": "${localWorkspaceFolder}" },
"remoteEnv": { "WRAPTOOL_TOKEN": "${localEnv:WRAPTOOL_TOKEN}" },
"mounts": [
"source=${localEnv:HOME}/.local/share/wraptool/harness-pool/home,target=/home/wraptool-harness,type=bind"
],
// Editor integration: install the language extensions in the container.
"customizations": {
"vscode": {
"extensions": ["golang.go", "redhat.java"]
}
}
}
Bringing it up and down with wraptool
You can drive the container with the raw dev container tooling — but that means starting the host server, minting a token, exporting it, and running devcontainer up yourself. wraptool bundles the whole dance into two commands.
wraptool up
Run it from the repository root (or any git worktree):
wraptool up # running bare `wraptool` in a repo does the sameIt is idempotent and does three things:
- Ensures the shared host server is running. If the server isn’t up, it starts it detached (pidfile + lock under
$XDG_RUNTIME_DIR, auto-generating the auth token) and waits for it to become reachable. - Brings up this project’s container — a native Guix container on a Guix host, else
devcontainer up— injecting the connection info thewraptoolfeature needs: the auth token (WRAPTOOL_TOKEN) and this worktree’s host path (WRAPTOOL_CWD), so.mcp.jsonis wired correctly without you exporting anything. It also bind-mounts the shared harness pool into the container — but defers when thedevcontainer.jsonalready declares that mount (e.g. one written bywraptool init devcontainer), so the two never collide on a duplicate-mount error. - Drops you into a shell in the container, ready to code. Exiting the shell leaves the container running; re-enter any time by running
wraptool upagain.
Control the shell behaviour with --shell:
wraptool up --shell=always # always open a shell
wraptool up --shell=never # just bring the container up (scripts / CI)wraptool down
Stop this worktree’s container when you’re finished:
wraptool down # stop this project's dev container
wraptool down --rm # ...and remove it (next `up` recreates it fresh)down deliberately leaves the shared server running, since one server backs every project and worktree. Manage that server directly when you need to:
wraptool server status
wraptool server stopSeveral worktrees can each use wraptool up; their generated connections send different host paths. This is routing, not automatic isolation: configure mcp.allowed_cwd_roots to reject a client that supplies another path.
wraptool up is the recommended path — the raw export WRAPTOOL_TOKEN=… && devcontainer up / VS Code “Reopen in Container” flow still works and is covered in Getting started for when you want to drive the container yourself.
Compile, test, and edit — inside the container
Once you’re in, your editor’s language servers, build tasks, and test runners all use the container’s toolchain. The go and java features put compilers on PATH, and the customizations.vscode.extensions list installs the matching editor extensions in the container so IntelliSense, debugging, and test gutters work out of the box:
go build ./... # the container's Go, not your laptop's
go test ./...The AI harness runs beside them:
claude # or: agyWhen the agent needs to touch a credential-guarded tool — commit and push, inspect a cluster — it calls wraptool over MCP and the command executes on the host. Confirm the isolation any time from inside the container:
cat ~/.ssh/id_ed25519 # fails — no keys in the container
git push # only works via the wrapped MCP toolWhere to go next
- Getting started — configure and run the host server.
- Isolated environments — Guix, Docker, Podman, and Unix-socket variants, plus the full security checklist.
- Configuration reference — every whitelist field.