refactor(devcontainer): address ce-code-review findings (P0 + 4 × P1 + 8 × P2 + 2 × P3)

Walkthrough resolution of the 16-finding ce-code-review on PR #1875. 15 of
16 findings applied; one (F12, Anthropic Feature floating tag) was
superseded by F6's Feature removal.

P0
- F1: WSL2 is now REQUIRED for Windows hosts, not just recommended.
  ${localEnv:HOME} resolves to empty string on Windows-native (no HOME env
  var) — bind mounts then point at /.claude, /.codex etc. and silently
  break. ensure-host-config-dirs.cjs wrote to USERPROFILE-derived paths
  via os.homedir(), so the two surfaces disagreed about which env var was
  "home" on Windows. README header reframed; "Windows 11 — WSL2 is required"
  section explains the mismatch concretely.

P1
- F2: Workspace `node_modules` volume names now include `-${devcontainerId}`
  so two GitNexus checkouts on the same host (~/work/GitNexus and
  ~/projects/GitNexus) don't share volumes and corrupt each other's
  installs.
- F3 + F5: `postCreateCommand` extracted to `.devcontainer/post-create.sh`
  with `set -euo pipefail` and six labeled echo steps so failure logs
  name the step instead of an opaque &&-chain index. Chown step extended
  to cover /home/node/.npm, /commandhistory, and /home/node/.local — these
  named-volume mount points were owned by build-time UID 1000 but the
  container's `node` is re-IDed at runtime by updateRemoteUserUID on
  non-1000 Linux hosts, leaving them unwritable until now.
- F4: Cursor installer downloaded to a temp file with curl --retry +
  --max-time; sha256 logged to build output before execution so drift
  across rebuilds is visible in CI logs. Full hard-pin (to a versioned
  downloads.cursor.com tarball with verified sha256) tracked as a
  follow-up in README "What's not included".

P2
- F6: Anthropic Feature replaced with a direct
  `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` so
  CLAUDE_CODE_VERSION actually pins the installed binary (the Feature
  ignored the ARG and pulled latest at install time). Honors the
  earlier "pin known-good versions" decision and resolves F12's
  floating-tag concern for this Feature.
- F7: Dockerfile ARG defaults dropped for the three version vars;
  `devcontainer.json` `build.args` is now the single source of truth.
  Standalone `docker build .devcontainer/` must pass --build-arg.
- F8: ensure-host-config-dirs.cjs deleted; `initializeCommand` now uses
  POSIX `mkdir -p` + `touch ~/.gitconfig` directly, dropping the
  host-Node-on-PATH prerequisite that broke on fresh Windows+Docker
  Desktop installs without Node.
- F9: ~/.gitconfig bind-mounted read-only so `git commit` inside the
  container uses the host's user.name / user.email. Read-only so
  container-side `git config --global` doesn't leak to host.
- F10: ~/.config/gh bind-mounted (read-write) so `gh pr create` /
  `gh pr checks` / `gh issue create` work inside the container without
  re-auth. AGENTS.md's commit + PR workflow now fully functional for
  agents inside the container.
- F11: CLAUDE_CONFIG_DIR removed from Dockerfile ENV; canonical value
  lives only in devcontainer.json containerEnv. Eliminates the two-file
  edit risk.
- F13: Mounts comment now documents per-instance vs per-workspace-name
  scoping rationale so future contributors don't guess.
- F14: README "Trust boundary, concretely" paragraph names the exfil
  path explicitly (malicious npm postinstall → OAuth tokens →
  ~/.claude/projects/<workspace>/memory/MEMORY.md secrets) and lists
  vendor-side rotation runbook entries.

P3
- F15: Dockerfile pre-create + chown of /home/node/.claude, .codex,
  .cursor dropped — those paths are bind-mounted, which fully shadows
  any image-side ownership. Only .npm, .local, /commandhistory still
  benefit from the pre-create.
- F16: README "Bumping CLI versions" section rewritten against the
  post-F6 reality: CLAUDE_CODE_VERSION and CODEX_VERSION are real
  pins; CURSOR_VERSION is informational only.

Verified locally: `docker build .devcontainer/ --build-arg ...` succeeds.
Smoke-tested image: `claude --version` (2.1.153), `codex --version`
(0.134.0), `cursor-agent --version` all resolve as the non-root `node`
user; named-volume mount points (/home/node/.npm, /commandhistory) are
node-owned at build time so non-1000 host UIDs get the post-create.sh
chown fix instead of EACCES.
This commit is contained in:
Gergo Magyar 2026-05-28 13:38:50 +01:00
parent 35e61a9e66
commit cd8ac5f6d1
5 changed files with 181 additions and 118 deletions

View file

@ -5,23 +5,27 @@
# `node` user (UID 1000), zsh + Oh My Zsh, eslint global, `gh` CLI.
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# Build args drive both the image build and lifecycle-time references. The
# matching ENV declarations below promote each ARG into the container's
# runtime environment so postCreateCommand and shells can resolve them
# (Docker ARG values are build-only by default).
ARG CLAUDE_CODE_VERSION=2.1.153
ARG CODEX_VERSION=0.134.0
ARG CURSOR_VERSION=latest
# Build args. Version defaults are NOT set here — devcontainer.json
# `build.args` is the single source of truth. Standalone `docker build
# .devcontainer/` (e.g., CI smoke) must pass each version via --build-arg
# or the build will fail loudly rather than silently drift from the
# devcontainer-canonical pin.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
ARG CURSOR_VERSION
ARG TZ=UTC
ARG USERNAME=node
# Promote build-only ARGs into runtime ENV so shells and lifecycle scripts
# can read them. CLAUDE_CONFIG_DIR is intentionally NOT set here — the
# canonical value lives in devcontainer.json `containerEnv` (single source
# of truth; runtime-time wins anyway).
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
TZ=${TZ} \
DEVCONTAINER=true \
NODE_OPTIONS=--max-old-space-size=4096 \
CLAUDE_CONFIG_DIR=/home/${USERNAME}/.claude \
POWERLEVEL9K_DISABLE_GITSTATUS=true
# Native build toolchain required by gitnexus/postinstall: tree-sitter
@ -33,41 +37,44 @@ RUN apt-get update \
python3 make g++ git curl ca-certificates bash \
&& rm -rf /var/lib/apt/lists/*
# Pre-create credential and history mount points owned by `node` BEFORE the
# devcontainer.json named volumes attach. Docker copies image-side ownership
# onto an empty named volume on first mount, so first-run `claude login`,
# `codex login --device-auth`, and `cursor-agent login` write into a
# node-owned directory instead of EACCES'ing on a root-owned volume.
# Pre-create + chown the named-volume mount points (~/.npm, ~/.local,
# /commandhistory) so empty volumes inherit `node:node` ownership on first
# mount. The three CLI config dirs (~/.claude, ~/.codex, ~/.cursor) are
# bind-mounted from the host — bind mounts fully shadow image-side
# ownership, so no chown is needed for those paths here.
RUN mkdir -p \
/home/${USERNAME}/.claude \
/home/${USERNAME}/.codex \
/home/${USERNAME}/.cursor \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local/bin \
/commandhistory \
&& chown -R ${USERNAME}:${USERNAME} \
/home/${USERNAME}/.claude \
/home/${USERNAME}/.codex \
/home/${USERNAME}/.cursor \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local \
/commandhistory
USER ${USERNAME}
# Install Codex CLI globally as the `node` user. The base image configures
# /usr/local/share/npm-global as the npm-global prefix with the `npm` group
# writable by `node`, so this works without sudo.
RUN npm install -g @openai/codex@${CODEX_VERSION}
# Install Claude Code and Codex CLI globally as the `node` user. The base
# image configures /usr/local/share/npm-global as the npm-global prefix
# with the `npm` group writable by `node`, so `npm install -g` works
# without sudo. Both versions are pinned via build args — bump in
# devcontainer.json and rebuild to upgrade.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Cursor CLI install. The official installer (cursor.com/install) does not
# expose a clean version-pinning flag; CURSOR_VERSION is carried for
# traceability only and the installer always pulls the latest at build time.
# To bump Cursor: rebuild the image. Auto-update in the running container is
# suppressed by not invoking `cursor-agent update`.
RUN curl -fsS https://cursor.com/install | bash
# Cursor CLI install. The official cursor.com/install script does not
# expose version pinning or a checksum, so we download to a temp file,
# log the sha256 to build output (so drift across rebuilds shows up in
# CI logs), then execute. Trust assumption: cursor.com's TLS chain is
# reliable. Long-term hardening (tracked as a follow-up): pin a specific
# downloads.cursor.com/lab/<version>/<arch>/agent-cli-package.tar.gz URL
# with a hard sha256 verification and skip the install script entirely.
RUN curl -fsS --retry 3 --max-time 60 -o /tmp/cursor-install.sh https://cursor.com/install \
&& echo "Cursor installer sha256:" \
&& sha256sum /tmp/cursor-install.sh \
&& bash /tmp/cursor-install.sh \
&& rm -f /tmp/cursor-install.sh
# Ensure ~/.local/bin (where Cursor's installer drops `agent` and
# `cursor-agent` symlinks) is on PATH for interactive shells and lifecycle
# scripts.
# ~/.local/bin (where Cursor's installer drops `agent` and `cursor-agent`
# symlinks) on PATH for interactive shells and lifecycle scripts.
ENV PATH=/home/${USERNAME}/.local/bin:${PATH}

View file

@ -1,6 +1,6 @@
# GitNexus Devcontainer
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, and Cursor CLI alongside the GitNexus native build chain. Designed for Windows 11 (Docker Desktop + WSL2 backend) as the primary host with first-class support for macOS and Linux.
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, and Cursor CLI alongside the GitNexus native build chain. Supported hosts: **macOS, Linux, and Windows 11 via WSL2** (Windows-native is unsupported — see below).
## Quick start
@ -10,9 +10,9 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
4. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
5. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
## Windows 11 (primary host) — WSL2 setup
## Windows 11 — WSL2 is required
**Clone the repo inside WSL2, not on the Windows side.** Bind-mounting a Windows-side path (`C:\…`) through Docker Desktop's WSL2 backend works but suffers from poor IO and unreliable file watchers (Vite/jest `--watch` will miss changes). The fix is to clone into the WSL2 filesystem.
**Windows-native is unsupported.** The devcontainer bind-mounts host config dirs via `${localEnv:HOME}/.claude` (and `.codex`, `.cursor`, `.gitconfig`, `.config/gh`). On Windows-native, the host has `USERPROFILE` set but no `HOME` — VS Code resolves the missing `HOME` to an empty string and Docker tries to bind-mount paths from filesystem root, which silently breaks the host-sync feature. The same checkout-from-Windows-side path also has poor IO and unreliable file watchers (Vite/jest `--watch` will miss changes). The fix is to clone and open the repo inside WSL2:
```bash
# 1. Install WSL2 and a Linux distro if you haven't already.
@ -27,13 +27,12 @@ git clone https://github.com/abhigyanpatwari/GitNexus.git
cd GitNexus
# 4. Launch VS Code from inside WSL — this opens VS Code attached to the WSL2
# filesystem, so subsequent "Reopen in Container" uses the WSL2-side path.
# filesystem, so `${localEnv:HOME}` resolves to the WSL user's home and
# subsequent "Reopen in Container" uses the WSL2-side path.
code .
```
Then run **Dev Containers: Reopen in Container**. The workspace will be bind-mounted from `\\wsl$\Ubuntu\home\<user>\GitNexus`, which is fast and gives reliable file-system events.
**Make sure Docker Desktop's WSL integration is enabled** for your distro: Docker Desktop → Settings → Resources → WSL Integration → toggle on the distro you cloned into.
Then run **Dev Containers: Reopen in Container**. The workspace will be bind-mounted from `\\wsl$\Ubuntu\home\<user>\GitNexus`, which is fast and gives reliable file-system events. **Make sure Docker Desktop's WSL integration is enabled** for your distro: Docker Desktop → Settings → Resources → WSL Integration → toggle on the distro you cloned into.
## macOS
@ -45,15 +44,38 @@ Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID:
## How CLI state is shared with your host
`~/.claude`, `~/.codex`, and `~/.cursor` inside the container are **bind-mounted directly from your host's `$HOME`**. That means:
The following directories inside the container are **bind-mounted directly from your host's `$HOME`**:
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`), you're already logged in inside the container. No second login step.
| Container path | Host source | Mode |
|---|---|---|
| `~/.claude` | `$HOME/.claude` | read-write |
| `~/.codex` | `$HOME/.codex` | read-write |
| `~/.cursor` | `$HOME/.cursor` | read-write |
| `~/.gitconfig` | `$HOME/.gitconfig` | **read-only** |
| `~/.config/gh` | `$HOME/.config/gh` | read-write |
That means:
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`, `gh auth login`), you're already logged in inside the container. No second login step.
- **Plugins, skills, agents, memory, and settings sync both ways.** Install a plugin from inside the container and it shows up on the host; add a custom agent on the host and the container sees it immediately. The auto-memory store at `~/.claude/projects/<workspace>/memory/` is the same file tree from both sides.
- **No per-workspace duplication.** All your devcontainers across all your projects see the same `.claude`/`.codex`/`.cursor` content, just like all your host shells do.
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email`. The mount is read-only so container-side `git config --global` doesn't leak to your host config — set those values from the host shell.
- **`gh` auth is shared.** `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating.
- **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do.
The bind mount source paths are guaranteed to exist by `.devcontainer/ensure-host-config-dirs.cjs`, which `initializeCommand` runs on the host before the container is created.
The bind mount source directories are guaranteed to exist by the `initializeCommand` (`mkdir -p $HOME/.claude $HOME/.codex $HOME/.cursor $HOME/.config/gh`), which runs on the host shell before container create.
For a high-trust enterprise environment where you don't want container code to be able to touch host credentials, replace the three `type=bind` entries for `.claude`/`.codex`/`.cursor` in `.devcontainer/devcontainer.json` with `type=volume` named volumes (Anthropic's reference pattern). Most personal-dev setups don't need that isolation — host and container share the same trust boundary.
### Trust boundary, concretely
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete: any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container with these bind mounts active, has direct read access to your OAuth refresh tokens for all three CLIs, your `gh` token, and `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill). The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would also have unrestricted network to exfiltrate.
**If a workspace dep is ever found compromised**, rotate credentials at the vendor side — local file deletion is insufficient because tokens may have already left:
- Anthropic: [console.anthropic.com → Settings → Keys](https://console.anthropic.com/settings/keys), revoke the OAuth session under Account
- OpenAI / Codex: [platform.openai.com/api-keys](https://platform.openai.com/api-keys), revoke session under Profile
- Cursor: dashboard → Integrations, rotate API key + revoke CLI session
- GitHub: `gh auth refresh` or revoke the token at github.com/settings/tokens
For high-trust enterprise environments where host and container should NOT share credentials, swap the three CLI bind mounts (`~/.claude`, `~/.codex`, `~/.cursor`) in `.devcontainer/devcontainer.json` for `type=volume` named volumes (Anthropic's reference pattern). You give up host plugin/skill/memory sync in exchange for credential isolation per devcontainer.
## First-time CLI authentication
@ -144,15 +166,12 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
## Bumping CLI versions
Three build args control pinned versions:
- `CLAUDE_CODE_VERSION` — informational. Anthropic's official Feature (`ghcr.io/anthropics/devcontainer-features/claude-code:1`) installs the latest stable at build time; rebuild to pick up a newer Claude Code. `DISABLE_AUTOUPDATER=1` keeps it locked between rebuilds.
- `CODEX_VERSION` — pinned in `.devcontainer/devcontainer.json` `build.args` and consumed by `npm install -g @openai/codex@${CODEX_VERSION}`. Bump the value and rebuild.
- `CURSOR_VERSION` — informational only. The Cursor installer (`cursor.com/install`) does not expose version pinning; it always pulls latest at build time. To bump, rebuild the container; auto-update inside the running container is suppressed by not invoking `cursor-agent update`.
Bump `CLAUDE_CODE_VERSION` and `CODEX_VERSION` in `.devcontainer/devcontainer.json` `build.args` and rebuild — both are real pins (Claude Code via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, Codex via `npm install -g @openai/codex@${CODEX_VERSION}`). `CURSOR_VERSION` is informational only because Cursor's official installer (`cursor.com/install`) doesn't expose version pinning; rebuild to pick up whatever the installer serves. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
## What's not included (yet)
- **Egress firewall.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Track at the project's issue tracker if you need this.
- **Hard-pinned Cursor CLI.** Today the Dockerfile downloads `cursor.com/install` to a temp file, logs the sha256 to the build output (so drift across rebuilds is visible in CI logs), then executes — trust assumption: cursor.com's TLS chain is reliable. The full pin (download a specific `downloads.cursor.com/lab/<version>/<arch>/agent-cli-package.tar.gz` with a verified sha256, skip the install script entirely) is tracked as a follow-up because it requires per-arch handling and a SHA bump per Cursor release.
- **Codespaces tuning.** The current config works in Codespaces incidentally (no privileged capabilities, no host-mount assumptions), but isn't actively tested there.
- **Playwright e2e support.** `gitnexus-web`'s `npm run test:e2e` needs Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
@ -160,10 +179,12 @@ Three build args control pinned versions:
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Windows-side bind-mount permission translation got out of sync after a UID change between rebuilds | On the host, ensure your user owns the directory tree; if it's truly stuck, move the affected dir aside and let the CLI rebuild it (`mv ~/.claude ~/.claude.bak` and log in again). Long-term: clone in WSL2 — bind-mount permission classes don't apply to WSL-side filesystems |
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run; Docker Desktop's Windows bind mount won't let the new container's `node` user overwrite it. `postCreateCommand` already runs `rm -rf .husky/_` defensively, but if you hit it on an older config, delete `.husky/_/` on the host (`rm -rf .husky/_`) and rebuild | Long-term: clone the repo inside WSL2 (see [Windows 11 WSL2 setup](#windows-11-primary-host--wsl2-setup)) — WSL-side filesystems don't have this bind-mount class of issue |
| Vite never hot-reloads on Windows | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 (see [WSL2 setup](#windows-11-primary-host--wsl2-setup)) |
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Windows-side bind-mount permission translation got out of sync after a UID change between rebuilds | Move to WSL2 — Windows-native isn't supported. See [Windows 11 — WSL2 is required](#windows-11--wsl2-is-required) |
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run on a Windows-side bind mount | `post-create.sh` already runs `rm -rf .husky/_` defensively. If you hit this on an older config, delete `.husky/_/` on the host and rebuild. Long-term: clone in WSL2 |
| Vite never hot-reloads | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 |
| `gitnexus-web` can't reach the backend | `4747` was remapped or backend isn't running | Verify the Ports panel shows `4747` forwarded with no remap; start the backend with `cd gitnexus && npx gitnexus serve` |
| `npm install` fails on tree-sitter-swift / proto / dart | Native build toolchain missing | This shouldn't happen in the devcontainer — verify the apt layer installed `python3 make g++`. If iterating, set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` to skip the vendored grammars |
| Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer |
| API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence |
| `git commit` produces commits with empty author | `~/.gitconfig` source path missing on the host | Set `git config --global user.name` / `user.email` from the host shell, then rebuild. The bind mount is read-only so the values come from the host |
| `gh: not logged in` inside the container | `~/.config/gh/` source path missing on the host | Run `gh auth login` from the host shell (or inside the container once); the auth file lands in the shared mount |

View file

@ -1,7 +1,8 @@
// Devcontainer for GitNexus. Pre-installs Claude Code, OpenAI Codex CLI,
// and Cursor CLI alongside the Node.js native build chain. Cross-platform
// (Win11+WSL2 + macOS + Linux), opened via the VS Code Dev Containers
// extension.
// across macOS, Linux, and Windows-via-WSL2 (Windows-native is unsupported
// — see .devcontainer/README.md § Windows 11 setup). Opens via the VS Code
// Dev Containers extension.
//
// First-time setup, auth flows, and troubleshooting: .devcontainer/README.md.
{
@ -18,17 +19,14 @@
}
},
// Runs on the HOST before the container is created. Ensures the bind
// mount sources (~/.claude, ~/.codex, ~/.cursor) exist so Docker doesn't
// reject the mount when a CLI has never been used on this host. Safe
// re-run; idempotent. See ensure-host-config-dirs.cjs.
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
// Runs on the HOST shell before the container is created. Guarantees the
// bind-mount source directories below exist so Docker doesn't reject the
// mount when a CLI has never been used on this host. Idempotent; safe to
// re-run. POSIX-shell only (Linux / macOS / WSL2) — Windows-native is
// out of scope per README's WSL2-required posture.
"initializeCommand": "mkdir -p $HOME/.claude $HOME/.codex $HOME/.cursor $HOME/.config/gh && touch $HOME/.gitconfig",
// Anthropic's official Claude Code Feature pulls the latest stable at
// build time; DISABLE_AUTOUPDATER below locks it inside the running
// container so rebuild is the only way the version changes.
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
@ -38,35 +36,45 @@
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
// CLI config dirs are bind-mounted from the host so the developer's
// existing plugins, skills, agents, memory, settings, and credentials
// for Claude Code / Codex / Cursor are immediately available inside the
// container, and changes inside the container flow back to the host.
// The `initializeCommand` above guarantees these source paths exist on
// first up so Docker never errors out on a missing bind source. Anthropic
// recommends per-devcontainer named volumes instead in enterprise /
// high-trust environments; for personal dev where the host + container
// share the same trust boundary, bind mounts give the better daily-driver
// experience.
// Mount topology, by group:
//
// Shell history, npm cache, and node_modules stay in named volumes
// scoped per-workspace — history doesn't need to escape the workspace,
// node_modules belong off the bind mount for Win/Mac perf, and the npm
// cache wants the container-native FS.
// 1. CLI config dirs — bind-mounted from the host so the developer's
// existing plugins, skills, agents, memory, settings, and credentials
// for Claude Code / Codex / Cursor + git identity + gh auth are
// immediately available inside the container, and changes inside the
// container flow back to the host. ~/.gitconfig is read-only so
// container-side `git config --global` doesn't leak to host config.
// For high-trust environments where host and container should NOT
// share credentials, swap these three CLI bind mounts for
// per-devcontainer named volumes (Anthropic's reference pattern).
//
// 2. Per-instance state — `${devcontainerId}` scoped: history and npm
// cache survive container rebuilds but stay isolated between
// sibling devcontainer instances.
//
// 3. Per-workspace-name AND per-instance state — workspace `node_modules`
// volumes use both `${localWorkspaceFolderBasename}` (debuggable in
// `docker volume ls`) and `${devcontainerId}` (collision-free between
// sibling instances of the same repo, e.g., ~/work/GitNexus vs
// ~/projects/GitNexus). Keeps tree-sitter native binaries and
// onnxruntime off the workspace bind mount (the real Win/Mac perf win).
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind",
"source=${localEnv:HOME}/.codex,target=/home/node/.codex,type=bind",
"source=${localEnv:HOME}/.cursor,target=/home/node/.cursor,type=bind",
"source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly",
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
"source=${localWorkspaceFolderBasename}-root-node-modules,target=/workspace/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules,target=/workspace/gitnexus/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules,target=/workspace/gitnexus-web/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules,target=/workspace/gitnexus-shared/node_modules,type=volume"
"source=${localWorkspaceFolderBasename}-root-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules-${devcontainerId},target=/workspace/gitnexus/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules-${devcontainerId},target=/workspace/gitnexus-web/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
],
// Interactive login is the default auth path for all three CLIs;
// credentials persist in the per-devcontainer named volumes mounted above.
// credentials persist in the host-bind-mounted directories (~/.claude,
// ~/.codex, ~/.cursor) declared in the mounts block above.
// API keys (ANTHROPIC_API_KEY / OPENAI_API_KEY / CURSOR_API_KEY) are NOT
// injected via containerEnv — `${localEnv:VAR}` resolves an unset host var
// to an empty string, and Cursor in particular treats `CURSOR_API_KEY=""`
@ -123,15 +131,9 @@
}
},
// Sequential setup: chown the workspace-side node_modules volumes (Docker
// creates them root-owned), drop any stale `.husky/_` runtime cache (it
// can be left over from prior runs and Docker Desktop's Windows bind-mount
// permission translation refuses to let the new container's `node` user
// overwrite a file the previous host UID created — husky's `prepare`
// copyfile then EPERMs on `.husky/_/h`), and install in dependency order:
// root (husky) → gitnexus-shared (install + build, consumed via file:..)
// → gitnexus-web (must install BEFORE gitnexus, because gitnexus's
// `prepare` script runs scripts/build.js which compiles gitnexus-web)
// → gitnexus (last; its prepare hook needs gitnexus-web's node_modules).
"postCreateCommand": "sudo chown -R node:node /workspace/node_modules /workspace/gitnexus/node_modules /workspace/gitnexus-web/node_modules /workspace/gitnexus-shared/node_modules && cd /workspace && rm -rf .husky/_ && npm install && cd /workspace/gitnexus-shared && npm install && npm run build && cd /workspace/gitnexus-web && npm install && cd /workspace/gitnexus && npm install"
// Driver script with labeled steps lives at .devcontainer/post-create.sh
// so each step's success/failure is visible in the log without parsing
// an &&-chain. Run via `bash` explicitly so the script doesn't depend
// on its executable bit surviving the workspace bind mount.
"postCreateCommand": "bash .devcontainer/post-create.sh"
}

View file

@ -1,25 +0,0 @@
// Runs on the HOST (not inside the container) before the dev container is
// created, as the devcontainer.json `initializeCommand`. Ensures the host
// has empty config directories for Claude Code, Codex CLI, and Cursor CLI
// at the user's home directory so the bind mounts in devcontainer.json
// always have a real source path (Docker fails the bind mount if the
// source doesn't exist).
//
// Cross-platform via Node's `os.homedir()` and `fs.mkdirSync({recursive:
// true})`. Node is already required on the host because the project's
// Claude Code, the @devcontainers/cli reentry, and most repo scripts
// depend on it.
//
// Safe to re-run: `recursive: true` is a no-op when the directory exists.
//
// No third-party dependencies; CommonJS so it runs on any Node ≥ 12
// without ESM gymnastics.
const fs = require("fs");
const os = require("os");
const path = require("path");
for (const dir of [".claude", ".codex", ".cursor"]) {
const target = path.join(os.homedir(), dir);
fs.mkdirSync(target, { recursive: true });
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Devcontainer postCreate driver. Runs once after the container is created
# (per devcontainer.json `postCreateCommand`). Each labeled step is its own
# command, so a failure log line names the step that failed instead of an
# opaque `&&`-chain index.
set -euo pipefail
cd /workspace
echo "[post-create] 1/6: chown workspace node_modules + named-volume mount points"
# `updateRemoteUserUID: true` realigns the `node` user's UID/GID at runtime
# on Linux hosts (no-op on Mac/Windows where Docker Desktop translates UIDs
# via its VM layer). The Dockerfile chown at build time targets the original
# UID; empty named volumes created at first mount inherit that ownership and
# end up owned by the stale UID after realignment. Re-chown here, post-
# realignment, so npm install can write to ~/.npm and zsh history writes to
# /commandhistory succeed on hosts with non-1000 UIDs.
sudo chown -R node:node \
/workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm \
/home/node/.local \
/commandhistory
echo "[post-create] 2/6: clear stale .husky/_ runtime cache"
# Docker Desktop's Windows bind-mount permission translation refuses to let
# the new container's `node` user overwrite a `.husky/_/h` left by a prior
# container with a different effective UID. `.husky/_` is gitignored runtime
# cache; husky regenerates it during npm install.
rm -rf .husky/_
echo "[post-create] 3/6: npm install at root (husky + lint-staged + prettier + eslint)"
npm install
echo "[post-create] 4/6: npm install + build gitnexus-shared"
# gitnexus and gitnexus-web both consume gitnexus-shared via
# file:../gitnexus-shared, so it must be built before either installs.
cd /workspace/gitnexus-shared
npm install
npm run build
echo "[post-create] 5/6: npm install gitnexus-web"
# Must install BEFORE gitnexus: gitnexus's `prepare` script runs
# scripts/build.js, which compiles gitnexus-web when the directory is
# present. In the devcontainer the full workspace is bind-mounted, so
# gitnexus-web/ is present at gitnexus install time even though it
# wouldn't be in the production Dockerfiles (which COPY selectively).
cd /workspace/gitnexus-web
npm install
echo "[post-create] 6/6: npm install gitnexus (triggers prepare -> scripts/build.js)"
cd /workspace/gitnexus
npm install
echo "[post-create] done"