From 35e61a9e665d43bca1cc476aff8fb2ac85ded278 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 12:27:40 +0100 Subject: [PATCH] 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. --- .devcontainer/README.md | 24 ++++++++++++++---- .devcontainer/devcontainer.json | 31 ++++++++++++++++++----- .devcontainer/ensure-host-config-dirs.cjs | 25 ++++++++++++++++++ 3 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 .devcontainer/ensure-host-config-dirs.cjs diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 290366970..bdaa69d79 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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//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 `~/.` 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-` 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` | diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 07c0a0f29..55e21ec74 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs new file mode 100644 index 000000000..90cccbdf8 --- /dev/null +++ b/.devcontainer/ensure-host-config-dirs.cjs @@ -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 }); +}