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).
This commit is contained in:
Gergo Magyar 2026-05-28 13:52:38 +01:00
parent 32cb7dc28a
commit f592c804ed
3 changed files with 56 additions and 18 deletions

View file

@ -6,9 +6,10 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI,
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).
2. Install [VS Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
3. Open the repo in VS Code → Command Palette → **Dev Containers: Reopen in Container**.
4. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
5. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
3. Install [Node.js](https://nodejs.org/) on the **host** (Node 18+). This is the only host-side toolchain dependency beyond Docker and VS Code — the devcontainer's `initializeCommand` runs `node .devcontainer/ensure-host-config-dirs.cjs` to 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.
4. Open the repo in VS Code → Command Palette → **Dev Containers: Reopen in Container**.
5. Wait for the first build (~3–6 minutes) and `postCreateCommand` to finish installing workspace dependencies.
6. Authenticate the three CLIs once — see [First-time CLI authentication](#first-time-cli-authentication) below.
## Windows 11 — WSL2 is strongly recommended
@ -58,13 +59,16 @@ The following directories inside the container are **bind-mounted directly from
| `~/.codex` | `$HOME/.codex` | read-write |
| `~/.cursor` | `$HOME/.cursor` | read-write |
| `~/.gitconfig` | `$HOME/.gitconfig` | **read-only** |
| `~/.config/git` | `$HOME/.config/git` | **read-only** |
| `~/.ssh` | `$HOME/.ssh` | **read-only** |
| `~/.config/gh` | `$HOME/.config/gh` | read-write |
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 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.
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email`. The mount is read-only so container-side `git config --global` doesn't leak to your host config — set those values from the host shell.
- **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email` from `~/.gitconfig` and any XDG-style config under `~/.config/git/`. The mounts are read-only so container-side `git config --global` doesn't leak to host config — set those values from the host shell.
- **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.
- **`gh` auth is shared.** `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating.
- **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do.
@ -72,7 +76,7 @@ The bind mount source directories are guaranteed to exist by the `initializeComm
### 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 with these bind mounts active, has direct read access to your OAuth refresh tokens for all three CLIs, your `gh` token, and `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill). The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would also have unrestricted network to exfiltrate.
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 with these bind mounts active, has direct read access to your OAuth refresh tokens for all three CLIs, your `gh` token, **your SSH private keys under `~/.ssh/`**, and `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill). Read-only mounts on `~/.ssh`, `~/.gitconfig`, and `~/.config/git` prevent container code from modifying or deleting them, but they're still readable. The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would also have unrestricted network to exfiltrate.
**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:

View file

@ -19,19 +19,16 @@
}
},
// Runs on the HOST shell before the container is created. Guarantees the
// bind-mount source directories below exist so Docker doesn't reject the
// mount when a CLI has never been used on this host. OS-keyed because
// VS Code runs the host shell in its native form (POSIX on Linux/macOS,
// cmd.exe on Windows — which means `mkdir -p` + `$HOME` won't work on
// Win32). All three branches are idempotent. WSL is covered by the
// linux branch because VS Code's WSL extension runs initializeCommand
// in the WSL shell.
"initializeCommand": {
"linux": "mkdir -p $HOME/.claude $HOME/.codex $HOME/.cursor $HOME/.config/gh && touch $HOME/.gitconfig",
"darwin": "mkdir -p $HOME/.claude $HOME/.codex $HOME/.cursor $HOME/.config/gh && touch $HOME/.gitconfig",
"win32": "powershell -NoProfile -Command \"$d=$env:USERPROFILE; foreach ($p in '.claude','.codex','.cursor','.config\\gh') { $f=Join-Path $d $p; if (-not (Test-Path $f)) { New-Item -ItemType Directory -Force -Path $f | Out-Null } }; if (-not (Test-Path (Join-Path $d '.gitconfig'))) { New-Item -ItemType File -Path (Join-Path $d '.gitconfig') | Out-Null }\""
},
// Runs on the HOST (not the container) before container create. The
// single-string form is the spec-canonical shape for cross-platform
// command-property dispatch; the object form is "named parallel tasks",
// not OS dispatch. We use Node so the same command works in cmd.exe on
// Windows and bash/zsh on Linux/macOS/WSL — the script reads `os.homedir()`
// (which respects $HOME on POSIX and %USERPROFILE% on Windows) and creates
// the host-side bind mount sources idempotently. Host prerequisite: Node
// on PATH (the only host-side toolchain dependency beyond Docker Desktop
// and the VS Code Dev Containers extension).
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
@ -70,6 +67,8 @@
"source=${localEnv:HOME}/.codex,target=/home/node/.codex,type=bind",
"source=${localEnv:HOME}/.cursor,target=/home/node/.cursor,type=bind",
"source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly",
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",

View file

@ -0,0 +1,35 @@
// Runs on the HOST (not inside the container) before the dev container is
// created, via devcontainer.json `initializeCommand`. Guarantees the bind
// mount sources declared in devcontainer.json exist on the host so Docker
// doesn't reject the mount when a CLI has never been used.
//
// Cross-platform via Node's `os.homedir()` (which reads $HOME on POSIX and
// %USERPROFILE% on Windows) and `fs.mkdirSync({recursive: true})`. Idempotent
// — `recursive: true` is a no-op when a directory already exists, and the
// `.gitconfig` touch is gated on file existence.
//
// Host prerequisite: Node.js on PATH. This is the only documented host
// requirement beyond Docker Desktop and the VS Code Dev Containers
// extension — everything else runs inside the container.
const fs = require("fs");
const os = require("os");
const path = require("path");
const home = os.homedir();
for (const dir of [
".claude",
".codex",
".cursor",
".ssh",
path.join(".config", "gh"),
path.join(".config", "git"),
]) {
fs.mkdirSync(path.join(home, dir), { recursive: true });
}
const gitconfig = path.join(home, ".gitconfig");
if (!fs.existsSync(gitconfig)) {
fs.closeSync(fs.openSync(gitconfig, "a"));
}