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.
This commit is contained in:
Gergo Magyar 2026-05-28 12:27:40 +01:00
parent a75fc2e060
commit 35e61a9e66
3 changed files with 68 additions and 12 deletions

View file

@ -43,9 +43,23 @@ Open the repo folder in VS Code → **Reopen in Container**. The image is multi-
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 is shared with your host
`~/.claude`, `~/.codex`, and `~/.cursor` inside the container are **bind-mounted directly from your host's `$HOME`**. That means:
- **Authentication is shared.** If you're already logged in on the host (`claude login`, `codex login`, `cursor-agent login`), you're already logged in inside the container. No second login step.
- **Plugins, skills, agents, memory, and settings sync both ways.** Install a plugin from inside the container and it shows up on the host; add a custom agent on the host and the container sees it immediately. The auto-memory store at `~/.claude/projects/<workspace>/memory/` is the same file tree from both sides.
- **No per-workspace duplication.** All your devcontainers across all your projects see the same `.claude`/`.codex`/`.cursor` content, just like all your host shells do.
The bind mount source paths are guaranteed to exist by `.devcontainer/ensure-host-config-dirs.cjs`, which `initializeCommand` runs on the host before the container is created.
For a high-trust enterprise environment where you don't want container code to be able to touch host credentials, replace the three `type=bind` entries for `.claude`/`.codex`/`.cursor` in `.devcontainer/devcontainer.json` with `type=volume` named volumes (Anthropic's reference pattern). Most personal-dev setups don't need that isolation — host and container share the same trust boundary.
## First-time CLI authentication
**Interactive login is the default for all three CLIs.** Credentials persist in per-workspace named volumes scoped by `${devcontainerId}`, so you authenticate once per project — subsequent rebuilds reuse the stored credentials.
If you already use these CLIs on the host, **skip this section** — your existing logins are already in scope inside the container.
If a CLI is brand-new on this host, log in from inside _or_ outside the container; either populates the shared `~/.<cli>` directory.
### Claude Code
@ -53,7 +67,7 @@ Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID:
claude login
```
Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback automatically. After auth, `~/.claude/` is populated in the named volume and persists across rebuilds. The `DISABLE_AUTOUPDATER=1` env var prevents the running CLI from updating itself — rebuild the container to pick up a newer Claude Code.
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
@ -61,7 +75,7 @@ Opens a browser auth flow. VS Code's port forwarding handles the OAuth callback
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 persist in `~/.codex/auth.json` inside the named volume.
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`.
@ -71,7 +85,7 @@ The device-code flow prints a URL and a one-time code. Visit the URL on your hos
cursor-agent login
```
Opens a browser auth flow; VS Code's port forwarding handles the callback. After auth, credentials persist in `~/.cursor/cli-config.json` inside the named volume.
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`.
@ -146,7 +160,7 @@ Three build args control pinned versions:
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `EACCES` on first `claude login` / `codex login` / `cursor-agent login` | Named volume mount got a stale state | `docker volume rm` the relevant `*-config-<devcontainerId>` volume and rebuild |
| `EACCES` / `EPERM` writing into `~/.claude`, `~/.codex`, or `~/.cursor` inside the container | Windows-side bind-mount permission translation got out of sync after a UID change between rebuilds | On the host, ensure your user owns the directory tree; if it's truly stuck, move the affected dir aside and let the CLI rebuild it (`mv ~/.claude ~/.claude.bak` and log in again). Long-term: clone in WSL2 — bind-mount permission classes don't apply to WSL-side filesystems |
| `EPERM: operation not permitted, copyfile ... '.husky/_/h'` in `postCreateCommand` | Leftover `.husky/_/` from a previous container run; Docker Desktop's Windows bind mount won't let the new container's `node` user overwrite it. `postCreateCommand` already runs `rm -rf .husky/_` defensively, but if you hit it on an older config, delete `.husky/_/` on the host (`rm -rf .husky/_`) and rebuild | Long-term: clone the repo inside WSL2 (see [Windows 11 WSL2 setup](#windows-11-primary-host--wsl2-setup)) — WSL-side filesystems don't have this bind-mount class of issue |
| Vite never hot-reloads on Windows | Repo cloned on Windows side, not WSL2 | Re-clone inside WSL2 (see [WSL2 setup](#windows-11-primary-host--wsl2-setup)) |
| `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` |

View file

@ -18,6 +18,12 @@
}
},
// Runs on the HOST before the container is created. Ensures the bind
// mount sources (~/.claude, ~/.codex, ~/.cursor) exist so Docker doesn't
// reject the mount when a CLI has never been used on this host. Safe
// re-run; idempotent. See ensure-host-config-dirs.cjs.
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
// Anthropic's official Claude Code Feature pulls the latest stable at
// build time; DISABLE_AUTOUPDATER below locks it inside the running
// container so rebuild is the only way the version changes.
@ -32,14 +38,25 @@
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
// Named volumes scoped per-devcontainer keep auth tokens, history, and
// node_modules persistent across rebuilds without leaking between
// workspaces. Sub-workspace node_modules volumes keep tree-sitter native
// binaries and onnxruntime off the bind mount (the real Win/Mac perf win).
// CLI config dirs are bind-mounted from the host so the developer's
// existing plugins, skills, agents, memory, settings, and credentials
// for Claude Code / Codex / Cursor are immediately available inside the
// container, and changes inside the container flow back to the host.
// The `initializeCommand` above guarantees these source paths exist on
// first up so Docker never errors out on a missing bind source. Anthropic
// recommends per-devcontainer named volumes instead in enterprise /
// high-trust environments; for personal dev where the host + container
// share the same trust boundary, bind mounts give the better daily-driver
// experience.
//
// Shell history, npm cache, and node_modules stay in named volumes
// scoped per-workspace — history doesn't need to escape the workspace,
// node_modules belong off the bind mount for Win/Mac perf, and the npm
// cache wants the container-native FS.
"mounts": [
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind",
"source=${localEnv:HOME}/.codex,target=/home/node/.codex,type=bind",
"source=${localEnv:HOME}/.cursor,target=/home/node/.cursor,type=bind",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
"source=${localWorkspaceFolderBasename}-root-node-modules,target=/workspace/node_modules,type=volume",

View file

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