Dev containers for 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 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 agent.
- 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
The feature is wiring only (major :1 — see design/harness/unified-harness-pool.md §12.3): it installs no harness binary. The binary comes from the shared harness pool’s runtime/ tree instead — version-locked and built once per developer with wraptool harness install <name> on the host, then mounted read-only into every project container. The feature is keyed on a single harness choice, so the name can’t drift between two places:
harness(defaultclaude) — selects which MCP config shapeconnectwrites; no longer selects an installer.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. Setfalseto wire it yourself.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
// Wire the container to the host wraptool. No binary is installed here —
// it comes from the runtime mount below.
"forge.snamellit.com/pti/wraptool/wraptool:1": { "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 pool: home/ (state) read-write, runtime/ (binaries)
// read-only.
"mounts": [
{ "source": "${localEnv:HOME}/.local/share/wraptool/harness-pool/home", "target": "/home/wraptool-harness", "type": "bind" },
"source=${localEnv:HOME}/.local/share/wraptool/harness-pool/runtime,target=/opt/wraptool-harness/runtime,type=bind,readonly"
]
}
The ~/.local/share/... mount sources are the Linux pool location. Match them to your OS or the bind mount fails on a non-existent source. ${localEnv:HOME} works on all three OSes — the pool root always lives under the user’s home directory, just at a different relative path per OS. 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,runtime}, Windows ${localEnv:HOME}/AppData/Local/wraptool/harness-pool/{home,runtime} (%LOCALAPPDATA% is itself %USERPROFILE%\AppData\Local). The Windows-specific caveat isn’t the mount source, it’s that HOME isn’t set by default there (only USERPROFILE) — see Windows for the one-time setx HOME %USERPROFILE% fix. wraptool init devcontainer and wraptool up write/mount the platform-correct paths for you; only a hand-written devcontainer.json needs this adjustment.
The runtime mount must stay the string form shown above, ending in ,readonly. The devcontainer CLI’s object-form mounts ({"source":..., "target":...}) have no read-only field at all, so a "readonly": true key on an object-form entry is silently dropped and the mount comes up read-write — which would let this ONE project’s container rewrite the pool binaries every OTHER project’s container runs. The home mount has no such requirement (it is read-write either way), so object form is fine there.
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. |
No harness needs a node feature in the image any more. The runtime mount bundles its own pinned node for npm-sourced harnesses (claude/opencode/pi); the feature’s own scripts are POSIX sh with no npm/node dependency. Add ghcr.io/devcontainers/features/node:1 only if your project code needs node. agy installs via its own channel; gemini has no automated pool installer (not pool-lockable) — its binary is never in the runtime mount, so bake it into your image or install it by hand if you select it.
See the feature definition for every option, and Isolated environments for the threat model. The old per-image-provisioning major (:0, with provision/ version options) remains published for existing configs but is deprecated — new setups should pin :1 explicitly. The older, pre-merge wraptool-connect feature (wiring only) is sunset: its source is retired and it is no longer published, but already-published registry artifacts (:0, 0.1.x) keep working for existing configs indefinitely.
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 wiring-only wraptool feature with your harness, and the WRAPTOOL_* env + both shared-pool mounts (home read-write, runtime read-only) prefilled. 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. Install the harness itself once, on the host, before opening the container:
wraptool harness install claudeA 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" },
"forge.snamellit.com/pti/wraptool/wraptool:1": { "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" },
"source=${localEnv:HOME}/.local/share/wraptool/harness-pool/runtime,target=/opt/wraptool-harness/runtime,type=bind,readonly"
],
// 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-poolhome/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. The read-onlyruntime/mount (the harness binary) can’t be injected the same way — the devcontainer CLI’s--mountflag has no read-only field — souponly verifies it: it checks the selected harness is pool-locked and thatdevcontainer.jsondeclares the mount correctly, and prints the exact snippet to add when it’s missing. - 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 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 policy field.