From cd8ac5f6d19bb264d4ba595dcc906978b2ca7d67 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 13:38:50 +0100 Subject: [PATCH] =?UTF-8?q?refactor(devcontainer):=20address=20ce-code-rev?= =?UTF-8?q?iew=20findings=20(P0=20+=204=20=C3=97=20P1=20+=208=20=C3=97=20P?= =?UTF-8?q?2=20+=202=20=C3=97=20P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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. --- .devcontainer/Dockerfile | 71 ++++++++++--------- .devcontainer/README.md | 61 ++++++++++------ .devcontainer/devcontainer.json | 84 ++++++++++++----------- .devcontainer/ensure-host-config-dirs.cjs | 25 ------- .devcontainer/post-create.sh | 58 ++++++++++++++++ 5 files changed, 181 insertions(+), 118 deletions(-) delete mode 100644 .devcontainer/ensure-host-config-dirs.cjs create mode 100644 .devcontainer/post-create.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9e50f7092..5efd7955a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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///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} diff --git a/.devcontainer/README.md b/.devcontainer/README.md index bdaa69d79..8753d3388 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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\\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\\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//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//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///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 | diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 55e21ec74..388dfc81e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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" } diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs deleted file mode 100644 index 90cccbdf8..000000000 --- a/.devcontainer/ensure-host-config-dirs.cjs +++ /dev/null @@ -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 }); -} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 000000000..d8e5a2429 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -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"