Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

108 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 caches (nix store, package caches) — affects ALL worktrees
devcontainer clean-caches

# 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.
  • This is the day-to-day subset — devcontainer help lists everything, including refresh-ssh/refresh-gpg/refresh-x11 (re-forward the SSH/GPG agent or X11 bridge mid-session; all three run automatically on up/exec/claude), fix-network and unlock (recovery after an ungraceful host shutdown), ps, and the credential and audit subcommands covered below.

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.

These two names are read only from project.env — an ambient MODEL_VERSION or DANGEROUSLY_SKIP_PERMISSIONS in your shell is ignored, so a value some other tool exported can't silently change the model or switch off permission prompts. To set either from the environment, use the DEV_-prefixed form, which is unambiguous about being deliberate:

DEV_MODEL_VERSION=claude-haiku-4-5 devcontainer claude
DEV_DANGEROUSLY_SKIP_PERMISSIONS=1 devcontainer claude

The DEV_-prefixed forms win over project.env — the usual flag > env > file precedence — so you can override a worktree's pinned value for a single run without editing the file. For the boolean DANGEROUSLY_SKIP_PERMISSIONS, only a literal 1 enables it, and the DEV_ form overrides in both directions: DEV_DANGEROUSLY_SKIP_PERMISSIONS=0 turns it off even when project.env set it on. devcontainer resolve prints what ended up in effect.

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>
  CLAUDE_VERIFY=<unset>
  CONTAINER_NAME=xorq-dasher-dev-xorq-dasher
  IMAGE=xorq-dasher-devimg:0f8d5db23e0128b228d7ad548c47b3a86161d71dd2fd4d643ad8e0b9fa18f98d
  STALENESS=1f6f727d11713ed45b0377d9972def2022394e2a823d57bdde579be80f1b9284
  BASE=nix-base (default; DEV_NIX_BASE=0 for the classic Dockerfile)

IMAGE is the fingerprint tag the workspace would use; STALENESS hashes build and runtime inputs (host mounts) and drives the recreate prompt. A Sources: block listing the project.env path is appended when one exists.

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_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. Its "latest" is the highest stable upstream tag — NixOS/nix publishes no GitHub releases — and it refuses to resolve backwards, so a truncated tag listing fails loudly instead of quietly pinning an older version. And devcontainer bump-hadolint does the same for the hadolint pin in projects/devcontainer/install-system.sh, which is a coupled tripleHADOLINT_VERSION plus HADOLINT_SHA256_AMD64 and HADOLINT_SHA256_ARM64 — and also bumps the paired hadolint rev: in .pre-commit-config.yaml, which tests/test-lint-config-sync.sh requires to match. It downloads both arch binaries before touching anything, so a half-bump (notably a stale arm64 sha, which breaks the arm64 container build at sha256sum -c while every test stays green) cannot be written. It has two read-only flags, and they answer different questions: --check is a report — current vs latest, no download, exit 0 whether or not the versions match (drift is news, not a failure; it still exits non-zero when it cannot resolve a version to report at all), the same shape as bump-nix --check and bump-claude-code --check; --verify is a gate — it fetches the release for the currently committed version (or an explicit one) and checks both committed per-arch checksums against it, exiting non-zero on a mismatch and also on a failed fetch, since a gate that passes when it could not check is worse than no gate. The two are mutually exclusive. Finally, devcontainer bump-nix-base moves the fourth pin — the published Nix base digest in nix/base/compose.nix-base.yml, which is what every non-seed overlay actually builds on. It resolves the digest straight from ghcr (anonymously, so it needs only curl — no docker, no read:packages), accepts a tag or an explicit digest, and carries the same --check report / --verify gate split as bump-hadolint. Prefer it to a hand edit for one specific reason: it refuses to pin anything that is not a multi-arch manifest list, and imagetools inspect prints the per-arch digests directly beneath the list digest — so pinning sha-<short>-amd64 by mistake is easy, looks entirely correct on an amd64 laptop, and breaks every arm64 consumer at pull time.

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 set up as a real directory with its two state subdirectories symlinkedcontainer-audit/ and container-sessions/ point at the main checkout, because audit logs and session captures are devcontainer infrastructure that must aggregate there regardless of project, while the rest of .claude (tracked content like agents/, and per-worktree settings.local.json) stays local to the worktree. Worktrees created before this layout carry a whole-directory .claude symlink; setup-worktree leaves those as-is (migrate by deleting the symlink and re-running it). See docs/adr/0003-track-claude-agents-in-git.md.

Tab completion

Completion scripts are generated from lib/command-table.tsv — the same table that generates devcontainer help — so the command names, descriptions and argument wiring are identical across bash, zsh, fish and the usage text.

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.

  • Every gh command fails with "cowardly refusing to continue with multi account migration … dbus-launch" — the container's hosts.yml has no token and gh refuses at config load, so even gh --version dies. Re-enter from the host and setup_gh fills it in; even when it can't (and warns that it copied nothing), it removes a stale tokenless copy from the container, which puts gh back in business via GH_TOKEN. To get the full config bridged, gh auth login on the host and re-enter.

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

  • X11 apps in the container can't open the display after an SSH reconnect — the forward was set up at container entry and points at the old DISPLAY. Re-forward with devcontainer refresh-x11 (X11 forwarding otherwise runs automatically on up/exec/claude).

  • 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. For mountless runtimes (CI, Codespaces), devcontainer set-token installs a raw setup-token bearer instead, injected as CLAUDE_CODE_OAUTH_TOKEN at launch, and devcontainer token-doctor warns when a higher-precedence auth source (ANTHROPIC_API_KEY, Bedrock/Vertex, apiKeyHelper) would silently outrank it — see docs/adr/0002-devcontainer-setup-token-env-delivery.md.
  • 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

Per-pid session status stubs are written to .claude/container-sessions/ in the workspace, visible from the host.

Note

In a worktree, .claude is a real directory, but .claude/container-audit and .claude/container-sessions are symlinks to the main checkout's copies (dev/setup-worktree always links those two so audit logs and session captures aggregate in one place; pre-existing worktrees may still have the older whole-directory .claude symlink, which resolves the same way). The audit log and session stubs are therefore shared across all worktreesdevcontainer audit reports every worktree's activity, and devcontainer clean (or audit --clear) run from any worktree clears them for all. Everything else under .claude is per-worktree, including settings.local.json.

Session transcripts live on the host

Transcripts are the one thing in ~/.claude that can't be rebuilt, so they don't live in the claude-home volume: ${HOME}/.claude/projects/-devcontainer-<container-name> is bind-mounted over the container's own project key. reset and clean are both docker compose down --volumes, and a bind is not a volume — the history survives them, and survives cleanup-worktree too. Because the host side sits under ~/.claude/projects, host tooling that walks that directory reads each container's history as an ordinary project.

dev/devcontainer-sessions reports what's there — session id, last activity, branch, and opening prompt, joined against docker inspect for the container and worktree:

devcontainer-sessions                  # every project, newest first
devcontainer-sessions -p xorq -n 10    # one project, 10 most recent
devcontainer-sessions --host-only      # skip docker entirely

Transcripts written before this bind existed are still inside the volumes; devcontainer-sessions --migrate copies them out (the volume copies stay put as a fallback).

To continue a container session on the host, --resume-on-host <session-id> reverses the seeding rewrite — container workspace back to host worktree — and files the result under the host's own project key, where claude --resume looks. It is a fast-forward: an existing host copy that is ahead of the container's is refused rather than overwritten, and backed up when --force overrides that. Once copied out, that session reads as a host-seeded copy on subsequent listings, so the container-side original is hidden unless you pass --all.

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. Session transcripts are exempt: they are bind-mounted from the host, so reset/clean leave them alone.

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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages