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.
This commit is contained in:
Gergo Magyar 2026-05-28 21:48:37 +01:00
parent 0d4a53e84d
commit 1008b0dcf9
8 changed files with 485 additions and 103 deletions

View file

@ -12,9 +12,13 @@ FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# devcontainer-canonical pin.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
ARG CURSOR_VERSION
ARG TZ=UTC
ARG USERNAME=node
# NOTE: there is intentionally no CURSOR_VERSION ARG. The cursor.com/install
# script does not honour a version pin, so an ARG would be dead config that
# implies a guarantee the installer can't keep. Cursor is currently installed
# unpinned (see the install step below); pinning is tracked as a follow-up in
# README § "What's not included (yet)".
# Promote build-only ARGs into runtime ENV so shells and lifecycle scripts
# can read them. CLAUDE_CONFIG_DIR is intentionally NOT set here — the
@ -22,7 +26,6 @@ ARG USERNAME=node
# of truth; runtime-time wins anyway).
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
TZ=${TZ} \
DEVCONTAINER=true \
NODE_OPTIONS=--max-old-space-size=4096 \
@ -69,10 +72,15 @@ RUN npm install -g \
# reliable. Long-term hardening (tracked as a follow-up): pin a specific
# downloads.cursor.com/lab/<version>/<arch>/agent-cli-package.tar.gz URL
# with a hard sha256 verification and skip the install script entirely.
#
# `timeout 300` wraps the installer because the script makes its OWN network
# requests (it downloads the actual Cursor binary) that `curl --max-time`
# above does NOT cover — without it a slow/hung Cursor CDN would block the
# Docker build indefinitely with no watchdog.
RUN curl -fsS --retry 3 --max-time 60 -o /tmp/cursor-install.sh https://cursor.com/install \
&& echo "Cursor installer sha256:" \
&& sha256sum /tmp/cursor-install.sh \
&& bash /tmp/cursor-install.sh \
&& timeout 300 bash /tmp/cursor-install.sh \
&& rm -f /tmp/cursor-install.sh
# ~/.local/bin (where Cursor's installer drops `agent` and `cursor-agent`

View file

@ -132,6 +132,8 @@ Install a plugin on the host or inside the container — both sides see it immed
| `~/.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 `gh`/`docker` are read-write but `ssh`/`aws`/`azure` are read-only:** `gh` and `docker` are *CLIs that write their own state* — `gh auth login` / `gh auth refresh` rewrite `hosts.yml`, and `docker login` / buildx write `config.json`. Read-write lets those work inside the container and keeps host ↔ container auth in sync. `ssh`/`aws`/`azure` are consumed *read-only* (the SSH client and the AWS/Azure SDKs only read their credential files), so they get the one-way mount that can't be written back from a compromised container. The cost of the `gh`/`docker` read-write choice: a compromised in-container dep can rewrite your host `~/.config/gh/hosts.yml` or `~/.docker/config.json` (e.g. point a credHelper at an attacker binary). If you don't need in-container `gh`/`docker login` to persist to the host, add `,readonly` to those two mounts in `devcontainer.json` to remove the write-back surface.
`~/.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.
@ -142,6 +144,10 @@ If a host source dir doesn't exist when the container is first created, the `ini
- **Codex on macOS / Linux with `cli_auth_credentials_store = "keyring"`** stores auth in the OS keyring (Keychain / Secret Service), so `~/.codex/auth.json` may not exist on host. Same fallback: `codex login --device-auth` inside the container.
- **Cursor CLI inside containers** has [known upstream auth issues](https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995) — even with a correctly-synced `cli-config.json`, you may need to re-run `cursor-agent login` inside 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 with `docker 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-scope `mcpServers` entries come along. But an entry whose `command` is 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 uses `npx -y gitnexus@latest mcp`) and remote/URL servers work unchanged. The path-translation pass only rewrites `*/.​<cli>/plugins/*` registry paths, **not** arbitrary `mcpServers` command paths (there's no correct container target for a host-local binary). Install such MCP servers inside the container, or use `npx`/remote ones.
- **User-scope config is synced once per container-create, then diverges.** A `mcpServers` entry, plugin enable, or setting you add **on the host after** the container was created is not visible in the container until the next rebuild (these files are copy-on-create, not bind-mounted — single-file binds trip EXDEV on Docker Desktop Windows). Rebuild to pick up host-side config changes. Shareable *dirs* (plugins/skills/agents/memory/commands) are live-bound and do not have this lag.
- **Plugins installed in-container before a host install get shadowed on rebuild.** If you ran `/plugin marketplace add` (or `codex plugin add`) inside the container while the matching host dir was empty, the extracted files landed in the named volume. On the next rebuild the (empty) host bind mount overlays that sub-path and the volume content becomes invisible (masked, not deleted — Docker mount precedence). The plugins appear to vanish. Recovery: re-run the install (it now writes through to the host), or install on the host. Installing on the host is the durable path since the host dir is the bind source.
- **Plugin installs are single-writer across checkouts.** `${devcontainerId}` isolates each container's *credential volume*, but the host plugin dirs (`~/.<cli>/plugins/...`) are the **one** shared bind source across every GitNexus checkout and every running container. Two containers running `/plugin marketplace add` / `codex plugin add` against the same host dir simultaneously can interleave their git clones/extractions. Install plugins from one container (or the host) at a time — same single-writer expectation as `gitnexus analyze` against a shared `.gitnexus/`.
### What you still don't have inside the container
@ -154,7 +160,7 @@ These are commonly-needed CLIs that aren't installed by default — adding them
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.
- **Plugins, skills, agents, memory, and commands sync both ways, live.** 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 memory store at `~/.claude/memory/` is the same file tree from both sides. (`settings.json` and the user-scope `~/.claude.json` are *not* in this live set — they're copy-on-create, so host edits to them need a rebuild; see the quirks above. `~/.claude/projects/` is container-local by design, so per-project trust/history don't leak between host and container.)
- **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 `~/.gitconfig` into 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 `~/.gitconfig` on the host (container-side `git config --global` writes 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.
- **`gh` auth is shared.** `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating.
@ -166,15 +172,17 @@ The bind mount source directories are guaranteed to exist by the `initializeComm
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** at `/host/.claude`, `/host/.codex`, `/host/.cursor` (mounted read-only) — including `.credentials.json`, `auth.json`, `cli-config.json`, plugins, skills, agents, memory store
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on first run)
- `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill)
- **Host AI CLI state** — the read-only stage at `/host/.claude`, `/host/.codex`, `/host/.cursor` (credentials + identity), **and** the read-write-bound shareable dirs (plugins, skills, agents, memory, commands, etc.) which ARE your host directories
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on container-create)
- `~/.claude/memory/` / per-project memory (which may contain user-stored secrets if you've used the `/remember` skill)
- Your **`gh` token** (`~/.config/gh`)
- Your **SSH private keys** (`~/.ssh/`)
- Docker registry tokens in **`~/.docker/config.json`** (if you've `docker login`-ed)
- AWS/Azure CLI credentials if you've populated `~/.aws/` or `~/.azure/`
What this design **does** prevent (vs. a full bidirectional bind mount): a malicious dep cannot **write back** to your host `~/.claude/plugins/`, `~/.claude/agents/`, or `~/.claude/skills/`. The read-only `/host` mount blocks the write. That matters because a host write-through would mean a single in-container compromise persists across container teardown — your next host Claude session would auto-load the malicious agent. Read-only mounts on `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure` give the same one-way property for those credentials.
It also has **write-through** to host for the shareable dirs — this is the deliberate cost of bidirectional plugin/skill/memory sync, **not** something the design prevents. A compromised in-container dep CAN write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, and `~/.cursor/{plugins,rules,commands,agents,skills}/`. Because several of those (agents, commands, skills, rules) are instruction/shell-executing surfaces an agent auto-loads, that write-through means a single in-container compromise can persist across container teardown and run in your next **host** session. The only deliberately-withheld auto-executing surface is Cursor's `hooks.json` (synced read-only via copy-on-create, never bound) because hooks fire without an agent invoking them — but that is a narrowing of the surface, not a closed boundary.
**What stays one-way (genuinely protected):** 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. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure` are read-only binds with the same one-way property. If you need the shareable dirs to be one-way too, switch them from `type=bind` to copy-on-create (or to named volumes — see the enterprise note below).
The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would still have unrestricted network to exfiltrate what it can read.

View file

@ -1,8 +1,9 @@
// Devcontainer for GitNexus. Pre-installs Claude Code, OpenAI Codex CLI,
// and Cursor CLI alongside the Node.js native build chain. Cross-platform
// across macOS, Linux, and Windows-via-WSL2 (Windows-native is unsupported
// — see .devcontainer/README.md § Windows 11 setup). Opens via the VS Code
// Dev Containers extension.
// across macOS, Linux, Windows-via-WSL2, and Windows-native (the latter
// needs a one-time HOME setup performed automatically by
// initializeCommand — see .devcontainer/README.md § Windows 11 setup).
// Opens via the VS Code Dev Containers extension.
//
// First-time setup, auth flows, and troubleshooting: .devcontainer/README.md.
{
@ -14,7 +15,6 @@
"args": {
"CLAUDE_CODE_VERSION": "2.1.153",
"CODEX_VERSION": "0.134.0",
"CURSOR_VERSION": "latest",
"TZ": "${localEnv:TZ:UTC}"
}
},
@ -42,23 +42,29 @@
// Mount topology, by group:
//
// 1. AI CLI host config — read-only bind at /host/.<cli>. `post-create.sh`
// selectively symlinks shareable subdirs (plugins/skills/agents/memory/
// commands) into the container's named-volume config dirs, and copies
// credentials + `.claude.json` (the onboarding-state file Claude Code
// looks for at $HOME, *outside* ~/.claude/) on first run. Read-only
// eliminates write-through from container to host — a compromised npm
// dep can't drop an `agents/evil.md` into your host ~/.claude/agents/
// that the next host Claude session would auto-load. The host stays
// the source of truth for shared content; install new plugins on the
// host and rebuild the container to pick them up.
// 1. AI CLI host config — TWO roles. (a) A read-only stage at /host/.<cli>
// that `post-create.sh` COPIES credentials + identity + single config
// files from on container-create (read-only so the credential snapshot
// can't be written back to host). (b) Direct READ-WRITE bind mounts of
// the shareable subdirs (Claude plugins/skills/agents/memory/commands;
// Codex plugins/prompts/memories/skills; Cursor plugins/rules/commands/
// agents/skills) overlaid onto the named volume at their sub-paths.
// Those RW binds are BIDIRECTIONAL: installing a plugin in the container
// writes straight to the host, and vice versa. This is a deliberate
// write-through trade-off — a compromised npm dep CAN drop files into
// your host plugin/agent/skill dirs that the next host session loads.
// See README § "Trust boundary, concretely". Credentials never join
// this surface — they stay in the volume (role 2).
//
// 2. AI CLI container config — per-devcontainer named volumes. These are
// what CLAUDE_CONFIG_DIR / CODEX_HOME / ~/.cursor actually point at.
// Credentials live here with proper Linux perms. Container-managed
// state (sessions, history, caches, projects/, settings.json) stays
// isolated per devcontainer, so two GitNexus checkouts on the same
// host don't corrupt each other's chat history or IDE lock files.
// 2. AI CLI container config — per-devcontainer named volumes. CODEX_HOME
// points here; CLAUDE_CONFIG_DIR is intentionally unset so it resolves
// to the default ~/.claude (same path). Credentials + identity +
// single config files (.credentials.json, ~/.claude/.claude.json,
// settings.json, config.toml, cli-config.json, mcp.json) live here with
// proper Linux perms and are NOT bind-mounted (single-file binds trip
// EXDEV on Docker Desktop Windows). Container-managed state (sessions,
// history, caches, IDE locks) stays isolated per devcontainer, so two
// GitNexus checkouts on the same host don't corrupt each other.
//
// 3. Other host config — read-write or read-only bind mounts for things
// that don't have the perm-flattening / onboarding-state complexity
@ -143,9 +149,17 @@
// absolute Windows paths like Claude's registry, so it stays in the
// volume and is translated by post-create.sh — that's why plugins/
// sub-dirs are bound individually rather than the whole plugins/ dir.
// mcp.json + hooks.json are single files (EXDEV-unsafe as binds) and
// are handled as copy-on-create; hooks.json is intentionally NOT
// shared (it executes shell commands — supply-chain surface).
// mcp.json + hooks.json are single files (EXDEV-unsafe as binds) and are
// handled as copy-on-create. hooks.json is additionally NOT synced at
// all: Cursor hooks run shell commands on a timer/event with no user
// action, so a poisoned host hooks.json would auto-execute in the
// container. NOTE this is a partial mitigation, not a clean boundary —
// the RW-bound commands/agents/skills/rules dirs (here and for Claude)
// are also instruction/shell-executing surfaces a compromised dep can
// write through to host. The hooks.json exclusion just removes the one
// surface that fires WITHOUT an agent deciding to invoke it; the broader
// write-through trade-off is accepted + documented in README § Trust
// boundary. To fully close it, switch these dirs to copy-on-create too.
"source=${localEnv:HOME}/.cursor/plugins/marketplaces,target=/home/node/.cursor/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.cursor/plugins/local,target=/home/node/.cursor/plugins/local,type=bind",
"source=${localEnv:HOME}/.cursor/rules,target=/home/node/.cursor/rules,type=bind",
@ -179,9 +193,14 @@
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
],
// Interactive login is the default auth path for all three CLIs;
// credentials persist in the host-bind-mounted directories (~/.claude,
// ~/.codex, ~/.cursor) declared in the mounts block above.
// Interactive login is the default auth path for all three CLIs.
// Credentials live in the per-container named volumes (claude-config /
// codex-config / cursor-config), NOT in the host bind mounts — they are
// copied from the read-only /host/.<cli> stage into the volume on
// container-create (single-file binds would trip EXDEV on Docker Desktop
// Windows). Only shareable content (plugins, skills, agents, memory,
// commands) is RW-bound to the host ~/.claude / ~/.codex / ~/.cursor
// dirs declared in the mounts block above.
// API keys (ANTHROPIC_API_KEY / OPENAI_API_KEY / CURSOR_API_KEY) are NOT
// injected via containerEnv — `${localEnv:VAR}` resolves an unset host var
// to an empty string, and Cursor in particular treats `CURSOR_API_KEY=""`
@ -208,6 +227,15 @@
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
// post-create.sh strips `installMethod` from the seeded ~/.claude.json so
// the npm-global binary auto-detects its own install method. This is
// belt-and-suspenders for Claude Code issue #17289: the install-checks
// routine probes ~/.local/bin/claude purely because the directory EXISTS
// (it does here — Cursor drops agent/cursor-agent symlinks there) even
// when installMethod is non-native, surfacing a false "claude command not
// found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT gate that
// routine — DISABLE_INSTALLATION_CHECKS is its dedicated kill switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},

View file

@ -7,19 +7,27 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[post-create] 1/2: chown AI CLI named-volume mount points"
# Named volumes (~/.claude, ~/.codex, ~/.cursor, /commandhistory,
# ~/.local) inherit ownership from the image's pre-realignment UID at
# first mount. After `updateRemoteUserUID: true` shifts the `node` user,
# these end up owned by the stale UID — writes inside the volume fail.
# The named volumes (~/.claude, ~/.codex, ~/.cursor, /commandhistory)
# inherit ownership from the image's pre-realignment UID at first mount.
# After `updateRemoteUserUID: true` shifts the `node` user, they end up
# owned by the stale UID — writes inside the volume fail. (~/.local is an
# image directory, not a volume, but is chowned defensively alongside.)
# install-deps.sh handles the workspace-side chown; this script handles
# the AI CLI side so each lifecycle hook owns its own concern.
sudo chown -R node:node \
/home/node/.claude \
/home/node/.codex \
/home/node/.cursor \
/home/node/.local \
/commandhistory
#
# `-xdev` keeps chown ON THE VOLUME filesystem and stops it descending into
# the RW host bind mounts overlaid at sub-paths (plugins/marketplaces,
# plugins/cache, skills, agents, memory, commands, codex/plugins, cursor/*).
# Those binds are a DIFFERENT filesystem (9p/virtiofs/bind); recursing into
# them would rewrite host file ownership on a non-UID-aligned Linux host and,
# worse, an EPERM there would abort provisioning before credentials sync.
for d in /home/node/.claude /home/node/.codex /home/node/.cursor \
/home/node/.local /commandhistory; do
sudo find "$d" -xdev -exec chown node:node {} +
done
echo "[post-create] 2/2: sync AI CLI credentials + identity from host"
# Defensive cleanup for users upgrading from an earlier devcontainer
@ -88,71 +96,29 @@ sync_from_host \
# Copy host's version into the named volume on container-create; container
# can rewrite freely from there until next rebuild resyncs.
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
sync_from_host /host/.claude.json /home/node/.claude.json 644
sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# Plugin registry path translation (Claude + Cursor). Both write absolute
# Seed $HOME/.claude.json from the host — but NOT verbatim. It mixes portable
# ACCOUNT/ONBOARDING state (hasCompletedOnboarding, oauthAccount, userID,
# projects, tipsHistory — keep) with HOST BINARY-MANAGEMENT state that is
# never valid here: this image installs Claude via `npm install -g`, but the
# host's `installMethod` (e.g. "native") makes Claude probe ~/.local/bin/claude
# and fail "claude command not found at /home/node/.local/bin/claude". The
# transform (strip machine fields + force hasCompletedOnboarding, guarding a
# non-object host file) lives in seed-claude-config.cjs so it is lintable and
# unit-tested (translate-plugin-registries.test.cjs).
node "$SCRIPT_DIR/seed-claude-config.cjs"
# Plugin registry path translation (Claude + Cursor). Both bake absolute
# OS-native install paths into their plugin registry JSONs —
# `C:\Users\X\.claude\plugins\...` on Windows, `/Users/X/.cursor/plugins/...`
# on macOS — so the host versions can't be bind-mounted into the Linux
# container (the CLI fails with `cache-miss` resolving a Windows path under
# Linux). For each CLI, read host's registry files, rewrite every absolute
# path ending in `/.<cli>/plugins/<rest>` to
# `/home/node/.<cli>/plugins/<rest>`, and write the result into the named
# volume. (Codex needs no translation — its enablement registry is
# config.toml with git URLs, not filesystem paths, so its whole plugins/
# dir is bind-mounted instead.)
node <<'NODE'
const fs = require("fs");
const path = require("path");
const buildRe = (cliName) =>
new RegExp(`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`);
const rewriteDeep = (obj, re, ctr) => {
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
if (obj && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
return out;
}
if (typeof obj === "string")
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, "/")}`);
return obj;
};
const REGISTRIES = [
{
cli: "claude",
host: "/host/.claude/plugins",
ctr: "/home/node/.claude/plugins",
files: ["known_marketplaces.json", "installed_plugins.json", "plugin-catalog-cache.json"],
},
{
cli: "cursor",
host: "/host/.cursor/plugins",
ctr: "/home/node/.cursor/plugins",
files: ["installed_plugins.json"],
},
];
for (const reg of REGISTRIES) {
const re = buildRe(reg.cli);
fs.mkdirSync(reg.ctr, { recursive: true });
for (const name of reg.files) {
const src = path.join(reg.host, name);
const dst = path.join(reg.ctr, name);
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
} catch {
continue;
}
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));
}
}
NODE
# Linux). The translation (regex + deep rewrite + the REGISTRIES table) lives
# in translate-plugin-registries.cjs so it is lintable and unit-tested. Codex
# needs no translation — its enablement registry is config.toml with git URLs,
# not filesystem paths, so its whole plugins/ dir is bind-mounted instead.
node "$SCRIPT_DIR/translate-plugin-registries.cjs"
# Codex auth. Hosts using OS keyring storage
# (`cli_auth_credentials_store = "keyring"`, default on macOS) have no

View file

@ -0,0 +1,78 @@
// Seeds the container's $HOME/.claude.json from the host's copy — but NOT
// verbatim. ~/.claude.json mixes portable ACCOUNT/ONBOARDING state
// (hasCompletedOnboarding, oauthAccount, userID, projects, tipsHistory — keep)
// with HOST BINARY-MANAGEMENT state that is never valid in this container:
// the image installs Claude via `npm install -g`, but the host's
// `installMethod` (e.g. "native") makes Claude expect/probe ~/.local/bin/claude
// and fail with "claude command not found at /home/node/.local/bin/claude". So
// we DROP the install/machine fields and let the npm-global binary auto-detect
// its own method, and force hasCompletedOnboarding so the wizard is skipped
// even on a first-time host.
//
// Extracted from a post-create.sh heredoc so the pure transform is lintable
// and unit-tested (see seed-claude-config.test via translate-plugin-registries
// test harness). DISABLE_AUTOUPDATER=1 (containerEnv) already neutralizes
// runtime updates; this purely silences the doctor mismatch + native probe.
"use strict";
const fs = require("fs");
// Host binary-management / machine-install fields — never valid for an
// `npm install -g` container. Stripping lets Claude auto-detect npm-global.
const MACHINE_FIELDS = [
"installMethod",
"autoUpdates",
"autoUpdatesProtectedForNative",
"shiftEnterKeyBindingInstalled",
];
// Pure transform: take whatever the host file parsed to and return the
// container-appropriate config object. Defends against a host file that is
// valid JSON but not an object (a bare number/string/array would otherwise
// slip the parse try/catch, no-op the field deletes, silently fail the
// hasCompletedOnboarding assignment, and re-trigger onboarding every rebuild).
function sanitizeClaudeConfig(parsed) {
let cfg = parsed;
if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
cfg = {};
}
for (const k of MACHINE_FIELDS) {
delete cfg[k];
}
cfg.hasCompletedOnboarding = true; // skip the wizard even on a first-time host
return cfg;
}
function readHostConfig(src) {
try {
if (fs.existsSync(src) && fs.statSync(src).size > 0) {
return JSON.parse(fs.readFileSync(src, "utf8"));
}
} catch {
// Malformed/unreadable host file — fall back to an empty config so the
// container still gets a valid hasCompletedOnboarding-bearing file.
}
return {};
}
function main() {
const src = process.argv[2] || "/host/.claude.json";
const dst = process.argv[3] || "/home/node/.claude.json";
const cfg = sanitizeClaudeConfig(readHostConfig(src));
try {
fs.writeFileSync(dst, JSON.stringify(cfg, null, 2));
fs.chmodSync(dst, 0o644);
} catch (err) {
console.error(
`[post-create] ERROR: failed to seed ${dst}: ${err && err.message}`,
);
process.exit(1);
}
}
module.exports = { sanitizeClaudeConfig, readHostConfig, MACHINE_FIELDS };
if (require.main === module) {
main();
}

View file

@ -0,0 +1,105 @@
// Translates Claude + Cursor plugin-registry JSON files from host absolute
// paths to the container's Linux paths, then writes them into the named
// volume. Both CLIs bake absolute OS-native install paths into their registry
// JSONs — `C:\Users\X\.claude\plugins\...` on Windows, `/Users/X/.cursor/...`
// on macOS — so the host versions can't be bind-mounted into the Linux
// container (the CLI fails with `cache-miss` resolving a Windows path under
// Linux). For each CLI we read the host registry, rewrite every absolute path
// ending in `/.<cli>/plugins/<rest>` to `/home/node/.<cli>/plugins/<rest>`,
// and write the result into the named volume. (Codex needs no translation —
// its enablement registry is config.toml with git URLs, not filesystem paths,
// so its whole plugins/ dir is bind-mounted instead.)
//
// Extracted from a post-create.sh heredoc so the regex + deep rewrite are
// lintable and unit-tested (the regex has had path-handling bugs before).
"use strict";
const fs = require("fs");
const path = require("path");
// Match an absolute path that contains `<sep>.<cli><sep>plugins<sep><rest>`
// where <sep> is `/` or `\`. Anchored at start; the lazy `.*?` consumes the
// home prefix up to the FIRST `.<cli>/plugins` segment.
function buildRe(cliName) {
return new RegExp(
`^(?:[A-Za-z]:)?[\\\\/].*?[\\\\/]\\.${cliName}[\\\\/]plugins[\\\\/](.*)$`,
);
}
// Recursively rewrite every string value in `obj` that matches `re`,
// remapping it under `ctr` (the container plugins dir) and normalizing
// Windows backslashes to forward slashes.
function rewriteDeep(obj, re, ctr) {
if (Array.isArray(obj)) return obj.map((v) => rewriteDeep(v, re, ctr));
if (obj && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = rewriteDeep(v, re, ctr);
return out;
}
if (typeof obj === "string") {
return obj.replace(re, (_, rest) => `${ctr}/${rest.replace(/\\/g, "/")}`);
}
return obj;
}
const REGISTRIES = [
{
cli: "claude",
host: "/host/.claude/plugins",
ctr: "/home/node/.claude/plugins",
files: [
"known_marketplaces.json",
"installed_plugins.json",
"plugin-catalog-cache.json",
],
},
{
cli: "cursor",
host: "/host/.cursor/plugins",
ctr: "/home/node/.cursor/plugins",
files: ["installed_plugins.json"],
},
];
function translate(registries) {
for (const reg of registries) {
const re = buildRe(reg.cli);
try {
fs.mkdirSync(reg.ctr, { recursive: true });
} catch (err) {
console.error(
`[post-create] ERROR: failed to create ${reg.ctr}: ${err && err.message}`,
);
process.exit(1);
}
for (const name of reg.files) {
const src = path.join(reg.host, name);
const dst = path.join(reg.ctr, name);
if (!fs.existsSync(src) || fs.statSync(src).size === 0) continue;
let data;
try {
data = JSON.parse(fs.readFileSync(src, "utf8"));
} catch {
continue; // skip a malformed host registry rather than abort
}
try {
fs.writeFileSync(
dst,
JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2),
);
} catch (err) {
console.error(
`[post-create] ERROR: failed to write ${dst}: ${err && err.message}`,
);
process.exit(1);
}
}
}
}
module.exports = { buildRe, rewriteDeep, REGISTRIES, translate };
if (require.main === module) {
translate(REGISTRIES);
}

View file

@ -0,0 +1,128 @@
// Unit tests for the devcontainer host->container config transforms.
// Pure-function coverage for the two pieces that used to live inline in
// post-create.sh heredocs (invisible to lint and untestable):
// - plugin-registry path translation (buildRe + rewriteDeep), which has
// had path-handling bugs before
// - the $HOME/.claude.json machine-field strip (sanitizeClaudeConfig)
//
// Run with the built-in Node test runner (no deps):
// node --test .devcontainer/
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildRe,
rewriteDeep,
} = require("./translate-plugin-registries.cjs");
const { sanitizeClaudeConfig } = require("./seed-claude-config.cjs");
const CLAUDE = "/home/node/.claude/plugins";
const CURSOR = "/home/node/.cursor/plugins";
function rw(value, cli, ctr) {
return rewriteDeep(value, buildRe(cli), ctr);
}
test("claude: Windows backslash absolute path -> container path", () => {
assert.equal(
rw("C:\\Users\\gergo\\.claude\\plugins\\cache\\x\\1.0", "claude", CLAUDE),
"/home/node/.claude/plugins/cache/x/1.0",
);
});
test("claude: Windows forward-slash absolute path -> container path", () => {
assert.equal(
rw("C:/Users/gergo/.claude/plugins/marketplaces/m", "claude", CLAUDE),
"/home/node/.claude/plugins/marketplaces/m",
);
});
test("claude: macOS POSIX path -> container path", () => {
assert.equal(
rw("/Users/alice/.claude/plugins/marketplaces/m", "claude", CLAUDE),
"/home/node/.claude/plugins/marketplaces/m",
);
});
test("claude: Linux POSIX path -> container path", () => {
assert.equal(
rw("/home/bob/.claude/plugins/cache/foo", "claude", CLAUDE),
"/home/node/.claude/plugins/cache/foo",
);
});
test("cursor: Windows path -> container cursor path", () => {
assert.equal(
rw("C:\\Users\\gergo\\.cursor\\plugins\\local\\myplug", "cursor", CURSOR),
"/home/node/.cursor/plugins/local/myplug",
);
});
test("cross-CLI isolation: claude regex leaves a .cursor path untouched", () => {
const input = "C:\\Users\\g\\.cursor\\plugins\\x";
assert.equal(rw(input, "claude", CLAUDE), input);
});
test("non-path strings pass through unchanged", () => {
assert.equal(rw("not-a-path", "claude", CLAUDE), "not-a-path");
assert.equal(rw("https://github.com/EveryInc/x.git", "claude", CLAUDE), "https://github.com/EveryInc/x.git");
});
test("non-string scalars pass through unchanged", () => {
assert.equal(rw(42, "claude", CLAUDE), 42);
assert.equal(rw(null, "claude", CLAUDE), null);
assert.equal(rw(true, "claude", CLAUDE), true);
});
test("nested objects/arrays are rewritten deeply", () => {
const input = {
"compound-engineering@m": [
{ installPath: "C:\\Users\\g\\.claude\\plugins\\cache\\ce\\3.9.2", version: "3.9.2" },
],
nested: { installLocation: "/Users/g/.claude/plugins/marketplaces/m" },
};
const out = rw(input, "claude", CLAUDE);
assert.equal(
out["compound-engineering@m"][0].installPath,
"/home/node/.claude/plugins/cache/ce/3.9.2",
);
assert.equal(out["compound-engineering@m"][0].version, "3.9.2");
assert.equal(
out.nested.installLocation,
"/home/node/.claude/plugins/marketplaces/m",
);
});
test("sanitizeClaudeConfig: strips machine fields, forces hasCompletedOnboarding", () => {
const out = sanitizeClaudeConfig({
installMethod: "native",
autoUpdates: false,
autoUpdatesProtectedForNative: true,
shiftEnterKeyBindingInstalled: true,
userID: "abc",
oauthAccount: { emailAddress: "x@y.z" },
});
assert.equal(out.installMethod, undefined);
assert.equal(out.autoUpdates, undefined);
assert.equal(out.autoUpdatesProtectedForNative, undefined);
assert.equal(out.shiftEnterKeyBindingInstalled, undefined);
assert.equal(out.userID, "abc");
assert.equal(out.oauthAccount.emailAddress, "x@y.z");
assert.equal(out.hasCompletedOnboarding, true);
});
test("sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing object", () => {
for (const bad of [42, "x", null, ["a"], true]) {
const out = sanitizeClaudeConfig(bad);
assert.equal(typeof out, "object");
assert.equal(Array.isArray(out), false);
assert.equal(out.hasCompletedOnboarding, true);
}
});
test("sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding", () => {
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
});

61
.github/workflows/ci-devcontainer.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Devcontainer Smoke
# Smoke-tests the .devcontainer/ on changes to it: unit-tests the pure
# host->container config transforms (plugin-registry path translation +
# the $HOME/.claude.json machine-field strip) and builds the devcontainer
# image via the canonical @devcontainers/cli path (which reads build.args
# from devcontainer.json, enforcing the "single source of truth" pin).
on:
push:
branches: [main]
paths:
- '.devcontainer/**'
- '.github/workflows/ci-devcontainer.yml'
pull_request:
paths:
- '.devcontainer/**'
- '.github/workflows/ci-devcontainer.yml'
permissions:
contents: read
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Branch/tag scope; cancel superseded PR runs, never cancel push-to-main runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
config-transforms:
name: Config-transform unit tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Unit-test the host->container config transforms
run: node --test .devcontainer/translate-plugin-registries.test.cjs
- name: Syntax-check the lifecycle shell scripts
run: |
bash -n .devcontainer/install-deps.sh
bash -n .devcontainer/post-create.sh
build:
name: Build devcontainer image
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
# Builds the image exactly as a developer's "Reopen in Container" would:
# @devcontainers/cli parses devcontainer.json (jsonc), resolves build.args
# (the CLAUDE_CODE_VERSION / CODEX_VERSION pins), and runs the Dockerfile.
# This is the smoke that catches Dockerfile regressions + drift from the
# canonical version pins. Lifecycle hooks (post-create.sh) are not run
# here — they need the host config mounts, which CI has none of.
- name: Build devcontainer via @devcontainers/cli
run: npx --yes @devcontainers/cli build --workspace-folder .