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:

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

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 (default claude) — selects which MCP config shape connect writes; no longer selects an installer.
  • 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. Set false to 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"
  ]
}
Important

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

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.

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

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" },

    "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 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 home/ 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. The read-only runtime/ mount (the harness binary) can’t be injected the same way — the devcontainer CLI’s --mount flag has no read-only field — so up only verifies it: it checks the selected harness is pool-locked and that devcontainer.json declares the mount correctly, and prints the exact snippet to add when it’s missing.
  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 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