Skip to content

xorq-labs/devcontainer

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 x86_64 only. Installs amd64 binaries and uses GNU coreutils. macOS and Windows are not supported.

Toolchain (container): Python 3.12 with uv 0.7.8, just 1.40.0, sops 3.9.4, gh, direnv, Node 20, and Claude Code 2.1.119. Generic tool versions are pinned in Dockerfile; project-specific versions (uv, direnv) are in the project overlay's install-system.sh.

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 images and host-side artifacts
devcontainer clean

# 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

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, 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).

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), then edit the overlay:

  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 — 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).

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

# bash
eval "$(devcontainer completions bash)"

# zsh
eval "$(devcontainer completions zsh)"

# fish
devcontainer completions fish | source

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 Content hash of Dockerfile, compose, and COPY'd files 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, then devcontainer clean && devcontainer up to rebuild from scratch.

  • Files in .venv/ owned by root, or Permission denied writing to the workspace — UID/GID drift between the host and the image. devcontainer clean && devcontainer up rebuilds with the current host UID/GID.

  • 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 can't see host OAuth token after upgrading from a pre-shared-credentials build — the shared-credentials design relies on a ~/.claude/.credentials.json -> credentials/.credentials.json symlink baked into the image and copied into the per-worktree claude-home named volume on first creation. Volumes that predate the symlink still hold the old plain file. Each worktree has its own claude-home volume, so repeat per worktree — new worktrees and fresh checkouts aren't affected. Two ways to recover:

    # surgical: repair the symlink in a running container (keeps venv volume)
    devcontainer fix-credentials
    
    # full reset: destroy volumes and rebuild (also rebuilds the venv)
    devcontainer reset && devcontainer up

Claude isolation & permission audit

The container's ~/.claude is a per-worktree Docker volume, isolated from the host except for credentials (see the shared-credentials note below). On each entry (up, exec, claude, claude-dangerously-skip-permissions), setup-claude sets up the following:

  • Credentials~/.claude/credentials/ is bind-mounted read-write from the host. All containers and the host share a single .credentials.json via this mount, so OAuth token refreshes in any container are immediately visible everywhere. On first run, setup_claude_credentials migrates the host's ~/.claude/.credentials.json into ~/.claude/credentials/ and leaves a symlink.
  • 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)

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

Shared credentials: The ~/.claude/credentials/ directory is bind-mounted read-write into every container. A container compromise gains write access to the host's credential file (read access was already possible via the read-only mount). This is a deliberate tradeoff to avoid cascading 401s from OAuth token refresh invalidating copies.

Volume persistence: down stops the container but leaves Docker volumes intact. Credentials live on the host filesystem (not in the volume), so reset does not scrub them — revoke tokens via your OAuth provider if needed.

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

No releases published

Packages

 
 
 

Contributors