GitNexus/.devcontainer/devcontainer.json
Gergo Magyar abbcacebca refactor(devcontainer): hybrid AI CLI config — read-only host share + per-container credentials
Restructure the Claude Code / Codex / Cursor mount topology to fix the
silent first-run-UI bug surfaced in PR testing, and to harden against
the host-write-through escape class the previous bind-mount design
exposed.

The actual root cause of the first-run wizard firing on the user's
screenshot — confirmed via three parallel research agents (best
practices, framework docs deep dive of the OpenAI Codex Rust source,
adversarial design review) — was NOT a credential permission check.
Claude Code splits state across `~/.claude/.credentials.json` AND
`~/.claude.json` (a FILE at $HOME, sibling of the `.claude/` dir).
The latter holds `hasCompletedOnboarding`, `userID`, `oauthAccount`
metadata, MCP user-scope config, and per-project trust state — and
Claude Code reads it at literal `$HOME/.claude.json`, not via
`CLAUDE_CONFIG_DIR`. The previous design mounted `~/.claude/` but
left `~/.claude.json` outside the topology entirely, so every container
started with a missing onboarding-state file and re-ran the wizard.

Confirmed by tfvchow/field-notes-public#10:
"Persisting .credentials.json alone is NOT sufficient. Without
.claude.json, Claude Code treats the session as a fresh install and
prompts for login regardless of valid credentials being present."

The new topology:

**Mounts**
- `${localEnv:HOME}/.claude` → `/host/.claude` (read-only bind)
- `${localEnv:HOME}/.codex` → `/host/.codex` (read-only bind)
- `${localEnv:HOME}/.cursor` → `/host/.cursor` (read-only bind)
- `${localEnv:HOME}/.claude.json` → `/host/.claude.json` (read-only bind)
- `claude-config-${devcontainerId}` → `/home/node/.claude` (named volume)
- `codex-config-${devcontainerId}` → `/home/node/.codex` (named volume)
- `cursor-config-${devcontainerId}` → `/home/node/.cursor` (named volume)

**containerEnv** gains `CODEX_HOME=/home/node/.codex` (Codex's own env
override, per its public Rust source). `CLAUDE_CONFIG_DIR=/home/node/
.claude` was already set.

**`post-create.sh`** stages the named volumes on first run:

- Symlinks shareable subdirs from `/host/.claude` into the named volume:
  `plugins/`, `skills/`, `agents/`, `memory/`, `commands/`. Codex gets
  `config.toml` symlinked. Cursor has no shareable subdirs (cli-config
  .json conflates auth and settings).
- Copies `.credentials.json`, `auth.json`, `cli-config.json` on first
  run with `chmod 600`. After first run, container manages its own
  refresh; host's credentials untouched.
- Copies `~/.claude.json` on first run (with stub
  `{"hasCompletedOnboarding":true,"installMethod":"global"}` fallback
  for hosts that haven't run Claude Code). This is the fix for the
  observed onboarding-wizard loop.

`ensure-host-config-dirs.cjs` now also touches `~/.claude.json` on the
host if missing, so the bind mount has a valid source on hosts that
have never run Claude Code.

**Why read-only + named volume vs. the previous full bidirectional
bind mount:**

1. **Host filesystem write-through escape, eliminated.** Previous
   design symlinked `plugins/`, `agents/`, `skills/` write-through
   into the host's `~/.claude/` — a malicious npm package in the
   workspace dep tree could drop `agents/evil.md` into the host's
   config, which the next host Claude session would auto-load. The
   read-only `/host` mount blocks this; container compromise no
   longer persists across teardown via host-side autoload.
2. **Windows bind-mount perm-flattening, sidestepped.** Files
   surfaced through a Docker Desktop Windows bind mount appear as
   `root:root` mode `777`. Credentials in the named volume come with
   proper Linux ownership and `chmod 600` — what each CLI expects on
   write (none enforces on read, but write-side hygiene matters for
   the host's understanding of "where credentials live").
3. **No `ide/` lock-file collisions.** Previous design symlinked
   `~/.claude/ide/` write-through, including per-PID lock files. Host
   PID and container PID namespaces are unrelated → lock-file PIDs
   misclassify dead processes as alive. Skipping `ide/` keeps lock
   files container-local.
4. **No `projects/` ghost dirs.** Host encodes the workspace path as
   `D--development-coding-GitNexus`, container as `-workspace`.
   Bidirectional `projects/` symlinks would split memory and session
   state across two ghost project dirs for what is conceptually the
   same project. Skipping `projects/` keeps per-project state
   container-local; host's projects/ stays untouched.
5. **No `settings.json` version drift.** Container is pinned to a
   specific Claude Code version (`CLAUDE_CODE_VERSION` build arg);
   host floats with auto-update. Bidirectional `settings.json` writes
   produced silent schema rollback. Skipping settings.json keeps each
   side authoritative for its own version.

**README** rewritten in the same section to describe the new topology
honestly: what's shared, what isn't, the OAuth refresh-token
divergence between host and container, per-CLI quirks (macOS Keychain
storage, Cursor's known upstream in-container auth bug, Codex
keyring storage). Trust-boundary section updated to name the threat
model accurately — same read surface as before (malicious dep can
still READ all credentials), but write-through into host plugin/agent
dirs is now blocked.

Verified locally: `@devcontainers/cli read-configuration` resolves all
19 mounts correctly on Windows, `post-create.sh` parses, and
`ensure-host-config-dirs.cjs` idempotently touches `~/.claude.json`.

Research backing this design:

- Anthropic Claude Code devcontainer docs (named-volume pattern):
  https://code.claude.com/docs/en/devcontainer
- tfvchow/field-notes-public#10 (both files required):
  https://github.com/tfvchow/field-notes-public/issues/10
- anthropics/claude-code#29029 (VS Code extension strips
  hasCompletedOnboarding):
  https://github.com/anthropics/claude-code/issues/29029
- OpenAI Codex Rust source (no read-side perm check):
  https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs
- Cursor CLI in-Docker auth issue:
  https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995
2026-05-28 15:31:02 +01:00

163 lines
7.8 KiB
JSON

// Devcontainer for GitNexus. Pre-installs Claude Code, OpenAI Codex CLI,
// and Cursor CLI alongside the Node.js native build chain. Cross-platform
// 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.
{
"name": "GitNexus AI CLI Devcontainer",
"build": {
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"CLAUDE_CODE_VERSION": "2.1.153",
"CODEX_VERSION": "0.134.0",
"CURSOR_VERSION": "latest",
"TZ": "${localEnv:TZ:UTC}"
}
},
// Runs on the HOST (not the container) before container create. The
// single-string form is the spec-canonical shape for cross-platform
// command-property dispatch; the object form is "named parallel tasks",
// not OS dispatch. We use Node so the same command works in cmd.exe on
// Windows and bash/zsh on Linux/macOS/WSL — the script reads `os.homedir()`
// (which respects $HOME on POSIX and %USERPROFILE% on Windows) and creates
// the host-side bind mount sources idempotently. Host prerequisite: Node
// on PATH (the only host-side toolchain dependency beyond Docker Desktop
// and the VS Code Dev Containers extension).
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteUser": "node",
"updateRemoteUserUID": true,
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
// Mount topology, by group:
//
// 1. AI CLI host config — read-only bind at /host/.<cli>. `post-create.sh`
// selectively symlinks shareable subdirs (plugins/skills/agents/memory/
// commands) into the container's named-volume config dirs, and copies
// credentials + `.claude.json` (the onboarding-state file Claude Code
// looks for at $HOME, *outside* ~/.claude/) on first run. Read-only
// eliminates write-through from container to host — a compromised npm
// dep can't drop an `agents/evil.md` into your host ~/.claude/agents/
// that the next host Claude session would auto-load. The host stays
// the source of truth for shared content; install new plugins on the
// host and rebuild the container to pick them up.
//
// 2. AI CLI container config — per-devcontainer named volumes. These are
// what CLAUDE_CONFIG_DIR / CODEX_HOME / ~/.cursor actually point at.
// Credentials live here with proper Linux perms. Container-managed
// state (sessions, history, caches, projects/, settings.json) stays
// isolated per devcontainer, so two GitNexus checkouts on the same
// host don't corrupt each other's chat history or IDE lock files.
//
// 3. Other host config — read-write or read-only bind mounts for things
// that don't have the perm-flattening / onboarding-state complexity
// Claude Code does. `~/.gitconfig` is handled separately by VS Code's
// auto-copy mechanism (not mounted).
//
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
// npm cache. Survive rebuilds, isolated between sibling instances.
//
// 5. 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). Keeps tree-sitter native
// binaries and onnxruntime off the workspace bind mount (Win/Mac perf).
"mounts": [
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly",
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",
"source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind",
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
"source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=npm-cache-${devcontainerId},target=/home/node/.npm,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 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=""`
// as "use this empty key" rather than "fall back to stored login", which
// would silently break `cursor-agent login`. Users who need API key auth
// should `export` the var in their container shell or carry it via their
// VS Code dotfiles repo (see .devcontainer/README.md).
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.eol": "\n",
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": { "path": "bash", "icon": "terminal-bash" },
"zsh": { "path": "zsh" }
}
}
}
},
// 4747 (gitnexus serve) must not be remapped: gitnexus-web hardcodes
// http://localhost:4747 as the default backend URL.
"forwardPorts": [5173, 4747, 4173],
"portsAttributes": {
"5173": {
"label": "Vite dev (gitnexus-web)",
"onAutoForward": "notify"
},
"4747": {
"label": "gitnexus serve HTTP API",
"onAutoForward": "notify",
"requireLocalPort": true
},
"4173": {
"label": "Static web (Vite preview)",
"onAutoForward": "silent"
}
},
// 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"
}