Skip to content

xorq-labs/devcontainer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

62 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pre-commit hooks

This repo uses pre-commit to run linters (shellcheck, ruff, yamllint, hadolint) before each commit. All hook dependencies (including linter binaries) are managed by pre-commit — no separate installs needed.

Setup (one-time, from the repo root with direnv active):

# copy the uv envrc if you haven't already — this sets UV_TOOL_DIR/UV_TOOL_BIN_DIR
# so uv installs tools into .tools/bin/ (project-local, not ~/.local/bin)
cp .envrcs/.envrc.user.uv .envrcs/.envrc.user
direnv allow

uv tool install pre-commit

The git hook is symlinked automatically by direnv (via symlink_hooks in lib/git.sh). In devcontainer and worktree contexts, install_hooks also clears core.hooksPath before symlinking. The hook finds pre-commit via .tools/bin/ on the host or PATH in the container — no pre-commit install needed.

After this, the configured checks run automatically on git commit. To run all hooks against every file manually:

pre-commit run --all-files

Dev container

As an alternative to setting up a local environment, you can use the dev container. It works with both the main checkout and git worktrees.

Prerequisites (host): Docker, bash, git, and Python 3. No other dependencies required (no Node, no uv). The script must be run from inside a git repo.

Important

Linux only. The tooling uses GNU coreutils; the Nix base image is multi-arch (amd64 + arm64), while the classic Dockerfile installs amd64 binaries. macOS and Windows are not supported — a macOS entry path (VS Code "Reopen in Container" on the pulled Nix base) is tracked in #43.

Toolchain (container): Python 3.12, just, sops, gh, and Claude Code. Most projects get these from the Nix base — a CI-published multi-arch image pulled from ghcr, where a claude-code bump reships one layer instead of rebuilding everything (see nix/base/README.md). Overlays that drive their own Nix store (created with init --nix) build on the classic root Dockerfile instead, which also ships Node 22 and pins its own tool versions. devcontainer resolve shows which base a workspace gets (BASE=...); DEV_NIX_BASE=0/=1 force it. Project-specific tools (e.g. uv, direnv) are installed by the project overlay's install-system.sh; for example, the Python overlays pin uv 0.7.8.

Setup: The devcontainer script lives in dev/. Run direnv allow in the repo root to add it to your PATH, or invoke it directly as ./dev/devcontainer.

# start the container (builds on first run)
devcontainer up

# open a shell inside the container
devcontainer exec

# run claude
devcontainer claude

# run claude with --dangerously-skip-permissions
devcontainer claude-dangerously-skip-permissions

# stop the container
devcontainer down

# destroy container and all volumes (venv, uv cache) to start fresh
devcontainer reset

# reset + remove host-side artifacts (keeps the shared project image)
devcontainer clean

# remove the project's shared images — affects ALL worktrees
devcontainer clean-images

# check whether the container is running
devcontainer status

# list all worktrees with container status and overlay
devcontainer list

# show resolved overlay and project.env values without starting anything
devcontainer resolve

# view container logs
devcontainer logs

Notes:

  • After the first up, subsequent exec and claude invocations auto-start the container if it isn't already running.
  • The container rebuilds automatically when the Dockerfile or compose config changes.
  • down prompts before stopping a running container; reset prompts before destroying volumes.
  • exec with no arguments opens a bash shell and requires an interactive TTY. With an explicit command (e.g. exec uv run pytest -m core), no TTY is needed.

Worktrees

To use from a worktree, pass -w or run the script from the worktree directory:

# from the main checkout, targeting a worktree
devcontainer -w ../xorq-my-feature up

# or from inside the worktree (direnv adds dev/ to PATH)
devcontainer up

Worktrees of one project share a single image, tagged <project>-devimg:<fingerprint> where the fingerprint hashes every build input (Dockerfile, compose files, overlay scripts, baked build args). The first worktree builds it; siblings with identical build inputs reuse it and skip the build entirely. A branch that edits a build input gets its own tag — the two images coexist, so nothing is clobbered or rebuilt on every branch switch. Containers, networks, and per-worktree volumes stay isolated per worktree; devcontainer clean-images removes the project's images (all fingerprints) once no container uses them, and old fingerprints otherwise accumulate until you run it.

The image tag hashes build inputs only. Purely runtime inputs (host-mounts.txt / host-mounts.local.txt) feed a separate staleness hash that drives the "config changed, recreate?" prompt on a running container — so editing a host mount recreates the container without minting a new image tag or triggering a rebuild. Compose files are hashed whole (they mix build and runtime config), so a runtime-only compose edit still re-tags into a fully-cached rebuild — correct, just slightly noisy.

Project configuration

Project-specific configuration lives in a project overlay — either projects/<name>/ in the devcontainer repo (shipped defaults) or .devcontainer/ in the consumer workspace (local override). Everything outside the overlay (the Dockerfile, nix/base/, docker-compose.yml, dev/devcontainer, lib/, etc.) is generic infrastructure. The overlay is resolved automatically: workspace .devcontainer/ takes precedence over projects/<name>/, which falls back to defaults/. Per-project devcontainer.json lives alongside the overlay because the spec doesn't support sub-file includes (see step 5 below).

The overlay also selects the container's base image: an overlay whose compose.override.yml mounts a nix seed volume (:/nix, from init --nix) or overrides classic-only Dockerfile build args (BASE_IMAGE, tool pins) builds on the classic root Dockerfile; every other overlay builds on the pulled Nix base via nix/base/Dockerfile.nix-default. The two are alternative /nix delivery strategies — see nix/base/README.md for the model table and escape hatches.

File Role
install-system.sh apt packages and language toolchain (runs as root during docker build)
setup-env.sh first-run + sync-on-lockfile-change hooks (runs in-container as vscode)
compose.override.yml extra named volumes, bind mounts, env vars, and the EXTRA_PATH build arg
external-volumes.txt basenames of named volumes declared external: true in compose.override.yml; pre-created as ${DEV_PROJECT_NAME}-<basename> so a fresh checkout doesn't error
worktree-symlinks.txt paths under the main worktree to symlink into new worktrees
worktree-copies.txt paths to copy (not symlink) into new worktrees; globs allowed
host-mounts.txt <host-path>:<container-path>[:<options>] bind mounts added to compose at runtime; tilde-expanded, missing host paths skipped with a warning
host-mounts.local.txt per-developer mount overrides (gitignored); same format as host-mounts.txt
audit-prefixes.txt first-word triggers for two-word grouping in the audit report (e.g. gitgit status, uvuv run)
project.env.example template for the gitignored project.env overrides

Copy project.env.example to project.env (gitignored) in the overlay directory to set:

  • MODEL_VERSION — passed as --model on each dev/devcontainer claude invocation. Per-worktree: each worktree's project.env can set a different model (e.g. use a cheaper model for routine tasks). Re-read from the host on every call, not baked into the container. Leave unset to use Claude Code's default.
  • DANGEROUSLY_SKIP_PERMISSIONS=1 — makes dev/devcontainer claude pass --dangerously-skip-permissions automatically. The explicit claude-dangerously-skip-permissions subcommand still works as a one-off override regardless of this setting.

Inspecting the resolved configuration:

devcontainer resolve shows what overlay and settings would be used for the current workspace without starting anything — useful for verifying that a checkout maps to the expected projects/<name>/ entry:

$ devcontainer resolve
Resolved configuration for workspace: /home/dan/repos/github/xorq-dasher

Overlay resolution:
  [skipped] .devcontainer/ — not present
  [skipped] projects/xorq-dasher/ — not present
  [fallback] defaults/

  overlay:  defaults/ (no project overlay matched)
  from:     /home/dan/repos/github/devcontainer/defaults

Resolved values:
  PROJECT_NAME=xorq-dasher
  MODEL_VERSION=<unset>
  DANGEROUSLY_SKIP_PERMISSIONS=<unset>
  CONTAINER_NAME=xorq-dasher-dev-xorq-dasher

If the overlay name doesn't match the checkout directory name (e.g. you cloned dasher as xorq-dasher), set DEV_PROJECT_NAME before invoking: DEV_PROJECT_NAME=dasher devcontainer resolve.

devcontainer list shows all worktrees with their container status and the overlay each would resolve to now:

$ devcontainer list
WORKTREE                 STATUS     OVERLAY
main                     running    projects/dasher
feat/auth                stopped    projects/dasher

When a container starts (up, exec, claude), the resolved overlay and project.env values are written to .envrcs/.resolved-env in the workspace as a historical record of what was actually used.

The container workspace is always /workspaces/src — threaded through compose, the Dockerfile, and setup-claude as DEV_CONTAINER_WORKSPACE, but changing it is not supported.

Adapting to another project

Clone this repo as a sibling of your project and symlink its dev/ directory into your workspace:

# from the parent directory containing both repos
ln -s ../devcontainer/dev myproject/dev
echo dev >> myproject/.gitignore

Then add PATH_add dev to your .envrc (or export PATH="$PWD/dev:$PATH" in your shell). The devcontainer script resolves its own location through the symlink to find the Dockerfile, compose config, and libraries, so a single clone serves all projects. socat is optional on the host but required for SSH and GPG agent forwarding into the container.

From your project directory, create a project overlay with devcontainer init --local (creates .devcontainer/ in the workspace) or devcontainer init [project-name] (creates projects/<name>/ in the devcontainer repo for shipping defaults). Pass --nix if your project drives Nix itself (its own flake, nix develop/nix build) — this seeds a writable, vscode-owned /nix volume and keeps you on the classic Dockerfile. Then edit the overlay:

Which base you get. By default your overlay builds on the CI-published Nix base pulled from ghcr — first up on a machine transfers ~0.76 GB (cached once; later claude-code bumps pull a single layer). This base ships no node/npm: if your project needs npx (e.g. npx-based MCP servers or tooling), install node in install-system.sh or run with DEV_NIX_BASE=0. Overlays created with init --nix (seed volume) or that override classic build args (BASE_IMAGE, tool pins) build on the classic root Dockerfile instead, which ships Node 22. Run devcontainer resolve to confirm which base your workspace gets (BASE=nix-base / BASE=classic, with the reason); DEV_NIX_BASE=0/=1 force it either way. See nix/base/README.md for the full model.

  1. install-system.sh — apt packages and language toolchain. The default is a no-op. Replace with whatever your project needs (e.g. build-essential + uv, golang-go, rustup, bun).

  2. setup-env.sh — what runs after the container starts. Takes one subcommand: first-run (called once after initial up — install dependencies, seed caches) or sync-if-needed (called on every exec/claude entry — re-sync if a lockfile changed, e.g. compare uv.lock mtime against .venv/.last-sync). Keep the case-statement interface and replace the bodies.

  3. compose.override.yml — named volumes, host bind mounts, env vars, and the EXTRA_PATH build arg. All project-specific compose customization belongs here, not in docker-compose.yml. Named-volume mount targets are auto-chowned to vscode on first run, so adding a volume requires no changes outside this file. Delete or rename volumes you don't need. Cross-worktree volumes (external: true) must also be listed in external-volumes.txt so dev/devcontainer pre-creates them with the project-namespaced name.

    Skeleton for a Python project with a .venv and shared uv cache:

    services:
      app:
        build:
          args:
            EXTRA_PATH: /workspaces/src/.venv/bin
        volumes:
          - uv-cache:/home/vscode/.cache/uv
    
    volumes:
      uv-cache:
        external: true

    With external: true, add uv-cache to external-volumes.txt so the volume is pre-created as ${DEV_PROJECT_NAME}-uv-cache.

  4. worktree-symlinks.txt / worktree-copies.txt — what setup-worktree propagates from the main worktree.

  5. devcontainer.json — only used when attaching VS Code to an already-running container (started via dev/devcontainer up). Edit forwardPorts and customizations.vscode.extensions for your project. The initializeCommand tripwire blocks VS Code's "Reopen in Container" flow, which is unsupported (see below). Lives alongside the overlay.

  6. Dockerfile (classic base — seed overlays and DEV_NIX_BASE=0) — exposes build args you can override from compose.override.yml rather than editing the Dockerfile in place: BASE_IMAGE (default mcr.microsoft.com/devcontainers/python:3.12-bookworm) for non-Python base images; EXTRA_PATH (empty by default, set to the Python venv's bin dir in the project override) prepended to the container PATH so project tools resolve; and tool-version pins (NODE_MAJOR, JUST_VERSION, SOPS_VERSION, CLAUDE_CODE_VERSION) — bump these together with their companion checksum args where present (NODESOURCE_SHA256, JUST_INSTALLER_SHA256, SOPS_SHA256; Claude Code is installed via npm and has no checksum arg). nix/base/Dockerfile.nix-default honors the same EXTRA_PATH contract on the Nix-base path. To bump the Claude Code pin, run devcontainer bump-claude-code — it updates CLAUDE_CODE_VERSION and the Nix base's pkgs/claude-code.nix together (pass an explicit version to pin it, or --check to compare without editing); tests/test-claude-code-pin-sync.sh guards the two against drift. Likewise, run devcontainer bump-nix to bump the Nix pin in lib/nix-seed.sh — it rewrites the coupled NIX_VERSION and NIX_INSTALLER_SHA256 defaults together (downloading the target installer to compute its checksum), and accepts an explicit version or --check.

All overlay files are optional. install-system.sh and setup-env.sh must exist (the Dockerfile COPYs them) but may be empty no-ops; compose.override.yml and the *.txt lists may be missing entirely — read_list treats a missing list as empty.

Two worktree paths are hardcoded in dev/setup-worktree rather than living in worktree-{symlinks,copies}.txt: .gitignore is always copied (git opens it with O_NOFOLLOW, so a symlink would ELOOP), and .claude is always symlinked (audit logs and session captures are devcontainer infrastructure that must aggregate in the main checkout regardless of project).

Tab completion

To load completions for the current session, eval the emitted script:

# bash
eval "$(devcontainer completions bash)"

# zsh
eval "$(devcontainer completions zsh)"

# fish
devcontainer completions fish | source

To install them permanently, install-completions writes each shell's script to the location it autoloads from (honoring XDG_DATA_HOME/XDG_CONFIG_HOME):

# install for every shell whose binary is present
devcontainer install-completions

# or a specific shell
devcontainer install-completions bash

# preview target paths without writing anything
devcontainer install-completions --print-paths
Shell Target Notes
bash ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/devcontainer requires the bash-completion package; loaded on demand
zsh ${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions/_devcontainer the dir must be on $fpath before compinit
fish ${XDG_CONFIG_HOME:-~/.config}/fish/completions/devcontainer.fish loaded automatically

Start a new shell after installing.

dev/devcontainer vs devcontainer.json

Warning

Use dev/devcontainer. The VS Code "Reopen in Container" / official devcontainer CLI path is unsupported. It will start a container, but most of what makes the dev environment work — UID/GID matching, host git/gh/SSH credentials, Claude config, sops keys, worktree support, dependency install — is implemented in dev/devcontainer and is not invoked by VS Code's path. The container will appear to start, then silently lack credentials, fail on permission errors, or run with stale state. We keep devcontainer.json only for IDE port forwarding and extension installation when used alongside an already-running container started via dev/devcontainer.

An initializeCommand tripwire in devcontainer.json exits non-zero on the unsupported path so the failure surfaces with a pointer here instead of silently degrading. dev/devcontainer drives docker compose directly and never reads devcontainer.json, so the tripwire doesn't fire when starting via the supported entry point; VS Code attach to an already-running container also bypasses it (lifecycle commands only run on build/up).

The two paths diverge in what they provide:

Capability dev/devcontainer devcontainer.json (VS Code)
Build & run docker compose via the script VS Code / devcontainer CLI
Toolchain (uv, just, sops, gh, node, claude) Dockerfile — always applied Dockerfile — always applied
UID/GID matching DEV_UID/DEV_GID build args Not applied (uses image defaults)
Git config, gh auth, Claude setup setup_git, setup_gh, setup_claude (in lib/host-bridge.sh) Not applied
Worktree support Full (mount host .git, resolve worktree paths) Not supported
SSH agent forwarding socat TCP bridge via host.docker.internal Not applied
GPG agent forwarding socat TCP bridge via host.docker.internal Not applied
sops age keys Mounted read-only via compose Not applied
Port forwarding Not handled (use docker compose ports) forwardPorts in devcontainer.json
VS Code extensions & settings Not applied customizations.vscode in devcontainer.json
Dep sync on lockfile change sync_if_needed in the script Not applied
Image staleness check Fingerprint of build inputs (image hash) + runtime inputs (staleness hash) Handled by VS Code

Troubleshooting

  • devcontainer: command not founddev/ isn't on PATH. Run direnv allow in the repo root, or invoke as ./dev/devcontainer.

  • docker: command not found or "Cannot connect to the Docker daemon" — Docker isn't installed or the daemon isn't running. Start Docker (systemctl --user start docker or your distro's equivalent).

  • docker compose reports "unknown command" — you have Compose v1. Install Compose v2 (the script uses docker compose, not docker-compose).

  • Build fails partway through up — inspect with devcontainer logs and retry: a failed build leaves no fingerprint tag, so the next up rebuilds. For corrupted cached layers, devcontainer clean-images then docker builder prune before retrying.

  • Files in .venv/ owned by root, or Permission denied writing to the workspace — UID/GID drift between the host and the image. The host UID/GID is part of the image fingerprint, so devcontainer clean && devcontainer up builds a fresh image matching the current host IDs.

  • uv sync runs every entry — the lockfile is newer than .venv/.last-sync. Expected after git pull; harmless.

  • "all predefined address pools have been fully subnetted" — Docker ran out of subnet ranges for new networks. Orphaned networks accumulate when containers exit without going through devcontainer down (host reboot, Docker daemon restart, docker stop). Clean up with docker network prune and retry.

  • Auto-rebuild prompt won't go away — the content hash of Dockerfile / compose changed. Accept the rebuild, or devcontainer clean to reset state.

  • GPG passphrase prompt inside the container despite the host not requiring one — The GPG agent bridge uses the agent-extra-socket, which disables external-cache lookups in pinentry (GNOME Keyring, macOS Keychain, etc.). The setup pre-warms gpg-agent's internal cache with a no-op sign, but if the cache expires mid-session you'll be prompted again. Bump the TTL in your host's ~/.gnupg/gpg-agent.conf:

    default-cache-ttl 28800
    max-cache-ttl 28800
    

    Then restart the agent: gpgconf --kill gpg-agent. The values above give an 8-hour window. To re-warm without restarting the container: devcontainer refresh-gpg.

  • Container claude isn't authenticated, or shows the wrong account — each container seeds its own private token from a host profile (DEV_CLAUDE_PROFILE, else the host's active profile). If that token expired, you re-logged-in on the host, or you want a different account, re-seed:

    # surgical: re-seed the private token from its profile (keeps venv volume)
    devcontainer fix-credentials
    
    # full reset: destroy volumes and rebuild (also rebuilds the venv)
    devcontainer reset && devcontainer up

    To pin a container to a specific profile, set DEV_CLAUDE_PROFILE=<name> before up/fix-credentials.

Claude isolation & permission audit

The container's ~/.claude is a per-worktree Docker volume, fully isolated from the host — including credentials: each container is seeded its own private token rather than sharing the host's credential file. On each entry (up, exec, claude, claude-dangerously-skip-permissions), setup-claude sets up the following:

  • Credentialssetup-claude seeds a private ~/.claude/.credentials.json (a regular file on the isolated volume), copied once from the read-only host profile store at ~/.claude-host/credentials/<profile>.json — profile chosen by DEV_CLAUDE_PROFILE, defaulting to the host's active profile — and patches the matching oauthAccount into ~/.claude.json so auth status reports the right account. The container refreshes its own token independently: no shared credential inode, so concurrent sessions never race on OAuth refresh (the failure mode of the old shared-mount design). See docs/adr/0001-devcontainer-private-token-isolation.md. Re-seed with devcontainer fix-credentials; override with an explicit token via devcontainer set-credentials.
  • Global permissions and CLAUDE.md — the permissions block from ~/.claude/settings.json, plus ~/.claude/CLAUDE.md (copied from the read-only host mount)
  • Project permissions and memory — the permissions block from ~/.claude/projects/<host-project-key>/settings.json and settings.local.json, plus the project's memory/ directory (copied)
  • Session transcripts — every *.jsonl under the host project key is mirrored into the container project key so claude --resume can continue a session started on the host. Resume locates a session by a cwd-derived project key (which differs between host and container) and each record carries an absolute cwd, so the host workspace prefix is rewritten to the container path as transcripts are copied. Host → container only; transcripts already present container-side are left untouched so continued work isn't clobbered.

Because the mirror runs on entry, a host session started after the container came up isn't visible until the next up/exec/claude. To pull it in immediately without a full re-setup:

devcontainer copy-host-transcripts

Container-side Claude settings changes (e.g. permissions granted mid-session) are overwritten on the next entry. Host hooks are intentionally not copied — they reference host paths and binaries that don't exist inside the container.

A PreToolUse hook logs every tool invocation to .claude/container-audit/audit.jsonl in the workspace. Use the audit subcommand to review:

# summary (default): tool counts, bash prefixes, permissions not in host baseline
devcontainer audit
devcontainer audit --summary

# just the new permission patterns (for piping into settings)
devcontainer audit --new

# all observed patterns (including those already in baseline)
devcontainer audit --all

# clear the audit log
devcontainer audit --clear

Container session logs are written to .claude/container-sessions/ in the workspace, visible from the host.

Note

Private credentials: Each container seeds its own token from the read-only host profile store; the host credential file is never bind-mounted read-write. A container compromise can read the host profile store (via the :ro .claude-host mount) but cannot write host credentials, and cannot invalidate other containers' or the host's tokens. This also removes the concurrent-refresh auth loss the old read-write shared mount was built to tolerate (see docs/adr/0001-devcontainer-private-token-isolation.md).

Volume persistence: down stops the container but leaves Docker volumes intact. The seeded token now lives in the claude-home volume, so reset scrubs it (re-seeded on the next up) — revoke tokens via your OAuth provider if a container is compromised.

Host git access: The host's .git directory is mounted read-write inside the container (required for git operations in worktrees). A container compromise could modify host git history, hooks, and refs.

SSH/GPG agent bridge (Linux-native): On Linux-native Docker, the socat SSH-agent and GPG-agent bridges bind to the Docker bridge gateway IP (e.g. 172.17.0.1). Any container on the same bridge network can connect to these ports and use the forwarded keys. On Docker Desktop and WSL2, the bridges bind to 127.0.0.1 and are not exposed. If you run untrusted sibling containers on the same Docker bridge, stop the bridges after your session (devcontainer down kills them) or isolate the dev container on a dedicated network.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages