* chore: extend .gitattributes for shell scripts and binary assets
Append explicit `*.sh text eol=lf` and `*.bash text eol=lf` rules so
shell scripts (notably anything COPYed into a Linux container) check out
with LF endings on Windows hosts with `core.autocrlf=true`, regardless
of the auto-detection on the existing `* text=auto eol=lf` line. Add
binary markers for `*.node`, `*.wasm`, `*.onnx`, `*.so`, `*.dll`,
`*.dylib` so native and ML model artifacts aren't ever subjected to text
normalization.
The existing `* text=auto eol=lf` and `.husky/* text eol=lf` rules are
preserved. `git ls-files --eol` confirmed zero CRLF or mixed blobs in
the index, so no `--renormalize` was needed.
* feat(devcontainer): add cross-platform devcontainer for Claude Code, Codex, and Cursor CLIs
Add a Dev Container that pre-installs Claude Code (2.1.153, via Anthropic's
official Feature), OpenAI Codex CLI (pinned 0.134.0), and Cursor CLI alongside
the GitNexus native build chain. Opens via VS Code's Dev Containers extension
on Windows 11 (Docker Desktop + WSL2), macOS, or Linux without OS-specific
branches in devcontainer.json.
Topology and base
- Base image `mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm`
(multi-arch, monthly patched, ships the `node` non-root user, zsh, `gh`).
- Node 22 LTS satisfies `gitnexus/`'s engines `>=22.0.0` and matches the
`node:22-bookworm-slim` SHA-pinned base used by `Dockerfile.cli`.
- Single container with all three CLIs co-installed (vs. docker-compose
per-tool) — prevailing 2026 community pattern, lowest daily-driver friction.
Persistence and auth
- Per-devcontainer named volumes scoped by `${devcontainerId}` for
`/home/node/.claude`, `/home/node/.codex`, `/home/node/.cursor`,
`/commandhistory`, and `/home/node/.npm`. Authentication survives rebuilds
without leaking between workspaces.
- Four sub-workspace `node_modules` volumes (root, gitnexus, gitnexus-web,
gitnexus-shared) keep tree-sitter native bindings and onnxruntime off the
bind mount — the actual Win/Mac perf win.
- Credential mount paths are pre-created in the Dockerfile with
`chown node:node` BEFORE `USER node`, so empty named volumes inherit
correct ownership on first mount and first-run logins don't EACCES.
- `CURSOR_API_KEY` is injected via `containerEnv: ${localEnv:CURSOR_API_KEY}`
(Cursor's documented headless path); falls back to interactive
`cursor-agent login` when the host env var is unset.
Build-arg promotion
- Build args (`CLAUDE_CODE_VERSION`, `CODEX_VERSION`, `CURSOR_VERSION`, `TZ`)
are promoted to ENV in the Dockerfile so lifecycle commands and shells can
resolve them. Without this promotion, Docker ARG values are build-only and
silently no-op at lifecycle time.
Workspace setup
- `postCreateCommand` chowns the four workspace `node_modules` volumes
(Docker creates them root-owned), then installs in dependency order:
root → gitnexus-shared (install + build) → gitnexus → gitnexus-web. The
shared package must build before its consumers (`file:../gitnexus-shared`).
Ports
- 5173 (Vite dev) and 4173 (Vite preview) auto-forwarded.
- 4747 (`gitnexus serve`) marked `requireLocalPort: true` because
`gitnexus-web/src/services/backend-client.ts` hardcodes
`http://localhost:4747` as the default backend URL; a remapped port would
silently break the web UI.
VS Code integration
- Recommended extensions: `anthropic.claude-code`,
`dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`, `eamodio.gitlens`.
- Settings: format-on-save with Prettier, ESLint auto-fix on save, zsh as
default terminal profile, persistent zsh history via `HISTFILE` →
`/commandhistory`.
Documentation
- `.devcontainer/README.md` covers WSL2 setup (clone inside WSL2 for IO and
file-watcher reliability), first-time auth flows for each CLI, port-
forwarding notes, LadybugDB container limitations, and the bumping
procedure for each CLI version.
- `CONTRIBUTING.md` gets a "Containerized development (optional)"
subsection pointing at the devcontainer README.
Deferred to a follow-up PR
- Opt-in egress firewall (originally planned as a fourth implementation
unit). The Dev Containers spec makes `runArgs` static — toggling
`NET_ADMIN`/`NET_RAW` capabilities cleanly requires either a separate
`devcontainer-firewall.json` profile or an `initializeCommand`-generated
overlay. Keeping this PR focused on the working baseline.
- Codespaces-specific tuning (works incidentally when the firewall is off,
not actively tested).
- Inside-container Playwright e2e (needs Chromium libs not in the base
image).
Verification deferred to user
- This change introduces a new dev tooling artifact. Validate by running
`docker build .devcontainer/`, opening the repo in VS Code via
"Dev Containers: Reopen in Container", confirming `claude --version`,
`codex --version`, `cursor-agent --version` resolve inside the container,
and `cd gitnexus && npm run test:unit` runs clean against the
named-volume `node_modules`.
* fix(devcontainer): make interactive login the default auth path for all CLIs
The previous `containerEnv` injected `CURSOR_API_KEY: "${localEnv:CURSOR_API_KEY}"`.
When the host had no `CURSOR_API_KEY` set, this resolved to an empty
string and Docker injected `CURSOR_API_KEY=""` into the container.
Cursor CLI treats a set-but-empty `CURSOR_API_KEY` as "use this key"
rather than "fall back to stored login", which silently broke
`cursor-agent login` on the most common path — users who hadn't
explicitly opted into API key auth.
Drop `CURSOR_API_KEY` from `containerEnv`. Login is now the
unconditional default for all three CLIs (Claude Code, Codex CLI,
Cursor CLI); the named-volume + Dockerfile-chown pattern keeps
credentials persistent across container rebuilds for every login path.
Reorganize the README's auth section to put login first for all three
CLIs uniformly (matching the new behavior) and move API key
authentication into a separate "Alternative" section for CI/headless
use. Document that API keys are intentionally not auto-propagated from
the host and explain the export-in-shell or VS Code dotfiles-repo paths
for users who want them. Update the troubleshooting row to reflect the
new design.
* fix(devcontainer): install gitnexus-web before gitnexus in postCreateCommand
The previous order (root → gitnexus-shared → gitnexus → gitnexus-web)
broke at the `gitnexus` install step because `gitnexus`'s `prepare`
script runs `scripts/build.js`, which compiles `gitnexus-web` whenever
its source tree exists. In the devcontainer the entire workspace is
bind-mounted, so `gitnexus-web/` is present from the start — but its
`node_modules/` wasn't yet, so `tsc -b` failed with:
error TS2688: Cannot find type definition file for 'vite/client'
error TS2688: Cannot find type definition file for 'node'
Reorder so `gitnexus-web` installs before `gitnexus`. Verified
end-to-end via `npx @devcontainers/cli up`: container builds clean,
all three CLIs (Claude 2.1.153, Codex 0.134.0, Cursor) respond, and
`npx tsc --noEmit` inside `/workspace/gitnexus` passes.
Production Dockerfiles (`Dockerfile.cli` etc.) don't hit this because
they only COPY `gitnexus/` + `gitnexus-shared/`, so `gitnexus-web/`
doesn't exist at install time and `scripts/build.js` skips the web
step. The devcontainer's full-tree bind mount changes that calculus.
* fix(devcontainer): clear stale .husky/_ before npm install
When `npm install` runs the root `prepare` script (husky), husky tries
to copyfile `node_modules/husky/husky` → `.husky/_/h`. On Docker Desktop
Windows bind mounts, if `.husky/_/` already exists from a prior
container run, the new container's `node` user can't overwrite it via
the bind mount's permission translation and the install fails with:
Error: EPERM: operation not permitted, copyfile
'/workspace/node_modules/husky/husky' -> '.husky/_/h'
Drop `.husky/_` defensively in `postCreateCommand` before `npm install`
so husky always starts from a clean slate. `.husky/_` is a husky
runtime cache (gitignored), so removing it has no effect on the repo —
husky regenerates it. No-op for WSL2-side checkouts (where this class
of bind-mount permission collision doesn't occur).
Add a troubleshooting row to `.devcontainer/README.md` covering the
manual recovery (`rm -rf .husky/_` on the host) and the long-term fix
(clone in WSL2 — Windows-side bind mounts will keep biting on this
kind of issue across rebuilds with different UID alignment).
* feat(devcontainer): bind-mount host CLI config dirs for plugin/skill/memory sync
Switch the credential/config mounts from per-devcontainer named volumes
to bind mounts of `${localEnv:HOME}/.claude`, `~/.codex`, and
`~/.cursor`. Effect inside the container:
- Authentication is shared with the host. If you've already run
`claude login` / `codex login --device-auth` / `cursor-agent login`
on the host, you're already authenticated in the container.
- Plugins, skills, agents, memory, and settings sync both ways. Install
a plugin in the container, it shows up on the host; add a custom
agent on the host, the container sees it immediately.
- All devcontainers on the host share the same CLI state, mirroring
how host shells already share it. (Per-workspace isolation of plugins
was never a stated requirement; the previous per-devcontainer named
volumes leaked nothing useful.)
Add `.devcontainer/ensure-host-config-dirs.cjs` and wire it as
`initializeCommand`. It runs on the host before container create and
guarantees `~/.claude`, `~/.codex`, `~/.cursor` exist, so Docker doesn't
reject the bind mount when a CLI has never been used on this host.
Cross-platform via Node `os.homedir()` + `fs.mkdirSync({recursive: true})`;
idempotent; no third-party deps.
Update `.devcontainer/README.md`:
- New "How CLI state is shared with your host" section explaining the
bind-mount model up front so users know their host plugins/skills/
memory carry into the container.
- Mark first-time-login section as skippable when the user is already
authenticated on the host.
- Note the high-trust escape hatch: replace the three bind mounts with
`type=volume` named volumes if the host/container trust boundary
needs to be separated (Anthropic's reference pattern for enterprise).
- Replace the obsolete "rm named volume" troubleshooting row with one
that covers EACCES/EPERM on the host-bind-mount path.
* 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.
* fix(devcontainer): cross-platform initializeCommand + soften Windows-native posture
The previous commit's `initializeCommand` was POSIX-only (`mkdir -p $HOME/...`).
VS Code on Windows runs the host shell as `cmd.exe /c ...`, which can't
parse POSIX syntax — `$HOME` doesn't expand, `mkdir -p` errors, the init
fails with `The syntax of the command is incorrect`, and container
creation aborts before Docker is invoked.
Switch `initializeCommand` to the spec's OS-keyed object form:
- linux/darwin (covers WSL2 because VS Code runs initializeCommand in
the WSL shell when attached via the WSL extension): POSIX mkdir+touch,
as before
- win32: PowerShell snippet that creates the same directories under
$USERPROFILE and touches the gitconfig if missing
Soften the README's hard "WSL2 required" framing from the previous
commit. Reality per `@devcontainers/cli read-configuration` output:
`${localEnv:HOME}` on Windows-native resolves to `C:\Users\<name>`
(VS Code falls back to USERPROFILE), so the bind mount sources are
valid Windows paths and Docker Desktop handles the translation. The
earlier `accessing specified distro mount service` failure was a
separate Docker Desktop WSL-integration issue, not a HOME-resolution
issue. Windows-native works; it's just slower with more bind-mount
permission edge cases (the husky/_/h EPERM class). The README now
explains the tradeoff and steers toward WSL2 for performance + file
watchers + permission reliability, rather than blocking Windows-native
checkouts outright.
Update the troubleshooting row to reflect the new posture.
* fix(devcontainer): Node-based initializeCommand; bind-mount .ssh + .config/git
Two fixes bundled:
1. The previous commit's OS-keyed `initializeCommand` object was based
on a misread of the Dev Containers spec. The object form on command
properties is **named parallel tasks**, not OS dispatch — VS Code ran
all three keys in parallel via cmd.exe on Windows, the POSIX branches
failed, and container creation aborted before Docker was invoked.
Restore the single-string Node-based form:
`node .devcontainer/ensure-host-config-dirs.cjs`. Node works
identically in cmd.exe on Windows and bash/zsh on Linux/macOS/WSL,
and `os.homedir()` respects $HOME on POSIX and %USERPROFILE% on
Windows. The script is idempotent (mkdirSync recursive is a no-op
for existing dirs; touch is gated on .gitconfig existence).
Document Node ≥18 on the host as the only host-side prerequisite
beyond Docker Desktop and the VS Code Dev Containers extension.
Anyone running Claude Code on the host already has it.
2. Extend the host-bind mount surface with `~/.ssh` and `~/.config/git`,
both read-only:
- `~/.ssh` lets commit signing + push over SSH remotes work inside
the container without copying private keys. Read-only mount means
container code can read keys but can't modify or delete them.
(Threat: a malicious dep can still read private keys from inside
the container; the read-only mount narrows write-side blast
radius, not read-side. Documented in the trust-boundary section.)
- `~/.config/git` covers XDG-style git config (`~/.config/git/config`,
`~/.config/git/ignore`, `~/.config/git/attributes`) for users who
keep settings there instead of `~/.gitconfig`. Read-only, same as
`~/.gitconfig`.
Update the CLI-state-sharing table and trust-boundary paragraph to
reflect the expanded surface.
Re-adds .devcontainer/ensure-host-config-dirs.cjs (deleted before the
OS-keyed attempt).
* fix(devcontainer): fail-fast on Windows-native with HOME-not-set diagnostic
The previous commit's "Windows-native works" softening was wrong. VS Code
on Windows-native resolves `${localEnv:HOME}` by reading the host shell's
HOME env var, and cmd.exe has no HOME set — the bind sources collapse to
`/.claude`, `/.codex`, etc., and Docker errors:
Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /.claude
The @devcontainers/cli output that prompted the softening was misleading
because I ran it from a Bash session with HOME already set, not from VS
Code's cmd.exe call context. The original Finding-1 P0 — that Windows-
native silently breaks the bind-mount feature — was correct.
Three changes:
1. `ensure-host-config-dirs.cjs` detects the failure mode early:
`if (process.platform === 'win32' && !process.env.HOME)` prints a
targeted error message naming the root cause (cmd.exe has no HOME →
${localEnv:HOME} resolves empty → bind sources fail) and a step-by-step
pointer to set up WSL2. Exits 1 so VS Code surfaces it as a clean
container-creation failure, not the cryptic Docker bind-mount error.
2. README header reverted to "Windows 11 via WSL2" only (not "and
Windows-native"). The "Windows 11 — WSL2 is required" section names
the specific HOME-resolution mismatch concretely so future readers
understand why the constraint exists.
3. Troubleshooting table gets a new row for the `ERROR: GitNexus
devcontainer requires WSL2` message pointing at the setup section.
* feat(devcontainer): support Windows-native via auto setx HOME on first run
Reverses the "WSL2 required on Windows" posture. Windows-native now
works after a one-time auto-handled setup.
The root cause of the bind-mount failure: VS Code resolves
`${localEnv:HOME}` by reading its own process env, and Windows doesn't
set `HOME` by default — Windows uses `USERPROFILE`. So the bind sources
were collapsing to `/.claude`, `/.codex`, etc., and Docker rejected them.
`ensure-host-config-dirs.cjs` now handles this automatically on Windows
hosts where `HOME` is unset:
1. Runs `setx HOME "%USERPROFILE%"`, which writes to the user-level
Windows environment (HKCU\Environment) — no admin required. Every
future user process inherits HOME from there.
2. Prints a clear one-time setup banner explaining the user needs to
fully restart VS Code (File > Exit, not just close the window) for
VS Code to pick up the new env at its next startup.
3. Exits 1 so VS Code surfaces this as a clean container-create failure
instead of letting Docker error opaquely later.
On the second Reopen-in-Container attempt, `HOME` is now set in VS
Code's env, the script skips the setup block, creates the bind-mount
source dirs, and the container builds normally. Subsequent rebuilds
have no extra steps.
Mac, Linux, and WSL2 hosts have `HOME` set by the shell, so the new
block is a no-op there. Same `devcontainer.json` works across all
supported hosts.
README rewritten to reflect the new posture:
- Header lists Windows 11 (native) as a supported host alongside macOS,
Linux, and WSL2, with a note that Windows-native gets a one-time
HOME setup handled by the initializeCommand.
- New "Windows 11 setup" section walks through the auto-handled setup
flow + a manual `setx HOME "%USERPROFILE%"` fallback for users who
want to do it themselves.
- "Known trade-offs of Windows-native vs WSL2" subsection lays out the
Docker Desktop Windows bind-mount edge cases (file watchers, npm
install perf, husky/_ EPERM) so users opting into Windows-native do
so eyes-open. WSL2 remains documented as the faster path for users
who want it, but it's no longer the only supported one.
- Troubleshooting table gets two new rows: the one-time setup banner
(with "what to do" instructions) and the residual `bind source path
does not exist` case (run setx manually + fully exit VS Code).
* fix(devcontainer): drop ~/.gitconfig bind mount; defer to VS Code auto-copy
VS Code's Dev Containers extension auto-copies the host's gitconfig into
the container at attach time using `(dd ...) >> /home/node/.gitconfig`.
A read-only bind mount of ~/.gitconfig blocks that write, so attach
failed with `cannot create /home/node/.gitconfig: Read-only file system`.
Making it read-write would let the append succeed, but the bind mount
means the host file and the container file are the same file — VS Code's
append would double the host gitconfig contents on every container
start.
Drop the ~/.gitconfig bind mount entirely. VS Code's auto-copy is the
purpose-built mechanism for this, gives the container the host's
user.name / user.email transparently, and avoids both the read-only
write failure and the append-duplication trap. The container ends up
with a writable /home/node/.gitconfig that's a copy of the host's, not
a mount.
The remaining six bind mounts (.claude, .codex, .cursor, .ssh, .config/git,
.config/gh) keep their existing modes — XDG-style git config under
~/.config/git is unaffected by VS Code's auto-copy (which only targets
~/.gitconfig), so its read-only bind mount stays.
Also remove the `.gitconfig` touch from ensure-host-config-dirs.cjs
(now unnecessary) and update the README CLI-state table, sharing
explanation, and troubleshooting row to reflect that gitconfig flows
in via VS Code auto-copy rather than the bind mount.
* feat(devcontainer): bind-mount ~/.docker, ~/.aws, ~/.azure for agent workflows
Extend the host bind-mount surface so coding agents inside the container
inherit cloud + container-registry auth from the host without any
per-container setup:
- ~/.docker (read-write) — Docker registry auth (config.json) + buildx
config. Container-registry pushes (ghcr.io, docker.io) from inside the
container pick up host `docker login` state. Read-write because the
Docker CLI refreshes credential-helper tokens.
- ~/.aws (read-only) — AWS CLI / SDK credentials. Read-only because
rotating creds typically happens via the host. Empty on this dev box,
so forward-compatible: the moment you `aws configure` on the host the
container picks it up on the next rebuild.
- ~/.azure (read-only) — Azure CLI credentials. Same pattern as ~/.aws.
`ensure-host-config-dirs.cjs` extends to mkdir these three on init so
the bind mounts always have a valid source even if a CLI has never been
used on this host.
The Docker CLI itself isn't installed in the container by default — the
~/.docker/ mount is inert until you add `docker-outside-of-docker:1` or
similar Feature. README now calls this out under "What you still don't
have inside the container" so it's obvious which CLIs are agent-ready
and which need a feature add to become useful.
README updates:
- Bind-mount table gains a "Why" column and rows for the three new
mounts, making it clear at a glance what each one enables.
- Trust-boundary section lists Docker registry tokens, AWS, and Azure
creds in the read-side exfil path so the threat model stays honest as
the credential surface grows.
- New subsection lists not-included CLIs (Docker, AWS, Azure, gcloud,
kubectl, private-npm) with the exact Feature ID or mount snippet
needed to enable each — turns "I want my agent to do X" into a
one-line config change.
Verified locally: `npx @devcontainers/cli read-configuration` resolves
all 9 host bind mounts to valid C:\Users\<name>/* paths on Windows.
* 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
* fix(devcontainer): resync AI CLI state from host on every container-create
Two bugs were causing Claude Code to fire the onboarding wizard inside the
container even with valid host credentials:
1. Missing the second state file. Claude Code 2.1.x writes a small `.claude.json`
INSIDE `CLAUDE_CONFIG_DIR` (carrying migration tracking + userID), not just the
one at `$HOME/.claude.json`. If the userIDs in the two files disagree, Claude
treats the session as inconsistent and re-onboards. The previous post-create.sh
only copied the `$HOME` one.
2. First-run guards (`[ ! -e $dst ]`) skipped the copy when stale named volumes
from earlier rebuilds still had the prior session's state in them, leaving the
container desynced from the host.
Replace `copy_on_first_run` with `sync_from_host` that always overwrites from
host on container-create. `link_readonly_share` now clears stale non-symlink dst
entries before linking. Copies both `$HOME/.claude.json` and
`$CLAUDE_CONFIG_DIR/.claude.json` so userIDs stay aligned. Container can still
mutate its own state between rebuilds; resync only happens on rebuild
(postCreate boundary).
* docs(devcontainer): document sync-from-host design + dual-source auth flow
README still described the old "first-run copy" behavior. After the
post-create.sh change to always-sync-from-host, the design works either
direction:
- Log in on host → next container-create syncs the credentials into the
named volume.
- Log in inside the container → the named volume persists the login across
rebuilds; the host has no source to overwrite from, so it stays alone.
Also documents the two-Claude-state-files trap (`$HOME/.claude.json` AND
`$CLAUDE_CONFIG_DIR/.claude.json`, both with the same userID required), and
the volume-deletion recovery path for stale named volumes carried over
from earlier rebuilds.
* fix(devcontainer): full plugin/config parity by dropping CLAUDE_CONFIG_DIR + syncing settings.json
Two changes that together give the container the same plugins and configs
as the host for all three AI CLIs (login stays per-container):
1. Drop CLAUDE_CONFIG_DIR from containerEnv. The named-volume mount target
`/home/node/.claude` already matches Claude's default `~/.claude`, so
the env var added no behavior — but setting it changed which file
Claude reads `hasCompletedOnboarding` from. With it set, Claude reads
`$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file that
does NOT carry `hasCompletedOnboarding`); without it, Claude reads
`$HOME/.claude.json` (the big onboarding-state file that does). The
wizard fires every container-create when set, skips when unset.
2. Sync `settings.json` from host (Claude) + symlink `memories/` and
`skills/` from host (Codex). Theme + `enabledPlugins` +
`extraKnownMarketplaces` live in `settings.json` — without syncing
it, the theme picker fires and host-installed plugins stay disabled
even though their files are symlinked in. Codex's `memories/` and
`skills/` are the symmetric Codex user-installed surface, now shared
the same way Claude's plugins/skills/agents/memory/commands are.
Cursor stays as-is — `cli-config.json` conflates auth+settings (already
synced), and there's no separate plugin surface to mirror.
Login details remain per-container by design (acceptable to re-login on
rebuild). Everything else — plugins, skills, agents, memory, MCP user-
scope config, project trust, theme, plugin enablement — now matches
host on every container-create.
* refactor(devcontainer): hybrid RW bind + per-container creds — fixes EROFS on in-container plugin install
The previous Option B topology (RO host stage + named volume + symlinks
into the volume) made `/plugin marketplace add` inside the container fail
with EROFS — the symlinks pointed at a read-only mount, so Claude
couldn't create new marketplace dirs. Switch to a hybrid: shareable
content (plugins/skills/agents/memory/commands/settings.json/$HOME/.claude.json
for Claude; config.toml/memories/skills for Codex) gets a direct RW bind
from host so reads and writes go bidirectionally; credentials + the
small identity file stay in per-container named volumes so logout in
container doesn't log out host.
Mount precedence does the heavy lifting: the named volume mounts at
/home/node/.<cli> first, then sub-path bind mounts overlay specific
sub-paths. Container's view at /home/node/.claude/plugins/ is the host
dir; container's view at /home/node/.claude/.credentials.json is the
named volume's file.
What this gives you:
- /plugin marketplace add in container = installed on host
- New skill on host = visible in container immediately (no rebuild)
- claude logout in container = host stays logged in
- compound-engineering plugin enabled on host = enabled in container
- Theme picker fires once (or never if host has theme set)
What it costs:
- Write-through: a compromised npm dep in workspace deps can write to
host ~/.claude/{plugins,skills,agents,memory,commands}/. Documented
trade-off; for personal dev, accepted. Credentials still per-container.
post-create.sh becomes much simpler — only syncs the four credential
files from host into the named volumes. No more symlink dance, no more
state-file merging.
ensure-host-config-dirs.cjs gains the new bind sources: the shareable
subdirs and settings.json/config.toml files get mkdir/touched on host
so Docker doesn't reject the mount when a CLI has never been used.
* fix(devcontainer): translate host plugin registry paths to Linux on rebuild
The previous topology bind-mounted the entire `~/.claude/plugins/`
directory from host. That brought through plugins, marketplaces, and
extracted cache content correctly — but ALSO brought through the
registry JSONs (`known_marketplaces.json`, `installed_plugins.json`,
`plugin-catalog-cache.json`) which carry absolute OS-native paths:
"installLocation": "C:\Users\gergo\.claude\plugins\marketplaces\X"
"installPath": "C:\Users\gergo\.claude\plugins\cache\Y\Z"
Claude in the Linux container fails to resolve these Windows paths and
reports `Marketplace X failed to load: cache-miss`.
Split the topology:
- `plugins/marketplaces/` (git clones) and `plugins/cache/` (extracted
plugin files) stay bidirectional RW binds — content is path-independent.
- Registry JSONs move into the per-container named volume. post-create.sh
reads host's versions, rewrites any absolute path ending in
`/.claude/plugins/<rest>` (Windows `C:\Users\...` and POSIX
`/Users/...` / `/home/...` patterns) to `/home/node/.claude/plugins/<rest>`,
and writes the translated result to the volume.
What this gets you:
- Plugin installed on host → next container rebuild has it (translated).
- Plugin installed inside container → lives in volume registry; lost on
rebuild (consistent with credentials model). Re-install on host for
persistence.
ensure-host-config-dirs.cjs now also creates `plugins/marketplaces/` and
`plugins/cache/` on host if absent (Docker rejects bind mounts whose
source doesn't exist).
* fix(devcontainer): clean stale plugin/skill symlinks from prior design before writes
A user upgrading from Option B (read-only host stage + symlinks) to the
current hybrid RW-bind topology hit EROFS in post-create.sh when the
plugin registry path-translator tried to write
`/home/node/.claude/plugins/known_marketplaces.json`. The named volume
still carried `/home/node/.claude/plugins -> /host/.claude/plugins`
(Option B's symlink). The new design's sub-path bind mounts at
`plugins/marketplaces` and `plugins/cache` overlay through the symlink,
but writes to the parent dir itself resolve via the symlink to the RO
host stage and fail.
Drop any leftover symlinks at known target paths early in step 2 so the
mkdir/writes that follow land in the volume.
* refactor(devcontainer): split workspace-deps to updateContentCommand
post-create.sh was doing two unrelated jobs: workspace dependency install
(four `npm install` runs in topological order) and AI CLI credential
sync. They have different lifecycle needs — deps should re-run when
lockfiles change, AI sync should run once per container — but both were
gated on container-create.
Per Dev Container spec lifecycle, `updateContentCommand` is the right
hook for workspace deps: runs at container-create AND on content
changes (lockfile updates). `postCreateCommand` is right for AI CLI
sync: container-create only.
Move steps 3-7 (husky cleanup + four `npm install` runs) into
install-deps.sh wired as `updateContentCommand`. Split the chown step
too — install-deps owns workspace-side dirs (node_modules volumes,
~/.npm), post-create owns AI-side dirs (~/.claude, ~/.codex, ~/.cursor,
/commandhistory, ~/.local). Each script now has one concern.
post-create.sh drops from ~187 lines to 148; install-deps.sh is 56 lines
new. Faster rebuilds when nothing about deps changed (the credential
sync + path translation work still runs every container-create, but the
npm install dance no longer does).
Research backing (no other simplification applies):
- Anthropic's reference devcontainer uses pure named volumes; no
host-state inheritance pattern is published.
- Path translation has no upstream fix (issues #21916, #10379 closed
without resolution). Our Node rewrite is the workaround.
- pnpm workspaces (`pnpm -r install`) would replace the four installs
with one command, but that's a real refactor (touches
gitnexus/scripts/build.js + 4 package.json files); deferred.
- `HUSKY=0` in containerEnv would drop the `rm -rf .husky/_` hack, but
would also stop pre-commit hooks from firing inside the container;
deferred.
* fix(devcontainer): drop single-file binds — fixes Codex `batchWrite failed in TUI`
On Docker Desktop Windows the named volumes are ext4 (`/dev/sdd`) while
single-file bind mounts from the Windows host land as 9p (drvfs).
Different filesystems → atomic config writes (write `foo.tmp`, then
rename onto `foo`) trip EXDEV `inter-device move failed` /
`Device or resource busy`.
Codex's TUI surfaces this as `config/batchWrite failed in TUI` when
saving model preference. Claude's writes to settings.json / .claude.json
fail the same way, silently.
Reproduction in container:
$ echo x > /tmp/foo.toml; mv /tmp/foo.toml /home/node/.codex/config.toml
mv: inter-device move failed: ... Device or resource busy
Fix: drop the three single-file bind mounts. Sync host's versions into
the named volume on container-create via `sync_from_host` (same pattern
already used for credentials). Atomic rename within the volume works
because everything is ext4.
Trade-off: container writes to these files no longer propagate to host;
they stay in the volume until next rebuild, which re-syncs from host.
Host is source of truth on rebuild — same model as credentials. Plugin/
skill/agent/memory/command DIRS still bind-mount bidirectionally (atomic
writes within a dir bind stay on one filesystem, no EXDEV).
Files affected:
- ~/.codex/config.toml
- ~/.claude/settings.json
- ~/.claude.json (HOME-level — added `/host/.claude.json` RO mount back
for sync_from_host to read)
* chore(autofix): apply prettier + eslint fixes via /autofix command
* feat(devcontainer): Codex + Cursor plugin/config host parity with Claude
Codex plugins installed in the container never reached the Windows host
because, unlike Claude, the Codex plugin tree wasn't bind-mounted —
only memories/ and skills/ were. Verified via live /proc/mounts: Claude
binds 6 shareable dirs (incl. plugins/marketplaces + plugins/cache),
Codex bound 2. So `codex plugin add` wrote into the ext4 named volume
and stayed there.
Codex changes:
- Bind the WHOLE ~/.codex/plugins dir + ~/.codex/prompts (plus existing
memories/skills). Strace of two real `codex plugin add` runs proved
the installer stages INSIDE plugins/cache/<marketplace>/ and renames
intra-dir, so a single 9p bind of plugins/ keeps the rename intra-fs —
no EXDEV (the bug that broke single-file binds). .tmp/ stays on the
volume (it's the cross-fs staging source). No path translation needed:
Codex enablement lives in config.toml as git URLs, not FS paths.
- Verified live: `codex plugin add compound-engineering@...` now writes
through to C:\Users\...\.codex\plugins\cache\ on the Windows host, and
host-created files appear in the container (bidirectional).
Cursor changes (review found cursor-agent has a real plugin surface, not
editor-only — Cursor 2.5 Marketplace shared by IDE + CLI):
- Bind plugins/marketplaces, plugins/local, rules, commands, agents,
skills (dir binds, EXDEV-safe).
- Copy-on-create mcp.json (single file → EXDEV-unsafe as bind), alongside
the existing cli-config.json.
- Translate plugins/installed_plugins.json (carries absolute Windows
paths like Claude's) — generalized the existing path-rewrite to run
for both Claude and Cursor.
- hooks.json deliberately NOT shared (runs shell commands → supply-chain
surface); documented as opt-in.
ensure-host-config-dirs.cjs pre-creates all new host bind sources.
post-create.sh defensive symlink cleanup extended to the new Codex/Cursor
paths. README updated with the accurate per-CLI share/sync/translate
matrix.
Design adversarially verified (straced installs, EXDEV primitive tests,
sqlite-under-bind check, path-encoding check) before implementing.
* fix(devcontainer): resolve ce-code-review findings (doc drift, chown scope, .cjs extraction, CI smoke)
Multi-agent review (9 reviewers) found the devcontainer files carried
comments + README from the abandoned read-only-symlink design, plus real
behavioral gaps. Resolved all actionable findings (no deferrals).
Documentation drift (the headline — stale comments described a security
model opposite to what shipped):
- README "Trust boundary" claimed a malicious dep "cannot write back …
the read-only /host mount blocks the write." FALSE — the shareable dirs
are RW-bound. Rewrote to document the bidirectional write-through, what
stays one-way (credentials never flow back), and how to close it.
- devcontainer.json mount group-1 comment described "selectively symlinks
… read-only eliminates write-through" — replaced with the RW-bind reality.
- Header "Windows-native is unsupported" -> supported (auto HOME setup).
- containerEnv comment "credentials persist in host-bind-mounted dirs" ->
they live in the named volumes.
- hooks.json exclusion documented honestly as a partial mitigation, not a
clean boundary (commands/agents/skills/rules are equally executing).
- ~/.local "named volume" -> image directory.
Behavioral fixes:
- chown -R recursed into the RW host binds (could rewrite host ownership /
EPERM-abort provisioning on non-UID-aligned Linux). Switched to
`find -xdev` per dir so chown stays on the volume filesystem.
- Cursor installer wrapped in `timeout 300` — its inner binary download
isn't covered by curl --max-time and could hang docker build forever.
- Removed dead CURSOR_VERSION ARG/ENV/build-arg (never consumed; "latest"
implied a pin the installer can't honor). Documented why Cursor is unpinned.
Extraction + tests (the two inline post-create.sh node heredocs were
unlintable and untestable; the path regex had had bugs):
- seed-claude-config.cjs — installMethod-strip seed, now with a non-object
guard (a bare-value/array host .claude.json could otherwise slip the
try/catch and silently re-trigger onboarding) and labeled write errors.
- translate-plugin-registries.cjs — plugin-registry path translation with
labeled errors.
- translate-plugin-registries.test.cjs — 12 tests (Windows/POSIX paths,
cross-CLI isolation, nested objects, non-object/empty-config guard).
- post-create.sh calls the modules via $SCRIPT_DIR.
CI:
- .github/workflows/ci-devcontainer.yml — runs the unit tests + shell
syntax checks + a `@devcontainers/cli build` smoke on .devcontainer/**
changes. Conforms to the repo concurrency convention (validator passes).
Documented (real gaps, fixes are honest docs since no correct auto-fix
exists): user-scope MCP servers with absolute host command paths don't
resolve in-container; user-scope config is copy-on-create so host edits
need a rebuild; in-container plugin installs get shadowed by an empty host
bind on rebuild (recovery noted); plugin installs are single-writer across
checkouts; gh/docker RW-vs-ssh/aws/azure-RO rationale.
Verified: fresh `@devcontainers/cli up` succeeds; installMethod stripped,
registry translated to Linux paths, credentials node:node, 12/12 tests pass.
* fix(devcontainer): set persist-credentials:false on CI checkouts + prettier
- zizmor `artipacked` (CodeQL/GitHub Advanced Security) flagged both
actions/checkout steps in ci-devcontainer.yml: checkout defaults to
persist-credentials:true, leaving GITHUB_TOKEN in .git/config where it
can leak into uploaded artifacts. Both jobs are read-only (run tests /
build smoke, never push), so persist-credentials:false is correct —
matches the repo convention in codeql.yml / ci-tests.yml.
- Ran prettier 3.8.0 over the new .cjs modules + test (single-quote/style
normalization to match the repo). JSON/YAML were already compliant;
README is in .prettierignore; .sh has no prettier parser. Behavior
unchanged — 12/12 transform unit tests still pass.
* fix(devcontainer): resolve adversarial review findings (pins, RO mounts, tests)
Resolves the blocking + actionable findings from the PR #1875 review:
- Pin base image by digest as bare name@digest [#1]. The :tag@digest form
trips the @devcontainers/cli image-name parser (which builds this image
in CI and in VS Code "Reopen in Container"); bare name@digest is the
parser-compatible form. Verified by a full local build.
- Pin Cursor by version + per-arch sha256 and fetch the artifact directly
instead of executing cursor.com/install; fail-closed on mismatch [#2].
- Mount ~/.config/gh and ~/.docker read-only so a compromised dep can't
rewrite the host GitHub token / Docker credHelper [#4].
- Pin @devcontainers/cli@0.87.0 in the CI smoke [#5].
- chown via find -xdev in install-deps.sh (symlink-safe; matches
post-create.sh) [#6].
- Add filesystem-I/O tests (translate/readHostConfig/seed main/ensurePaths)
and refactor ensure-host-config-dirs to be unit-testable [#7].
- Stop pre-creating settings.json/config.toml on the host; only the real
single-file bind source (.claude.json) is touched [#10].
- Add a prominent top-of-README security callout for the RW write-through
trade-off and reframe the deferred egress firewall as the key missing
compensating control [#3, #9].
Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
* fix(devcontainer): resolve local adversarial-review findings (low/nit)
Follow-up to a local branch review (run after the cloud review crashed before
producing findings); all 5 confirmed findings were low/nit:
- chown via `find -xdev -exec chown -h`: add -h so chown acts on a symlink
ITSELF, not its target. Without it a dangling node_modules/.bin link aborted
provisioning under `set -e`, and a cross-fs symlink target could be
dereferenced/rewritten. Verified in a clean container (regular files still
chowned; dangling link no longer aborts; cross-fs target untouched). Applied
to install-deps.sh and post-create.sh; the inline comments are corrected to
describe -xdev (descent bound) and -h (no deref) as the two distinct guards.
- Reword the .cjs header claims from "lintable" to "unit-tested and
prettier-checked": ESLint applies no rules to .cjs in this repo; CI only
prettier-checks them.
- README: the initializeCommand is `node ensure-host-config-dirs.cjs`, which
creates the full bind-source set, not a bash `mkdir -p` of four dirs.
- ci-devcontainer.yml: document that the x64 runner exercises only the amd64
Cursor branch; the arm64 sha/URL is hash-pinned (verified against the
published artifact) but not built in CI.
- Make the seed chmod-644 test meaningful: pre-create dst at 0o600 so only the
explicit chmodSync can widen it (the prior assertion passed under the default
umask regardless of whether the chmod ran).
25/25 config-transform tests pass; arm64 + x64 Cursor artifacts verified.
* docs(devcontainer): rewrite code comments in plain English
The devcontainer comments had grown dense and jargon-heavy. Rewrite them
across all 9 files into short, plain-English sentences — same facts and
reasoning, just clearer wording.
Comments only; no code changed. Verified: the diff touches comment lines
only, 25/25 config-transform tests pass, devcontainer.json is still valid
JSONC with build.args + readonly mounts unchanged, shell scripts pass
`bash -n`, and prettier is clean.
* feat(devcontainer): persist AI CLI session state across container recreation
Add dedicated per-workspace named volumes (mount group 6) for the three
AI CLIs' session/resume state so `claude --resume`, `codex resume`, and
`cursor-agent resume` survive a rebuild, a full delete-and-recreate, and
the `docker volume rm <cli>-config-*` re-login fix:
- Claude -> ~/.claude/projects
- Codex -> ~/.codex/sessions
- Cursor -> ~/.cursor/chats + ~/.cursor/projects
The volumes are SEPARATE from the credential/config volumes and keyed
like the node_modules volumes (${localWorkspaceFolderBasename}-...-
${devcontainerId}), so wiping a config volume to force a re-login no
longer destroys session history. Session state already survived a plain
rebuild (it lived in the config volume); this closes the recreation,
volume-rm, and devcontainerId-change gaps.
Kept container-private (not host bind mounts) deliberately: transcripts
can contain pasted secrets, so a host bind would spill them to host
disk, widen the supply-chain write-through surface, and leak
cross-project transcripts. A commented-out opt-in host-bind block is
included for users who accept that trade-off.
post-create.sh: chown each new volume root explicitly (find -xdev stops
at the config-volume filesystem boundary and won't descend into them),
guarded with `[ -d ] || continue` so a missing root can't abort
provisioning under set -e.
README: document the topology, what survives vs not, the one-time
first-rebuild masking of pre-existing config-volume sessions, updated
rebuild/reset commands, and the trust-boundary impact.
* feat(devcontainer): isolate host AI-CLI config via seed-once copies + persist claude-mem
Replace the read-write host bind mounts for the AI-CLI shareable dirs
(Claude skills/agents/memory/commands/plugins; Codex plugins/prompts/
memories/skills; Cursor rules/commands/agents/skills/plugins) with a
seed-once copy from a read-only /host/.<cli> stage into the per-container
config volume. The container gets its own writable copy and can never
write back to the host, closing the write-through vector where a
compromised in-container dependency could drop a malicious agent, command,
skill, or plugin onto the host for the next host session to auto-load.
Add a per-container claude-mem named volume (claude-mem-${devcontainerId})
at /home/node/.claude-mem, seeded once from a read-only /host/.claude-mem
stage. claude-mem's multi-GB SQLite + Chroma store is kept off a host bind
(unreliable fcntl locking / corruption risk over 9p on Docker Desktop
Windows) while still surviving rebuilds.
- post-create.sh: seed shareable dirs (marker-gated, seed-once) and run
plugin-registry translation per seeded CLI; seed claude-mem behind a
completion-sentinel guard that self-heals an interrupted multi-GB copy;
chown the claude-mem volume only on first create.
- translate-plugin-registries.cjs: add selectRegistries() so translation
runs per-CLI seed-once instead of clobbering container-installed plugins.
- ensure-host-config-dirs.cjs: add ~/.claude-mem; drop the shareable
subdirs (no longer bind sources).
- devcontainer.json: drop the RW shareable binds; add the claude-mem
volume + read-only stage.
- README: rewrite trust-boundary, mount table, and rebuild/reset docs for
the copy model.
- tests: cover selectRegistries and the trimmed DIRS (30 pass).
* feat(devcontainer): add Bun 1.3.14, pinned via build arg
Installed by the official bun.sh/install script with the release tag
passed as the first positional arg, so the version is pinned even though
the install path itself is an unverified remote script (the one such
exception in the image — Cursor and the base image stay sha256/digest-
pinned). BUN_INSTALL is set in ENV so the binary lands at a known path
and the installer's rc-file edits don't matter. unzip is added to apt
since the Bun installer extracts a .zip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(devcontainer): persist gh auth via copy-into-volume model
Move ~/.config/gh from a read-only bind to the same read-only host
stage + per-container named volume pattern used for the AI CLI
credentials. post-create.sh seeds hosts.yml/config.yml from the
/host/.config/gh stage into the gh-config volume on create, so an
in-container `gh auth login` now persists across rebuilds while the
read-only stage still prevents any write-back to the host token.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(devcontainer): bump Claude Code to 2.1.156 for Opus 4.8
The pin was 2.1.153, which predates Opus 4.8 support (added in
2.1.154). With DISABLE_AUTOUPDATER=1 the container never updated past
the pin, so Claude Code only offered models up to 4.7. Bump to the
latest 2.1.156 so Opus 4.8 is available.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|---|---|---|
| .. | ||
| devcontainer-lock.json | ||
| devcontainer.json | ||
| Dockerfile | ||
| ensure-host-config-dirs.cjs | ||
| install-deps.sh | ||
| post-create.sh | ||
| README.md | ||
| seed-claude-config.cjs | ||
| translate-plugin-registries.cjs | ||
| translate-plugin-registries.test.cjs | ||
GitNexus Devcontainer
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, Cursor CLI, and Bun alongside the GitNexus native build chain. Supported hosts: macOS, Linux, Windows 11 (native), and Windows 11 via WSL2. Windows-native needs a one-time HOME env var setup — handled automatically by the initializeCommand on first run (see Windows 11 setup).
⚠️ Read this before using it on a work machine
This devcontainer does not write to your host AI-CLI config. Your skills, agents, commands, plugins, memory, prompts, and rules are copied once from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container cannot drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your credentials (Claude/Codex/Cursor logins, plus
gh) likewise stay in per-container volumes and are never written back, and~/.ssh,~/.aws,~/.azure, and~/.dockerare mounted read-only.What is still exposed: the read-only host stages (
/host/.claude,/host/.codex,/host/.cursor,/host/.claude-mem) and the read-only credential mounts are all readable inside the container. A compromised dependency can therefore READ your host CLI config, memory, SSH/cloud credentials, and GitHub token — and there is no egress firewall yet, so it has the network to exfiltrate what it reads. Read-only protects you from tampering and write-back, not from disclosure.The trade-off of the copy model: host and container config diverge after first create. A skill or plugin you add on the host later won't appear in the container until you wipe the config volume and rebuild (see § Rebuild / reset). Edits you make inside the container persist across rebuilds but never reach the host.
Quick start
- Install Docker Desktop (Windows/macOS) or Docker Engine (Linux).
- Install VS Code with the Dev Containers extension.
- Install Node.js on the host (Node 18+). This is the only host-side toolchain dependency beyond Docker and VS Code — the devcontainer's
initializeCommandrunsnode .devcontainer/ensure-host-config-dirs.cjsto set up the bind-mount source directories before container create. If you already use Claude Code or another Node-based CLI on the host, you're already set. - Open the repo in VS Code → Command Palette → Dev Containers: Reopen in Container.
- Wait for the first build (~3–6 minutes) and
postCreateCommandto finish installing workspace dependencies. - Authenticate the three CLIs once — see First-time CLI authentication below.
Windows 11 setup
Windows-native (one-time setup, then "just works")
The host bind mounts use ${localEnv:HOME}/.claude (and .codex, .cursor, .ssh, .config/git, .config/gh, .gitconfig). VS Code resolves ${localEnv:HOME} by reading its own process env, and Windows doesn't set HOME by default — it uses USERPROFILE. So the bind mounts can't resolve until you tell Windows to also expose your profile as HOME.
The initializeCommand (node .devcontainer/ensure-host-config-dirs.cjs) handles this automatically:
- First time you Reopen in Container, the script detects the missing
HOME, runssetx HOME "%USERPROFILE%"(which writes to your user-level Windows env — no admin needed), prints a one-time setup banner, and exits. - Close all VS Code windows (File → Exit) and reopen. VS Code picks up the new
HOMEat startup. - Reopen in Container again. The script now sees
HOME=C:\Users\<you>, skips the setup block, creates the bind-mount source dirs, and Docker brings the container up.
Subsequent rebuilds work normally with no extra steps. The HOME env var is set persistently in your Windows user environment, so it'll be there for every future VS Code session (and any other tool that wants HOME).
If you'd rather set it manually before opening the container:
setx HOME "%USERPROFILE%"
# Close & reopen VS Code
Known trade-offs of Windows-native vs WSL2
Windows-native works, but Docker Desktop's Windows bind-mount layer has rough edges that WSL2 avoids:
- File watchers can miss events. Vite / jest
--watchrunning inside the container watching workspace files mounted fromD:\...may miss changes — chokidar polling (CHOKIDAR_USEPOLLING=true) is the usual workaround. npm installis 3-5× slower through the Windows-to-Linux bind-mount translation than on a WSL2-native filesystem.- Permission edge cases. The husky
.husky/_/hEPERM class we hit earlier in this PR is specific to Windows-side bind mounts changing UID ownership between container runs.post-create.shclears the cache defensively to keep this from being fatal, but it's still a real source of friction.
If you hit any of those and want to migrate to WSL2 later, the steps are below.
WSL2 (faster, fewer edge cases)
To clone and open the repo inside WSL2:
# 1. Install WSL2 and a Linux distro if you haven't already.
wsl --install -d Ubuntu
# 2. Enter WSL.
wsl
# 3. Clone the repo inside your WSL2 home directory.
cd ~
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 `${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.
macOS
Open the repo folder in VS Code → Reopen in Container. The image is multi-arch; on Apple Silicon you'll pull the linux/arm64 variant automatically.
Linux
Same as macOS — open in VS Code and reopen in container. updateRemoteUserUID: true (default) shifts the container's node user UID/GID to match your host user, so bind-mounted files stay writable without extra setup.
How CLI state flows from your host
AI CLIs (Claude Code, Codex, Cursor): copy-once from a read-only host stage + per-container credentials
The three AI CLIs use a copy-from-read-only-stage topology: the host's ~/.<cli> folders (and ~/.claude-mem) are mounted read-only at /host/.<cli>, and post-create.sh copies out of them into per-container named volumes. Credentials, identity, and single config files are copied on every create; the shareable subdirs (plugins, skills, agents, memory, commands, prompts, rules) are copied once on first create and then owned by the container. Nothing is bind-mounted read-write into the host's CLI config, so the container can never modify your host setup. Session sub-paths overlay the config volume via their own named volumes (Docker mount precedence — more specific path wins).
| Mount | Source | Target | Mode | Purpose |
|---|---|---|---|---|
| Container Claude config dir | named volume claude-config-${devcontainerId} |
/home/node/.claude |
rw | Per-container credentials + identity |
| Container Codex config dir | named volume codex-config-${devcontainerId} |
/home/node/.codex |
rw | Per-container credentials |
| Container Cursor config dir | named volume cursor-config-${devcontainerId} |
/home/node/.cursor |
rw | Per-container credentials |
| Container gh config dir | named volume gh-config-${devcontainerId} |
/home/node/.config/gh |
rw | Per-container gh auth (hosts.yml/config.yml) seeded from host stage; in-container login persists |
| Claude sessions (overlay on the config volume) | named volume …-claude-sessions-${devcontainerId} |
/home/node/.claude/projects |
rw | --resume transcripts; survives the <cli>-config volume wipe — see Session resume |
| Codex sessions | named volume …-codex-sessions-${devcontainerId} |
/home/node/.codex/sessions |
rw | codex resume rollouts; SQLite index backfills on recreation |
| Cursor sessions | named volumes …-cursor-sessions-${devcontainerId}, …-cursor-projects-${devcontainerId} |
/home/node/.cursor/chats, /home/node/.cursor/projects |
rw | cursor-agent resume store (best-effort — layout reverse-engineered) |
| claude-mem store | named volume claude-mem-${devcontainerId} |
/home/node/.claude-mem |
rw | claude-mem's SQLite DB + Chroma vector store; seeded once from /host/.claude-mem, then container-private — see note below |
| Host Claude state, read-only stage | $HOME/.claude |
/host/.claude |
read-only | post-create.sh reads credentials + identity from here on container-create |
| claude-mem store, read-only stage | $HOME/.claude-mem |
/host/.claude-mem |
read-only | post-create.sh seeds the claude-mem volume from here on first create |
| Host Codex state, read-only stage | $HOME/.codex |
/host/.codex |
read-only | Same purpose for Codex |
| Host Cursor state, read-only stage | $HOME/.cursor |
/host/.cursor |
read-only | Same purpose for Cursor |
| Claude shareable subdirs | seeded into the config volume from $HOME/.claude/{plugins/marketplaces,plugins/cache,skills,agents,memory,commands} |
same under /home/node/.claude/ |
n/a (copy) | Seed-once copy from the read-only stage; container owns its copy after |
| Codex shareable subdirs | seeded from $HOME/.codex/{plugins,prompts,memories,skills} |
same under /home/node/.codex/ |
n/a (copy) | Seed-once copy (whole plugins/ dir — no path-bearing registry inside it) |
| Cursor shareable subdirs | seeded from $HOME/.cursor/{plugins/marketplaces,plugins/local,rules,commands,agents,skills} |
same under /home/node/.cursor/ |
n/a (copy) | Seed-once copy of the Cursor 2.5 plugin/rules/commands surface |
What gets seeded once from the host (copy, not bind):
- Claude:
plugins/marketplaces,plugins/cache,skills/,agents/,memory/,commands/ - Codex:
plugins/(whole dir),prompts/,memories/,skills/ - Cursor:
plugins/marketplaces,plugins/local,rules/,commands/,agents/,skills/
On the first container-create, post-create.sh copies each of these out of the read-only /host/.<cli> stage into the per-container config volume, then writes a .devcontainer-shareable-seeded marker. On every later rebuild the marker is present, so the copy is skipped and the container keeps whatever it has accumulated. A plugin/skill/agent you install inside the container persists across rebuilds; one you add on the host after first create won't appear in the container until you remove the config volume and rebuild (see § Rebuild / reset). Nothing here is writable back to the host — /plugin marketplace add inside the container installs into the container's own volume copy, not your host ~/.<cli>/plugins/.
Single config files are copied on container-create, not bind-mounted — on Docker Desktop Windows a single-file bind is 9p while the named volume is ext4, and atomic config writes (tmp → rename onto target) trip EXDEV (this is what caused Codex's config/batchWrite failed in TUI). So these are synced from host on rebuild and the container rewrites its own copy until the next rebuild: settings.json + $HOME/.claude.json (Claude), config.toml (Codex), cli-config.json + mcp.json (Cursor). hooks.json (Cursor) is deliberately not synced — Cursor hooks execute shell commands, so sharing them would widen the supply-chain attack surface; add it yourself if you want it.
Plugin registry files with absolute paths are translated, not copied verbatim — Claude's known_marketplaces.json / installed_plugins.json / plugin-catalog-cache.json and Cursor's installed_plugins.json bake in C:\Users\… (Windows) or /Users/… (macOS) install paths. post-create.sh rewrites those to /home/node/.<cli>/plugins/… and writes the result into the named volume, so plugins resolve inside Linux instead of failing with cache-miss. This translation is also seed-once per CLI — it runs only for a CLI being seeded that create (translate-plugin-registries.cjs claude cursor), so it stays consistent with the seed-once cache/ copy and won't overwrite a plugin you installed inside the container on a later rebuild. Codex needs no translation — its enablement registry is config.toml (git URLs + logical keys, no filesystem paths), so its whole plugins/ dir is copied as-is.
What stays per-container (in the named volume) and is synced from host on container-create:
.credentials.json(Claude OAuth tokens),auth.json(Codex),cli-config.json(Cursor) — credentials~/.claude/.claude.json(Claude's identity-only file:userID,oauthAccount, migration tracking) — kept per-container so logging in via container doesn't overwrite host's stored identity
post-create.sh runs on every container-create, copies host's credentials into the volume if present, then container manages refresh from there. Sync is "always overwrite if host has the file, otherwise leave container alone". So:
- Host has credentials → container starts logged in.
- Host has no credentials →
claude login/codex login --device-auth/cursor-agent logininside container; credentials stay in the named volume across rebuilds (volume is keyed by${devcontainerId}, stable for the workspace path). claude logoutinside container clears volume credentials only; host is untouched.
Why CLAUDE_CONFIG_DIR is intentionally NOT set: Claude's default ~/.claude matches the named-volume mount target, so the env var added no behavior — but setting it changed which file Claude reads hasCompletedOnboarding from. With it set, Claude reads $CLAUDE_CONFIG_DIR/.claude.json (the small identity-only file) and re-onboards every container; without it, Claude reads $HOME/.claude.json (copied from the read-only /host/.claude.json stage on container-create via seed-claude-config.cjs, with hasCompletedOnboarding: true).
Host CLI config is protected from write-through. The shareable dirs are copied out of a read-only stage into the container's own volume, so a compromised npm package in the workspace dep tree — running inside the container — cannot write a malicious agent, command, skill, or plugin back to ~/.claude/, ~/.codex/, or ~/.cursor/ on the host. The earlier design bind-mounted these read-write and accepted that write-through as the cost of live sync; this design closes it. An even earlier alternative (read-only stage + symlinks) made /plugin marketplace add inside the container fail with EROFS; copying into a writable volume avoids that, because the container writes to its own copy rather than a read-only mount. What a compromised dependency can still do is read the read-only host stages (/host/.<cli>, /host/.claude-mem) and the read-only credential mounts and exfiltrate them — there is no egress firewall yet. The cost of the copy model is divergence: host edits made after first create don't reach the container until you wipe the config volume and rebuild.
Refresh-token divergence between rebuilds. Container's credentials match host's at container-create time; after that, container manages its own refresh until the next rebuild. Anthropic rotates refresh tokens on every use, so an unattended container that hasn't talked to the API in weeks can hit a silent 401 if the host has refreshed since. Re-run claude login inside the container, or rebuild, to recover.
claude-mem is seeded once, then container-private. The claude-mem store ($HOME/.claude-mem — a multi-GB SQLite DB claude-mem.db + -wal/-shm, plus a Chroma vector store chroma/chroma.sqlite3 and its HNSW index binaries) is the one shareable-looking folder that is deliberately not a host bind, for the same SQLite reason as sessions below: a multi-GB WAL database over the 9p/virtiofs bind risks unreliable fcntl locking and corruption — sharply so if claude-mem ran on the host and in the container against the same DB at once. So it gets its own per-container named volume (claude-mem-${devcontainerId}), and post-create.sh seeds it once from the read-only /host/.claude-mem stage only when the volume has no DB yet. The first container-create copies the host's store in (a one-time copy, possibly several GB); every later rebuild keeps whatever the container accumulated and skips the copy. The container's memory and the host's diverge from that seed point — writes do not flow back — which is the price of keeping SQLite off a shared bind. To re-seed from the host's current store, remove the volume (docker volume rm claude-mem-<id>) and rebuild. ensure-host-config-dirs.cjs creates an empty ~/.claude-mem on hosts that never installed claude-mem, so the read-only stage bind always resolves; the seed then finds no DB and the container simply starts with empty memory.
Session resume across container recreation
claude --resume, codex resume, and cursor-agent resume all read local transcript files. Those live inside each CLI's config dir, which is a per-container named volume — so they already survive an ordinary Rebuild Container. What they did not survive were the very things this README tells you to do: docker volume rm <cli>-config-${devcontainerId} to force a re-login or clear an EACCES, a ${devcontainerId} change, or a full delete-and-recreate. Each of those drops the config volume and takes your session history with it.
So the resume/transcript directories get their own named volumes (mount group 6 in devcontainer.json), keyed like the node_modules volumes (${localWorkspaceFolderBasename}-…-${devcontainerId}) and mounted over the config volume at the session sub-paths:
| Resume command | Persisted volume → target | What's stored |
|---|---|---|
claude --resume / --continue |
…-claude-sessions-… → ~/.claude/projects |
<encoded-cwd>/<uuid>.jsonl transcripts + sessions-index.json. Container cwd is always /workspace, so only that slice is stored. Pure JSONL/JSON — no SQLite. |
codex resume / resume --last |
…-codex-sessions-… → ~/.codex/sessions |
YYYY/MM/DD/rollout-*.jsonl. The state_5.sqlite thread index stays on the config volume (a single WAL file we don't split out); when it's absent after a recreation, Codex rebuilds it from these rollouts on the next start (a one-time backfill). |
cursor-agent resume / ls |
…-cursor-sessions-… → ~/.cursor/chats; …-cursor-projects-… → ~/.cursor/projects |
chats/{hash}/{uuid}/store.db (one SQLite db per session, each in its own dir) + projects/.../agent-transcripts. cursor-agent's layout is reverse-engineered, so treat this as best-effort. |
Because these are separate volumes from <cli>-config-${devcontainerId}, the re-login fix (docker volume rm claude-config-…) no longer destroys your sessions — that was the point.
Survives: Rebuild Container, Rebuild Without Cache, a full delete-and-recreate of the container, and the docker volume rm <cli>-config-… re-login / EACCES fix.
Does not survive (same durability tier as the node_modules volumes): docker volume prune, a ${devcontainerId} change (moving the checkout to a new path, or switching between Windows-native and WSL2), or moving to a new machine. To deliberately wipe sessions, remove the session volumes too — see Rebuild / reset. Two checkouts with the same folder name on one host would share session volumes only if they also share a ${devcontainerId}; they don't, so they stay separate.
First rebuild after adopting this, one-time: if a container created before these volumes existed already had sessions on the config volume (~/.claude/projects, ~/.codex/sessions, …), the new empty session volume mounts over that sub-path and masks the old content — same Docker-precedence shadowing described for plugins above. The old sessions are hidden, not deleted. To carry them forward once, copy them out of the config volume into the session volume; or just start fresh — new sessions land on the session volume from then on.
Why sessions are container-private and not even seeded from the host. The shareable config dirs are seeded once from the host (you want your skills/agents/plugins in the container). Sessions are deliberately not seeded and never touch the host, because a transcript can contain anything you pasted or the agent read — API keys, file contents, connection strings. Binding or copying them to/from the host would (a) spill that to host disk, (b) add a write-through surface a compromised dependency can reach (there's still no egress firewall), and (c) leak every other project's transcripts into the container (Codex sessions/ and Cursor chats/ aren't project-scoped). Container-private volumes avoid all three while still surviving recreation. And Claude/Codex transcripts embed the container cwd (/workspace), so even if you did bind them to the host, the host CLI wouldn't natively --resume them — its encoded-cwd folder differs.
Opt in to host-shared sessions anyway. If you want transcripts visible/portable on the host and accept the trade-offs above, uncomment the host-bind block in devcontainer.json (just below the group-6 volumes) and add the matching source dirs to ensure-host-config-dirs.cjs's DIRS so Docker can resolve the binds. That block scopes Claude to /workspace's encoded subdir to limit the cross-project leak; the Codex and Cursor stores can't be scoped that way, so they expose every project's transcripts.
Other host bind mounts
| Container path | Host source | Mode | Why |
|---|---|---|---|
~/.config/git |
$HOME/.config/git |
read-only | XDG-style git config / ignore / attributes |
~/.ssh |
$HOME/.ssh |
read-only | SSH commit signing + git push over SSH |
~/.config/gh |
$HOME/.config/gh |
copy → volume | gh CLI auth (PR/issue create, checks) — seeded from your host login on create into a per-container volume; in-container gh auth login persists across rebuilds and never writes back to the host |
~/.docker |
$HOME/.docker |
read-only | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
~/.aws |
$HOME/.aws |
read-only | AWS CLI / SDK credentials (forward-compat — empty by default) |
~/.azure |
$HOME/.azure |
read-only | Azure CLI credentials (forward-compat — empty by default) |
Why ssh/aws/azure/docker are read-only, and why gh is copied into a volume: ssh/aws/azure are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. docker can write its own state (docker login / buildx write config.json), but a read-write host bind would let a compromised in-container dependency rewrite your host ~/.docker/config.json (point a credHelper at an attacker-controlled binary) — a credential-takeover vector. The common case is reading an existing host login, so docker stays read-only: registry pulls/pushes using your host creds work, only a docker login inside the container won't persist back. gh used to be read-only for the same reason, but that meant an in-container gh auth login had nowhere to write and silently failed. So gh now uses the copy-into-volume model (the same one the AI-CLI credentials use): the host ~/.config/gh is a read-only stage at /host/.config/gh, and post-create.sh copies hosts.yml/config.yml out of it into the per-container gh-config volume on create. The container gets a writable copy — gh auth login / gh auth refresh inside the container now work and persist across rebuilds — while the read-only stage guarantees nothing is ever written back to the host's token. If you want docker to behave the same way, give it the same treatment (a /host/.docker stage + a docker-config volume + a copy step in post-create.sh).
~/.gitconfig is not bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's user.name / user.email are available inside the container.
If a host source dir doesn't exist when the container is first created, the initializeCommand (node .devcontainer/ensure-host-config-dirs.cjs) creates it empty — so the bind mount always has a valid source.
Per-CLI quirks worth knowing
- Claude Code on macOS stores credentials in the system Keychain, not in
~/.claude/.credentials.json. The sync silently no-ops; runclaude logininside the container once and the named volume persists it. - Codex on macOS / Linux with
cli_auth_credentials_store = "keyring"stores auth in the OS keyring (Keychain / Secret Service), so~/.codex/auth.jsonmay not exist on host. Same fallback:codex login --device-authinside the container. - Cursor CLI inside containers has known upstream auth issues — even with a correctly-synced
cli-config.json, you may need to re-runcursor-agent logininside the container. - Stale named volumes from old rebuilds can carry forward. If you delete and re-create the same workspace, or if a prior container left interim state with a different
userID, deleting the named volumes before rebuild guarantees a clean sync:docker volume rm claude-config-${devcontainerId} codex-config-${devcontainerId} cursor-config-${devcontainerId}(look them up withdocker volume ls | grep -config-). - User-scope MCP servers with absolute host paths won't resolve in-container.
~/.claude.json(Claude),~/.codex/config.toml(Codex), and~/.cursor/mcp.json(Cursor) are copied from host on container-create, so their user-scopemcpServersentries come along. But an entry whosecommandis an absolute host path (C:\tools\foo.exe,/usr/local/bin/foo) points at a binary that doesn't exist in the container — that server silently fails to launch. Only registry/npx-based servers (like this repo's.mcp.json, which usesnpx -y gitnexus@latest mcp) and remote/URL servers work unchanged. The path-translation pass only rewrites*/.<cli>/plugins/*registry paths, not arbitrarymcpServerscommand paths (there's no correct container target for a host-local binary). Install such MCP servers inside the container, or usenpx/remote ones. - Host config is seeded once per devcontainer, then diverges — this now applies to everything. A
mcpServersentry, setting, plugin, skill, agent, or command you add on the host after the container was created is not visible in the container until you remove the config volume and rebuild. Single config files (mcpServers,settings.json, …) are copy-on-create; the shareable dirs (plugins/skills/agents/memory/commands/prompts/rules) are copy-on-first-create (they persist across ordinary rebuilds and aren't even re-copied). Both diverge from the host after their copy. To pull host-side changes in, wipe the relevant volume and rebuild (see § Rebuild / reset). - Plugins/skills/agents installed in-container persist; they do not reach the host. A
/plugin marketplace add(orcodex plugin add, or a new skill/agent) inside the container writes to the container's own config volume and survives ordinary rebuilds. It never appears on the host — the host dirs are read-only sources, not bind targets. To get a plugin onto the host, install it on the host (then wipe + rebuild to seed it into the container). - No cross-checkout plugin contention. Because each container copies plugins into its own per-
${devcontainerId}volume rather than sharing one host bind source, two containers (or checkouts) installing plugins at the same time no longer interleave git clones/extractions against a shared host dir. Each writes only its own copy.
What you still don't have inside the container
These are commonly-needed CLIs that aren't installed by default — adding them would be follow-up work, not in this PR's scope:
- Docker CLI (for
docker push/docker buildfrom inside the container). Add viaghcr.io/devcontainers/features/docker-outside-of-docker:1to thefeaturesblock —~/.docker/is already mounted read-only, so your hostdocker loginstate works immediately for pulls/pushes; an in-containerdocker loginwon't persist to the host (drop,readonlyon that mount if you need it to). - AWS CLI / Azure CLI / gcloud / kubectl — same pattern: add the matching Feature, the host config dirs already flow through.
- Private npm registry auth (
~/.npmrc) — you don't have a global one on this host. If you ever start using private packages, addsource=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonlyto the mounts.
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 commands are seeded from the host once, then container-private. On first create the container copies your host's plugins/skills/agents/memory/commands (and Codex prompts/memories, Cursor rules) into its own volume. After that they're independent: install or edit inside the container and it stays in the container (persists across rebuilds); add a plugin or agent on the host and the container won't see it until you wipe the config volume and rebuild. Nothing the container does reaches the host. (
settings.jsonand the user-scope~/.claude.jsonare copy-on-create the same way;~/.claude/projects/is container-local by design.) - Git identity comes from the host. Commits from inside the container use your host's
user.name/user.email— VS Code's Dev Containers extension auto-copies your~/.gitconfiginto the container at attach time. Any XDG-style config under~/.config/git/flows through via the read-only bind mount. To change git identity, edit~/.gitconfigon the host (container-sidegit config --globalwrites to a container-local file that's discarded on rebuild). - SSH keys flow through (read-only). Push over SSH remotes and SSH commit signing work inside the container using your host keys. The mount is read-only so container code can't exfiltrate or modify private keys — agent-perspective, this means you get git operations but the keys stay vendor-side.
ghauth is shared, and in-container logins persist. If you're logged in on the host,gh pr create,gh pr checks,gh issue creatework inside the container without re-authenticating. If you're not, rungh auth logininside the container once — becauseghconfig lives in a writable per-container volume (seeded from the host stage), that login persists across rebuilds and never touches the host's token.- 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 directories are guaranteed to exist by the initializeCommand (node .devcontainer/ensure-host-config-dirs.cjs), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows cmd.exe and POSIX shells. It creates the top-level bind-mount source dirs — ~/.claude, ~/.codex, ~/.cursor, ~/.claude-mem, plus ~/.ssh, ~/.docker, ~/.aws, ~/.azure, ~/.config/{gh,git}. It deliberately does not pre-create the shareable subdirs (skills/agents/plugins/…): those are no longer bind sources (they're copied out of the whole-~/.<cli> read-only stage), and pre-creating empty ones would needlessly write into the host of someone who never used that CLI.
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, has direct read access to:
- Host AI CLI state — the read-only stage at
/host/.claude,/host/.codex,/host/.cursor,/host/.claude-mem, which exposes your entire host~/.<cli>tree (credentials, identity, AND the shareable skills/agents/plugins/memory/commands) for reading. The container copies what it needs out of this stage; a compromised dep can read all of it. It is read-only, so none of it can be written back - The container's own credential snapshots at
/home/node/.claude/.credentials.jsonetc. (copied from host on container-create) ~/.claude/memory// per-project memory (which may contain user-stored secrets if you've used the/rememberskill)- The current container's own session transcripts (
~/.claude/projects,~/.codex/sessions,~/.cursor/chats/projects— the group-6 volumes), which can hold anything pasted into or read during a session. These are container-private (see one-way note below), so this is read access to this container's sessions only, not the host's or other projects' - Your
ghtoken (~/.config/gh) - Your SSH private keys (
~/.ssh/) - Docker registry tokens in
~/.docker/config.json(if you'vedocker login-ed) - AWS/Azure CLI credentials if you've populated
~/.aws/or~/.azure/
It does not have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep cannot write into your host ~/.claude/{plugins,agents,skills,commands,memory}/, ~/.codex/{plugins,prompts,memories,skills}/, or ~/.cursor/{plugins,rules,commands,agents,skills}/. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next host session — is closed: there is no writable path from the container to those host folders. (Cursor's hooks.json is still additionally withheld from even the container's copy, because hooks fire without an agent invoking them.) The boundary is now one-way for all of the host CLI config, not just credentials.
What stays one-way (genuinely protected): everything. Credentials never flow back to host — .credentials.json / auth.json / cli-config.json live only in the per-container named volumes, and the /host/.<cli> stage they're copied from is mounted read-only, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. ~/.ssh, ~/.config/git, ~/.aws, ~/.azure, and ~/.docker are read-only binds with the same property — a compromised dep can read your registry tokens but cannot rewrite them to hijack your future host auth. ~/.config/gh is now a read-only stage copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container gh auth login can never overwrite your host token. Session transcripts live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any other project's transcripts. The opt-in host-bind block in devcontainer.json reverses that for sessions only — enable it only if you accept transcripts on host disk; see Session resume across container recreation.
The egress firewall is the key compensating control that is still missing. It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely.
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, revoke the OAuth session under Account
- OpenAI / Codex: platform.openai.com/api-keys, revoke session under Profile
- Cursor: dashboard → Integrations, rotate API key + revoke CLI session
- GitHub:
gh auth refreshor revoke the token at github.com/settings/tokens
For high-trust enterprise environments where the container should not even be able to read host CLI state, remove the three read-only stage binds (/host/.claude, /host/.codex, /host/.cursor) — plus /host/.claude-mem and /host/.claude.json — from .devcontainer/devcontainer.json. With no stage to copy from, post-create.sh's seed and credential-sync steps quietly do nothing (their [ -f ] / [ -d ] guards), and each devcontainer starts with empty, fully isolated config and credentials (Anthropic's reference pattern). You give up seeding your host setup into the container in exchange for removing host config/credentials from the container's read surface entirely; log in inside each container instead.
First-time CLI authentication
Each CLI works either way:
- Log in on host first → the container picks it up automatically on the next rebuild (
sync_from_hostcopies the credential file into the named volume duringpost-create.sh). Host stays the source of truth. - Log in inside the container → credentials write to the named volume. They persist across ordinary rebuilds (volume is keyed by
${devcontainerId}, which is stable for a given workspace folder). The host's credentials are untouched.
You can mix and match per-CLI. A common setup is "Claude logged in on host, Codex/Cursor logged in inside container".
Claude Code
claude login
Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback automatically. After auth, ~/.claude/ is populated and visible from both host and container. The DISABLE_AUTOUPDATER=1 env var prevents the in-container CLI from auto-updating — rebuild the container to pick up a newer Claude Code.
OpenAI Codex CLI
codex login --device-auth
The device-code flow prints a URL and a one-time code. Visit the URL on your host browser, paste the code, and the CLI authenticates without needing a callback listener — this is the most reliable path inside containers. Credentials land in ~/.codex/auth.json (shared with host).
codex login (browser-callback variant) also works but can be flaky in some headless contexts; prefer --device-auth.
Cursor CLI
cursor-agent login
Opens a browser auth flow; VS Code's port forwarding handles the callback. Credentials persist in ~/.cursor/cli-config.json (shared with host).
Verify any time with cursor-agent status.
Alternative: API key authentication (CI / headless)
For non-interactive use (CI runners, automated scripts), all three CLIs accept API keys via env vars:
| CLI | Env var | Where to get the key |
|---|---|---|
| Claude Code | ANTHROPIC_API_KEY |
https://console.anthropic.com/settings/keys |
| Codex | OPENAI_API_KEY |
https://platform.openai.com/api-keys |
| Cursor | CURSOR_API_KEY |
Cursor dashboard → Integrations |
These env vars are intentionally not injected into the container from the host. ${localEnv:VAR} resolves an unset host variable to an empty string, and some CLIs (Cursor in particular) treat a set-but-empty key as "use this key" rather than "fall back to stored login" — which would silently break the login flow for everyone who hasn't pre-set the host var.
To use an API key inside the container, export it in your terminal session:
export ANTHROPIC_API_KEY=sk-ant-...
# or OPENAI_API_KEY, or CURSOR_API_KEY
For persistence across container shells, carry the export via your VS Code dotfiles repository. VS Code clones the dotfiles repo into the container on attach and runs your install command, so the export lands in ~/.bashrc / ~/.zshrc per your own setup — and your API keys stay out of this repo's committed devcontainer.json.
A non-empty API key env var takes precedence over stored login credentials for each CLI.
Port forwarding
| Port | Service | Notes |
|---|---|---|
5173 |
Vite dev server (gitnexus-web) |
Auto-forwarded with notification |
4747 |
gitnexus serve HTTP API |
Must not be remapped — gitnexus-web hardcodes http://localhost:4747 as the default backend URL |
4173 |
Static web (Vite preview) | Silently forwarded |
VS Code's Ports panel shows forwarded ports once their listener starts.
Known gotchas
- LadybugDB integration tests may fail in containers (file-locking,
AGENTS.md§ Testing). Default tonpm run test:unitinside the container; run integration tests on the host. Tracking issue: documented as a known limitation. - Single-writer LadybugDB constraint (
GUARDRAILS.md§ LadybugDB lock). Don't rungitnexus analyzeon the host and inside the container against the same.gitnexus/directory simultaneously — the second writer will getdatabase busy. - Native grammar builds add ~30s to first install. Tree-sitter Dart/Proto/Swift grammars build during
gitnexus'spostinstall. To skip them (loses parsing for those three languages), setGITNEXUS_SKIP_OPTIONAL_GRAMMARS=1in your shell or add it toremoteEnvand rebuild. tree-sitter-kotlinwarnings on install are expected (perAGENTS.md). Ignore them..mcp.jsonworks inside the container:npx -y gitnexus@latest mcpresolves cleanly because npm registry is reachable and the workspace bind mount exposes the same.mcp.jsonthe host sees.- Husky pre-commit fires inside the container without extra setup. The root
npm install(run automatically inpostCreateCommand) installs the hook viapackage.jsonprepare.
Rebuild / reset
- Rebuild Container (Command Palette) — re-runs the Dockerfile build and
postCreateCommandagainst the existing named volumes (auth, history, and sessions persist). - Rebuild Container Without Cache — fresh image layers, same volumes.
- To force a re-login / clear an
EACCES— remove the per-container config volumes and rebuild. As of the session-volume change this no longer drops your--resumehistory (sessions are on separate volumes — see Session resume):
⚠️ Since the shareable dirs are now seeded into the config volume (not bind-mounted), wipingdocker volume ls | grep -- -config- # the credential / identity volumes docker volume rm claude-config-<id> codex-config-<id> cursor-config-<id> gh-config-<id><cli>-configalso discards any plugin/skill/agent/command you installed inside the container and re-seeds those dirs from the host on the next rebuild. That is the intended way to pull host-side config changes in, but if you have in-container-only plugins you want to keep, reinstall them after the rebuild (or install them on the host first so the re-seed brings them along). - To also wipe session history (a true clean slate) — remove the session volumes too (
<name>is your workspace folder name):
Then rebuild.docker volume ls | grep -E -- '-(sessions|cursor-projects)-' # the group-6 volumes docker volume rm <name>-claude-sessions-<id> <name>-codex-sessions-<id> \ <name>-cursor-sessions-<id> <name>-cursor-projects-<id> - To re-seed claude-mem from the host (the container's memory has diverged and you want the host's current store back) — remove the claude-mem volume and rebuild;
post-create.shcopies the host store in again on the next create:docker volume rm claude-mem-<id>
Bumping CLI versions
Bump the version pins in .devcontainer/devcontainer.json build.args and rebuild — all three are real, fail-loud pins. Claude Code installs via npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} and Codex via npm install -g @openai/codex@${CODEX_VERSION}. Cursor is pinned too: bump CURSOR_VERSION and both CURSOR_SHA256_X64 / CURSOR_SHA256_ARM64 together — the Dockerfile downloads the pinned downloads.cursor.com/lab/<version>/linux/<arch>/agent-cli-package.tar.gz artifact directly (no remote install script) and fails the build on a sha256 mismatch. Re-hash each arch with curl -fSL <url> | sha256sum. To stop Cursor from auto-updating in the running container, don't call cursor-agent update.
What's not included (yet)
- Egress firewall — the most important hardening still outstanding. The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR —
runArgsis static indevcontainer.json, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separatedevcontainer-firewall.jsonprofile or aninitializeCommand-generated overlay. Until it lands, the read surface in § Trust boundary has no network containment — anything readable can be exfiltrated. Track at the project's issue tracker if you need this. - 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'snpm run test:e2eneeds Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
GitNexus devcontainer one-time Windows setup banner from initializeCommand |
First-time Windows-native Reopen-in-Container; HOME env var was missing |
The script just ran setx HOME "%USERPROFILE%" for you. Close ALL VS Code windows (File → Exit) and reopen — see Windows 11 setup |
bind source path does not exist: /.claude (or similar) from Docker |
Windows-native HOME env var is still missing even after one rebuild — setx may have failed or VS Code wasn't fully restarted |
Run setx HOME "%USERPROFILE%" in a Windows shell manually, fully exit VS Code (check Task Manager that no Code.exe remains), reopen |
EACCES / EPERM writing into ~/.claude, ~/.codex, or ~/.cursor inside the container |
Stale state from a previous container with a different effective UID | Move the affected dir aside and let the CLI rebuild it (mv ~/.claude ~/.claude.bak and log in again). Long-term: WSL2 setup, which doesn't hit this class of issue |
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 for persistence |
git commit produces commits with empty author |
~/.gitconfig is missing or empty on the host (VS Code's auto-copy had nothing to copy) |
Set git config --global user.name "Your Name" and git config --global user.email "you@example.com" from the host shell, then rebuild the container |
gh: not logged in inside the container |
Not logged in on the host (nothing to seed), or the gh-config volume is empty |
Just run gh auth login inside the container — gh config lives in a writable per-container volume, so the login persists across rebuilds. (Logging in on the host instead also works: it seeds in on the next container create.) |