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:

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.

Note

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:ubuntu and 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-utils behave 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):

Tip

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 (default true) — installs the harness binary into the image (npm i -g for claude/opencode/pi, the harness’s own script for agy), plus pins HOME to 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 (default true) — 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"
  ]
}
Important

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

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.

Important

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,java

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

It is idempotent and does three things:

  1. 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.
  2. Brings up this project’s container — a native Guix container on a Guix host, else devcontainer up — injecting the connection info the wraptool feature needs: the auth token (WRAPTOOL_TOKEN) and this worktree’s host path (WRAPTOOL_CWD), so .mcp.json is wired correctly without you exporting anything. It also bind-mounts the shared harness pool into the container — but defers when the devcontainer.json already declares that mount (e.g. one written by wraptool init devcontainer), so the two never collide on a duplicate-mount error.
  3. 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 up again.

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 stop

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

Note

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:  agy

When 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 tool

Where to go next

Back to top