From f592c804edc4d3a7bbed0c57603672e38c5f1494 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 13:52:38 +0100 Subject: [PATCH] fix(devcontainer): Node-based initializeCommand; bind-mount .ssh + .config/git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .devcontainer/README.md | 14 +++++---- .devcontainer/devcontainer.json | 25 ++++++++-------- .devcontainer/ensure-host-config-dirs.cjs | 35 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 .devcontainer/ensure-host-config-dirs.cjs diff --git a/.devcontainer/README.md b/.devcontainer/README.md index a6bcd3e60..fd8001b53 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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//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//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//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: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cae21a5ed..75eccc5c3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs new file mode 100644 index 000000000..7e6e95b57 --- /dev/null +++ b/.devcontainer/ensure-host-config-dirs.cjs @@ -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")); +}