// 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, Windows-via-WSL2, and Windows-native (the latter // needs a one-time HOME setup performed automatically by // initializeCommand — 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", "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 — TWO roles. (a) A read-only stage at /host/. // that `post-create.sh` COPIES credentials + identity + single config // files from on container-create (read-only so the credential snapshot // can't be written back to host). (b) Direct READ-WRITE bind mounts of // the shareable subdirs (Claude plugins/skills/agents/memory/commands; // Codex plugins/prompts/memories/skills; Cursor plugins/rules/commands/ // agents/skills) overlaid onto the named volume at their sub-paths. // Those RW binds are BIDIRECTIONAL: installing a plugin in the container // writes straight to the host, and vice versa. This is a deliberate // write-through trade-off — a compromised npm dep CAN drop files into // your host plugin/agent/skill dirs that the next host session loads. // See README § "Trust boundary, concretely". Credentials never join // this surface — they stay in the volume (role 2). // // 2. AI CLI container config — per-devcontainer named volumes. CODEX_HOME // points here; CLAUDE_CONFIG_DIR is intentionally unset so it resolves // to the default ~/.claude (same path). Credentials + identity + // single config files (.credentials.json, ~/.claude/.claude.json, // settings.json, config.toml, cli-config.json, mcp.json) live here with // proper Linux perms and are NOT bind-mounted (single-file binds trip // EXDEV on Docker Desktop Windows). Container-managed state (sessions, // history, caches, IDE locks) stays isolated per devcontainer, so two // GitNexus checkouts on the same host don't corrupt each other. // // 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": [ // Per-container named volumes for credentials + identity state. Each // CLI's actual `~/.` config dir lives in a volume so credentials // (with proper Linux 600 perms) and per-container session state stay // isolated from the host. Login in container vs login on host = // independent. Bind mounts BELOW these volumes override the volume's // contents at the bound sub-paths — Docker mount precedence: more // specific path wins. "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", // Read-only host stage for post-create.sh to copy credentials + // identity from on container-create. Kept read-only so a container // process can't write back to host CLI state files (the write-through // attack vector). Only the credential/identity files are READ here; // shareable content is bind-mounted directly RW below, not staged. "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", // Direct RW bind mounts for shareable subdirs + files. These OVERLAY // the named volume at their target paths, so reads/writes from inside // the container go straight to host. `/plugin marketplace add` in // container = installed on host. New skill on host = visible in // container next read. Trade-off accepted: a compromised npm dep can // write into host plugin/skill/agent/memory/command dirs. // Plugins are split: the SOURCE (marketplaces/ git clones + cache/ // extracted plugin files) is path-independent and bind-mounted // bidirectionally. The REGISTRY (known_marketplaces.json, // installed_plugins.json, plugin-catalog-cache.json) carries absolute // OS-specific paths (`C:\Users\X\.claude\plugins\...` on Windows, // `/Users/x/...` on macOS) so it CANNOT be shared as-is — stays in // the named volume, generated by post-create.sh from host's versions // with paths translated to the container's Linux paths. // DIRECTORY binds — bidirectional, atomic writes inside the dir work // fine (same filesystem). Sub-path writes from container go to host // immediately; host changes are visible to container immediately. // // Claude: plugins SOURCE dirs (marketplaces/ git clones + cache/ // extracted files) are path-independent; the path-bearing registry // JSONs at plugins/ root stay in the volume and are translated by // post-create.sh (see SINGLE-FILE note below). "source=${localEnv:HOME}/.claude/plugins/marketplaces,target=/home/node/.claude/plugins/marketplaces,type=bind", "source=${localEnv:HOME}/.claude/plugins/cache,target=/home/node/.claude/plugins/cache,type=bind", "source=${localEnv:HOME}/.claude/skills,target=/home/node/.claude/skills,type=bind", "source=${localEnv:HOME}/.claude/agents,target=/home/node/.claude/agents,type=bind", "source=${localEnv:HOME}/.claude/memory,target=/home/node/.claude/memory,type=bind", "source=${localEnv:HOME}/.claude/commands,target=/home/node/.claude/commands,type=bind", // // Codex: bind the WHOLE plugins/ dir (parent of cache/). `codex plugin // add` stages installs INSIDE plugins/cache// and renames // intra-dir (verified by strace), so under a single 9p bind of plugins/ // the rename stays intra-9p — no EXDEV. Unlike Claude, Codex has NO // path-bearing registry file under plugins/ (enablement lives in // config.toml at the .codex root as git URLs, not FS paths), so no // translation is needed and the whole dir can be bound. `.tmp/` (the // ext4 marketplace-clone staging area) is deliberately NOT bound — it // must stay on the volume as the cross-fs source side. "source=${localEnv:HOME}/.codex/plugins,target=/home/node/.codex/plugins,type=bind", "source=${localEnv:HOME}/.codex/prompts,target=/home/node/.codex/prompts,type=bind", "source=${localEnv:HOME}/.codex/memories,target=/home/node/.codex/memories,type=bind", "source=${localEnv:HOME}/.codex/skills,target=/home/node/.codex/skills,type=bind", // // Cursor: cursor-agent (CLI, not just the IDE) shares the Cursor 2.5 // plugin/rules/commands/agents/skills surface on disk. Bind the SOURCE // dirs (path-independent). plugins/installed_plugins.json carries // absolute Windows paths like Claude's registry, so it stays in the // volume and is translated by post-create.sh — that's why plugins/ // sub-dirs are bound individually rather than the whole plugins/ dir. // mcp.json + hooks.json are single files (EXDEV-unsafe as binds) and are // handled as copy-on-create. hooks.json is additionally NOT synced at // all: Cursor hooks run shell commands on a timer/event with no user // action, so a poisoned host hooks.json would auto-execute in the // container. NOTE this is a partial mitigation, not a clean boundary — // the RW-bound commands/agents/skills/rules dirs (here and for Claude) // are also instruction/shell-executing surfaces a compromised dep can // write through to host. The hooks.json exclusion just removes the one // surface that fires WITHOUT an agent deciding to invoke it; the broader // write-through trade-off is accepted + documented in README § Trust // boundary. To fully close it, switch these dirs to copy-on-create too. "source=${localEnv:HOME}/.cursor/plugins/marketplaces,target=/home/node/.cursor/plugins/marketplaces,type=bind", "source=${localEnv:HOME}/.cursor/plugins/local,target=/home/node/.cursor/plugins/local,type=bind", "source=${localEnv:HOME}/.cursor/rules,target=/home/node/.cursor/rules,type=bind", "source=${localEnv:HOME}/.cursor/commands,target=/home/node/.cursor/commands,type=bind", "source=${localEnv:HOME}/.cursor/agents,target=/home/node/.cursor/agents,type=bind", "source=${localEnv:HOME}/.cursor/skills,target=/home/node/.cursor/skills,type=bind", // // SINGLE-FILE binds for settings.json / .claude.json / config.toml // are deliberately ABSENT. On Docker Desktop Windows the named-volume // is ext4 (/dev/sdd) and a single-file bind from host is 9p drvfs — // different filesystems. Atomic config writes (write `foo.tmp` -> // rename onto `foo`) trip EXDEV and fail with `Device or resource // busy` / `inter-device move failed`. Codex's TUI hits this as // "config/batchWrite failed in TUI"; Claude silently loses writes // through the same mechanism. Instead: host stage at /host/.claude // (RO) + post-create.sh copies these files into the named volume on // every container-create. Host changes propagate on rebuild; // container changes stay container-local until rebuild. "source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly", "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 live in the per-container named volumes (claude-config / // codex-config / cursor-config), NOT in the host bind mounts — they are // copied from the read-only /host/. stage into the volume on // container-create (single-file binds would trip EXDEV on Docker Desktop // Windows). Only shareable content (plugins, skills, agents, memory, // commands) is RW-bound to the host ~/.claude / ~/.codex / ~/.cursor // dirs 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). // CLAUDE_CONFIG_DIR is intentionally NOT set. The Claude default is // `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the // claude-config named volume mounts — setting the env var explicitly // changes which file Claude reads `hasCompletedOnboarding` from: with // the var set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the // small identity file); without it, Claude reads `$HOME/.claude.json` // (the big onboarding-state file that actually carries // `hasCompletedOnboarding`, MCP user-scope config, and per-project // trust). Leaving the var unset matches host behavior and lets // post-create.sh's host-sync of `$HOME/.claude.json` skip the wizard // on every container-create. // // CODEX_HOME is kept (even though it matches the Codex default) as a // canary: if we ever move the Codex named volume target, the env var // makes the dependency explicit instead of silently following the // default. "containerEnv": { "CODEX_HOME": "/home/node/.codex", "DISABLE_AUTOUPDATER": "1", // post-create.sh strips `installMethod` from the seeded ~/.claude.json so // the npm-global binary auto-detects its own install method. This is // belt-and-suspenders for Claude Code issue #17289: the install-checks // routine probes ~/.local/bin/claude purely because the directory EXISTS // (it does here — Cursor drops agent/cursor-agent symlinks there) even // when installMethod is non-native, surfacing a false "claude command not // found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT gate that // routine — DISABLE_INSTALLATION_CHECKS is its dedicated kill switch. "DISABLE_INSTALLATION_CHECKS": "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" } }, // Lifecycle split (per Dev Container spec): // - `updateContentCommand` runs on container-create AND whenever // workspace content changes (e.g. lockfile updates). It owns // workspace dependency installation — re-installing on every // container-create wastes time when nothing changed, but it must // re-run when deps shift. // - `postCreateCommand` runs once on container-create. It owns AI CLI // credential + identity sync from the host — work that should // happen exactly once per container instance, not on every content // update. // Run both via explicit `bash` so they don't depend on the script's // executable bit surviving the workspace bind mount. "updateContentCommand": "bash .devcontainer/install-deps.sh", "postCreateCommand": "bash .devcontainer/post-create.sh" }