fix(devcontainer): resolve adversarial review findings (pins, RO mounts, tests)

Resolves the blocking + actionable findings from the PR #1875 review:

- Pin base image by digest as bare name@digest [#1]. The :tag@digest form
  trips the @devcontainers/cli image-name parser (which builds this image
  in CI and in VS Code "Reopen in Container"); bare name@digest is the
  parser-compatible form. Verified by a full local build.
- Pin Cursor by version + per-arch sha256 and fetch the artifact directly
  instead of executing cursor.com/install; fail-closed on mismatch [#2].
- Mount ~/.config/gh and ~/.docker read-only so a compromised dep can't
  rewrite the host GitHub token / Docker credHelper [#4].
- Pin @devcontainers/cli@0.87.0 in the CI smoke [#5].
- chown via find -xdev in install-deps.sh (symlink-safe; matches
  post-create.sh) [#6].
- Add filesystem-I/O tests (translate/readHostConfig/seed main/ensurePaths)
  and refactor ensure-host-config-dirs to be unit-testable [#7].
- Stop pre-creating settings.json/config.toml on the host; only the real
  single-file bind source (.claude.json) is touched [#10].
- Add a prominent top-of-README security callout for the RW write-through
  trade-off and reframe the deferred egress firewall as the key missing
  compensating control [#3, #9].

Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
This commit is contained in:
Gergo Magyar 2026-05-29 07:02:36 +01:00
parent 25dd0234dd
commit bfdd183c95
7 changed files with 420 additions and 130 deletions

View file

@ -3,7 +3,20 @@
# Base image: Microsoft's TypeScript+Node devcontainer image. Multi-arch
# (linux/amd64, linux/arm64), monthly security patching, ships the non-root
# `node` user (UID 1000), zsh + Oh My Zsh, eslint global, `gh` CLI.
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
#
# Digest-pinned (matches the Dockerfile.cli / gitnexus/Dockerfile.test
# convention and issue #1451) so a silent upstream retag can't change the
# build under us. Pinned as bare `name@digest` (NO `:tag` prefix) on purpose:
# unlike the production Dockerfiles (built with plain `docker build`), this one
# is built by `@devcontainers/cli` / the VS Code Dev Containers resolver, whose
# image-name parser rejects the combined `name:tag@sha256:...` form. The digest
# below corresponds to the `1-22-bookworm` tag and is the multi-arch
# manifest-list digest, so it still resolves the right platform. Refresh when
# bumping the readable tag:
# docker buildx imagetools inspect \
# mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm \
# --format '{{json .Manifest.Digest}}'
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
# Build args. Version defaults are NOT set here — devcontainer.json
# `build.args` is the single source of truth. Standalone `docker build
@ -12,13 +25,14 @@ FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# devcontainer-canonical pin.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
# Cursor is pinned by version + per-arch tarball sha256 (verified in the
# install step below); all three live in devcontainer.json build.args, same
# single-source-of-truth / fail-loud-without-default rule as the others.
ARG CURSOR_VERSION
ARG CURSOR_SHA256_X64
ARG CURSOR_SHA256_ARM64
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
@ -26,6 +40,7 @@ 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 \
@ -65,23 +80,39 @@ RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Cursor CLI install. The official cursor.com/install script does not
# expose version pinning or a checksum, so we download to a temp file,
# log the sha256 to build output (so drift across rebuilds shows up in
# CI logs), then execute. Trust assumption: cursor.com's TLS chain is
# 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.
# Cursor CLI install — pinned and hash-verified, no remote script executed.
# cursor.com/install merely detects os/arch and downloads a versioned tarball
# from downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
# then extracts it and symlinks `agent`/`cursor-agent` into ~/.local/bin. We
# replicate that directly against a PINNED version + per-arch sha256, so the
# build runs no unverified remote code (matches the base-image / npm digest-pin
# convention; issue #1451). The download is fail-closed: a sha mismatch aborts.
#
# `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 \
&& timeout 300 bash /tmp/cursor-install.sh \
&& rm -f /tmp/cursor-install.sh
# Bump: set CURSOR_VERSION + both CURSOR_SHA256_* in devcontainer.json
# build.args. Re-hash each arch with:
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
#
# TARGETARCH is BuildKit's automatic per-platform build arg; it must be
# (re)declared in this stage to be in scope. Fall back to `dpkg
# --print-architecture` for a non-BuildKit `docker build`.
ARG TARGETARCH
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
case "$arch" in \
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
esac; \
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
test -x "$dir/cursor-agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
rm -f /tmp/cursor.tgz
# ~/.local/bin (where Cursor's installer drops `agent` and `cursor-agent`
# symlinks) on PATH for interactive shells and lifecycle scripts.

View file

@ -2,6 +2,14 @@
A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, and Cursor CLI alongside the GitNexus native build chain. Supported hosts: **macOS, Linux, Windows 11 (native), and Windows 11 via WSL2.** Windows-native needs a **one-time `HOME` env var setup** — handled automatically by the `initializeCommand` on first run (see [Windows 11 setup](#windows-11-setup)).
> ### ⚠️ Read this before using it on a work machine
>
> By **default this devcontainer shares your host AI-CLI config read-write.** A plugin, skill, agent, command, or memory you add on the host or inside the container appears on both sides — **and so does anything a compromised workspace dependency writes while running in the container.** Because `agents/`, `commands/`, `skills/`, and `rules/` are auto-loaded instruction/shell-executing surfaces, a single in-container compromise can land on your **host** and run in your next host CLI session, even after the container is gone. This is a deliberate trade-off for live config sync, **not** something the design prevents.
>
> What is **not** exposed that way: your **credentials** (Claude/Codex/Cursor logins) stay isolated in per-container volumes and are never written back to the host, and `~/.ssh`, `~/.aws`, `~/.azure`, `~/.config/gh`, and `~/.docker` are mounted **read-only** (readable, not writable from the container). There is **no egress firewall yet**, so a compromised dependency with read access also has the network to exfiltrate what it reads.
>
> If you don't want host↔container config sharing, use the isolated per-container-volume setup described in [§ Trust boundary, concretely](#trust-boundary-concretely) — you trade plugin/skill/memory sync for full isolation. Read that section in full before trusting this with credentials you couldn't afford to rotate.
## Quick start
1. Install [Docker Desktop](https://docs.docker.com/desktop/) (Windows/macOS) or Docker Engine (Linux).
@ -127,12 +135,12 @@ Install a plugin on the host or inside the container — both sides see it immed
|---|---|---|---|
| `~/.config/git` | `$HOME/.config/git` | **read-only** | XDG-style git config / ignore / attributes |
| `~/.ssh` | `$HOME/.ssh` | **read-only** | SSH commit signing + git push over SSH |
| `~/.config/gh` | `$HOME/.config/gh` | read-write | `gh` CLI auth (PR create, issue create, checks) |
| `~/.docker` | `$HOME/.docker` | read-write | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
| `~/.config/gh` | `$HOME/.config/gh` | **read-only** | `gh` CLI auth (PR/issue create, checks) — container reads your existing host login |
| `~/.docker` | `$HOME/.docker` | **read-only** | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
| `~/.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.
**Why everything here is read-only — including `gh`/`docker`:** `ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `gh` and `docker` *can* write their own state (`gh auth login` / `gh auth refresh` rewrite `hosts.yml`; `docker login` / buildx write `config.json`), so they were originally read-write — but that also lets a compromised in-container dependency rewrite your host `~/.config/gh/hosts.yml` (swap your GitHub token) or `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary), which is a credential-takeover vector, not just a read. Since the common case is *reading* an existing host login, both are mounted **read-only**: `gh pr create`, `gh pr checks`, and registry pulls/pushes using your host creds all still work — only a `gh auth login` / `docker login` run **inside** the container won't persist back to the host. Re-run those on the host, or, if you specifically want in-container logins to stick, drop `,readonly` from the `~/.config/gh` and `~/.docker` mounts in `devcontainer.json`.
`~/.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.
@ -153,7 +161,7 @@ If a host source dir doesn't exist when the container is first created, the `ini
These are commonly-needed CLIs that aren't installed by default — adding them would be follow-up work, not in this PR's scope:
- **Docker CLI** (for `docker push` / `docker build` from inside the container). Add via `ghcr.io/devcontainers/features/docker-outside-of-docker:1` to the `features` block — `~/.docker/` is already mounted so `docker login` state from your host will work immediately.
- **Docker CLI** (for `docker push` / `docker build` from inside the container). Add via `ghcr.io/devcontainers/features/docker-outside-of-docker:1` to the `features` block — `~/.docker/` is already mounted **read-only**, so your host `docker login` state works immediately for pulls/pushes; an in-container `docker login` won't persist to the host (drop `,readonly` on that mount if you need it to).
- **AWS CLI / Azure CLI / gcloud / kubectl** — same pattern: add the matching Feature, the host config dirs already flow through.
- **Private npm registry auth** (`~/.npmrc`) — you don't have a global one on this host. If you ever start using private packages, add `source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly` to the mounts.
@ -182,9 +190,9 @@ Host and container share a single trust boundary by design — fine for personal
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).
**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`, **`~/.config/gh`, and `~/.docker`** are read-only binds with the same one-way property — readable but not writable from the container, so a compromised dep can *read* your `gh`/registry tokens but cannot *rewrite* them to hijack your future host auth. If you need the shareable AI-CLI 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.
**The egress firewall is the key compensating control that is still missing.** It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely.
**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:
@ -287,12 +295,11 @@ VS Code's Ports panel shows forwarded ports once their listener starts.
## Bumping CLI versions
Bump `CLAUDE_CODE_VERSION` and `CODEX_VERSION` in `.devcontainer/devcontainer.json` `build.args` and rebuild — both are real pins (Claude Code via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, Codex via `npm install -g @openai/codex@${CODEX_VERSION}`). `CURSOR_VERSION` is informational only because Cursor's official installer (`cursor.com/install`) doesn't expose version pinning; rebuild to pick up whatever the installer serves. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
Bump the version pins in `.devcontainer/devcontainer.json` `build.args` and rebuild — all three are real, fail-loud pins. Claude Code installs via `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` and Codex via `npm install -g @openai/codex@${CODEX_VERSION}`. **Cursor is pinned too:** bump `CURSOR_VERSION` **and** both `CURSOR_SHA256_X64` / `CURSOR_SHA256_ARM64` together — the Dockerfile downloads the pinned `downloads.cursor.com/lab/<version>/linux/<arch>/agent-cli-package.tar.gz` artifact directly (no remote install script) and fails the build on a sha256 mismatch. Re-hash each arch with `curl -fSL <url> | sha256sum`. To stop Cursor from auto-updating in the running container, don't call `cursor-agent update`.
## What's not included (yet)
- **Egress firewall.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Track at the project's issue tracker if you need this.
- **Hard-pinned Cursor CLI.** Today the Dockerfile downloads `cursor.com/install` to a temp file, logs the sha256 to the build output (so drift across rebuilds is visible in CI logs), then executes — trust assumption: cursor.com's TLS chain is reliable. The full pin (download a specific `downloads.cursor.com/lab/<version>/<arch>/agent-cli-package.tar.gz` with a verified sha256, skip the install script entirely) is tracked as a follow-up because it requires per-arch handling and a SHA bump per Cursor release.
- **Egress firewall — the most important hardening still outstanding.** The original plan included an opt-in iptables/ipset firewall adapted from Anthropic's reference devcontainer. It was deferred to a follow-up PR — `runArgs` is static in `devcontainer.json`, so toggling NET_ADMIN/NET_RAW capabilities cleanly requires either a separate `devcontainer-firewall.json` profile or an `initializeCommand`-generated overlay. Until it lands, the read surface in [§ Trust boundary](#trust-boundary-concretely) has no network containment — anything readable can be exfiltrated. Track at the project's issue tracker if you need this.
- **Codespaces tuning.** The current config works in Codespaces incidentally (no privileged capabilities, no host-mount assumptions), but isn't actively tested there.
- **Playwright e2e support.** `gitnexus-web`'s `npm run test:e2e` needs Chromium libs that the base image doesn't ship. Use the host for e2e until a Playwright layer is added.
@ -310,4 +317,4 @@ Bump `CLAUDE_CODE_VERSION` and `CODEX_VERSION` in `.devcontainer/devcontainer.js
| Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer |
| API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence |
| `git commit` produces commits with empty author | `~/.gitconfig` is missing or empty on the host (VS Code's auto-copy had nothing to copy) | Set `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` from the host shell, then rebuild the container |
| `gh: not logged in` inside the container | `~/.config/gh/` source path missing on the host | Run `gh auth login` from the host shell (or inside the container once); the auth file lands in the shared mount |
| `gh: not logged in` inside the container | Not logged in on the host, or `~/.config/gh/` source path missing on the host | Run `gh auth login` **on the host** — the `~/.config/gh` mount is read-only, so an in-container login won't persist; the host auth flows into the container on next attach |

View file

@ -15,6 +15,13 @@
"args": {
"CLAUDE_CODE_VERSION": "2.1.153",
"CODEX_VERSION": "0.134.0",
// Cursor: pinned version + per-arch tarball sha256, verified at build
// time in the Dockerfile (no remote install script is executed). Bump
// all three together; re-hash each arch with:
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
"CURSOR_VERSION": "2026.05.28-a70ca7c",
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
"TZ": "${localEnv:TZ:UTC}"
}
},
@ -66,10 +73,12 @@
// 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
// Claude Code does. `~/.gitconfig` is handled separately by VS Code's
// auto-copy mechanism (not mounted).
// 3. Other host config — READ-ONLY bind mounts for credential/identity
// dirs that don't have the perm-flattening / onboarding-state complexity
// Claude Code does (ssh, aws, azure, git config, and gh + docker — the
// latter two RO so a compromised dep can't rewrite the GitHub token or
// Docker credHelper; see the inline note at those mounts). `~/.gitconfig`
// is handled separately by VS Code's auto-copy mechanism (not mounted).
//
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
// npm cache. Survive rebuilds, isolated between sibling instances.
@ -181,8 +190,16 @@
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,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=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind",
// gh + docker are READ-ONLY. The container reads your EXISTING host login
// (the common case), but a compromised in-container dependency can't
// rewrite ~/.config/gh/hosts.yml (your GitHub token) or
// ~/.docker/config.json (registry credHelper -> arbitrary binary). The
// trade-off: `gh auth login` / `docker login` run INSIDE the container
// won't persist back to the host — re-run them on the host, or drop
// `,readonly` on the next two lines if you want in-container logins to
// stick. See README § Trust boundary.
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind,readonly",
"source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind,readonly",
"source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly",
"source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",

View file

@ -10,68 +10,21 @@
// gitconfig into the container at attach time, so a bind mount conflicts with
// that mechanism and was removed.
//
// The pure path-ensuring logic is exported (ensurePaths/DIRS/FILES) and the
// Windows HOME side effect only runs when this file is executed directly as
// the initializeCommand — so it can be unit-tested against a temp dir without
// touching the real home or invoking `setx`.
//
// 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.
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
// Windows-native auto-setup. VS Code resolves the bind-mount sources via
// `${localEnv:HOME}` reading its own process env, and Windows doesn't set
// `HOME` by default (it uses `USERPROFILE`). Without `HOME`, the bind
// sources collapse to filesystem-root paths (`/.claude`, `/.codex`, ...)
// and Docker rejects them with `bind source path does not exist`.
//
// Fix: persist `HOME=%USERPROFILE%` to the user's environment via `setx`.
// `setx` writes to `HKCU\Environment` and the new value is inherited by
// every process the user launches after — including VS Code after a
// restart. The current VS Code process can't see the update (its env was
// fixed at launch), so we instruct the user to restart VS Code once.
//
// Subsequent runs detect `HOME` is set, skip this block, and proceed
// normally. Mac/Linux/WSL hosts have `HOME` set by the shell, so this
// block is a no-op on those platforms.
if (process.platform === 'win32' && !process.env.HOME) {
const userprofile = process.env.USERPROFILE;
if (userprofile) {
try {
require('child_process').execFileSync('setx', ['HOME', userprofile], {
stdio: 'ignore',
});
console.error('');
console.error('='.repeat(70));
console.error(' GitNexus devcontainer one-time Windows setup');
console.error('='.repeat(70));
console.error('');
console.error(`HOME has been set to %USERPROFILE% (${userprofile}).`);
console.error("VS Code reads this at startup, so the current session can't pick it up.");
console.error('');
console.error(' 1. Close ALL VS Code windows (File > Exit, not just the window).');
console.error(' 2. Reopen VS Code, open this folder, and re-run Reopen in Container.');
console.error('');
console.error('This is a one-time setup. Subsequent rebuilds work normally.');
console.error('='.repeat(70));
process.exit(1);
} catch (err) {
console.error('ERROR: failed to set HOME automatically: ' + err.message);
console.error('');
console.error('Run this in a Windows shell, then restart VS Code:');
console.error(' setx HOME "%USERPROFILE%"');
process.exit(1);
}
} else {
console.error('ERROR: neither HOME nor USERPROFILE is set on this host.');
console.error('');
console.error('Set HOME to your user profile directory and restart VS Code:');
console.error(' setx HOME "%USERPROFILE%"');
process.exit(1);
}
}
const home = os.homedir();
// Directory bind-mount sources. devcontainer.json declares RW binds for
// shareable subdirs (plugins/skills/agents/memory/commands for Claude;
// memories/skills for Codex) so reads/writes go directly host<->container.
@ -79,7 +32,7 @@ const home = os.homedir();
// one. Per-CLI directories themselves (~/.claude, ~/.codex, ~/.cursor)
// are also created for the /host/.<cli> read-only stage mounts that
// post-create.sh reads credentials from.
const dirs = [
const DIRS = [
'.claude',
path.join('.claude', 'plugins'),
// Claude plugin SOURCE dirs — content is path-independent so these get
@ -119,31 +72,89 @@ const dirs = [
path.join('.config', 'gh'),
path.join('.config', 'git'),
];
for (const dir of dirs) {
if (fs.existsSync(path.join(home, dir))) {
continue;
// Single-file sources. Only `~/.claude.json` is pre-created: it is the one
// SINGLE-FILE bind (read-only at /host/.claude.json), and Docker would create
// a DIRECTORY in its place if the source were absent — so it must exist as a
// file. `~/.claude/settings.json` and `~/.codex/config.toml` are NOT
// single-file binds; post-create.sh copies them out of the /host/.<cli>
// read-only DIR stage and `sync_from_host` no-ops gracefully when they're
// absent (the `[ -f ]` guard). Pre-creating them would be gratuitous host
// mutation on a machine that never ran that CLI, so we don't.
const FILES = ['.claude.json'];
// Create every dir and touch every file under `home`. Idempotent — an
// existing path is left untouched. Parameterized on the filesystem root so
// tests can drive it against a temp dir.
function ensurePaths(home, dirs = DIRS, files = FILES) {
for (const dir of dirs) {
const full = path.join(home, dir);
if (!fs.existsSync(full)) {
fs.mkdirSync(full, { recursive: true });
}
}
for (const file of files) {
const full = path.join(home, file);
if (!fs.existsSync(full)) {
fs.closeSync(fs.openSync(full, 'a'));
}
}
fs.mkdirSync(path.join(home, dir), { recursive: true });
}
// Single-file sources. `~/.claude.json` is bind-mounted READ-ONLY at
// /host/.claude.json, so it must exist or Docker rejects the mount —
// touch-empty if absent. The others are read by post-create.sh from the
// /host/.<cli> read-only dir stages and COPIED into the named volume
// (copy-on-create, never single-file-bound — that trips EXDEV on Docker
// Desktop Windows). Touching them is harmless and gives sync a source:
// `~/.claude/settings.json` (theme + enabled plugins), `~/.codex/config.toml`
// (Codex prefs + plugin enablement). `~/.cursor/{mcp.json,cli-config.json}`
// are left untouched — sync_from_host no-ops if the host never created
// them, which is the correct "not configured yet" state.
const files = [
'.claude.json',
path.join('.claude', 'settings.json'),
path.join('.codex', 'config.toml'),
];
for (const file of files) {
const fullPath = path.join(home, file);
if (!fs.existsSync(fullPath)) {
fs.closeSync(fs.openSync(fullPath, 'a'));
module.exports = { ensurePaths, DIRS, FILES };
if (require.main === module) {
// Windows-native auto-setup. VS Code resolves the bind-mount sources via
// `${localEnv:HOME}` reading its own process env, and Windows doesn't set
// `HOME` by default (it uses `USERPROFILE`). Without `HOME`, the bind
// sources collapse to filesystem-root paths (`/.claude`, `/.codex`, ...)
// and Docker rejects them with `bind source path does not exist`.
//
// Fix: persist `HOME=%USERPROFILE%` to the user's environment via `setx`.
// `setx` writes to `HKCU\Environment` and the new value is inherited by
// every process the user launches after — including VS Code after a
// restart. The current VS Code process can't see the update (its env was
// fixed at launch), so we instruct the user to restart VS Code once.
//
// Subsequent runs detect `HOME` is set, skip this block, and proceed
// normally. Mac/Linux/WSL hosts have `HOME` set by the shell, so this
// block is a no-op on those platforms.
if (process.platform === 'win32' && !process.env.HOME) {
const userprofile = process.env.USERPROFILE;
if (userprofile) {
try {
require('child_process').execFileSync('setx', ['HOME', userprofile], {
stdio: 'ignore',
});
console.error('');
console.error('='.repeat(70));
console.error(' GitNexus devcontainer one-time Windows setup');
console.error('='.repeat(70));
console.error('');
console.error(`HOME has been set to %USERPROFILE% (${userprofile}).`);
console.error("VS Code reads this at startup, so the current session can't pick it up.");
console.error('');
console.error(' 1. Close ALL VS Code windows (File > Exit, not just the window).');
console.error(' 2. Reopen VS Code, open this folder, and re-run Reopen in Container.');
console.error('');
console.error('This is a one-time setup. Subsequent rebuilds work normally.');
console.error('='.repeat(70));
process.exit(1);
} catch (err) {
console.error('ERROR: failed to set HOME automatically: ' + err.message);
console.error('');
console.error('Run this in a Windows shell, then restart VS Code:');
console.error(' setx HOME "%USERPROFILE%"');
process.exit(1);
}
} else {
console.error('ERROR: neither HOME nor USERPROFILE is set on this host.');
console.error('');
console.error('Set HOME to your user profile directory and restart VS Code:');
console.error(' setx HOME "%USERPROFILE%"');
process.exit(1);
}
}
ensurePaths(os.homedir());
}

View file

@ -18,12 +18,19 @@ echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
# `updateRemoteUserUID: true` shifts the `node` user, the volumes end up
# owned by the stale UID — npm install can't write. Re-chown
# post-realignment; idempotent on subsequent runs.
sudo chown -R node:node \
/workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm
#
# `find -xdev -exec chown` (same idiom as post-create.sh) rather than a bare
# `chown -R`: -xdev keeps each chown ON its own volume filesystem, and `find`
# does not follow symlinks during traversal — so a symlink committed in the
# workspace tree (or dropped by a dependency postinstall on a rerun) can't
# redirect the chown onto a host path outside the volume.
for d in /workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm; do
sudo find "$d" -xdev -exec chown node:node {} +
done
echo "[install-deps] 2/4: clear stale .husky/_ runtime cache"
# Docker Desktop's Windows bind-mount permission translation refuses to

View file

@ -1,9 +1,16 @@
// 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)
// Coverage for the logic that used to live inline in post-create.sh heredocs
// (invisible to lint and untestable):
// - plugin-registry path translation (buildRe + rewriteDeep + the real
// filesystem translate() driver), which has had path-handling bugs before
// - the $HOME/.claude.json machine-field strip (sanitizeClaudeConfig +
// readHostConfig + the seed-claude-config main() entry point)
// - the host bind-source bootstrap (ensurePaths), incl. a regression guard
// that it does NOT pre-create settings.json / config.toml on the host
//
// Both pure-function and filesystem-I/O paths are exercised; the I/O tests use
// throwaway os.tmpdir() trees and clean up after themselves, so they run in CI
// with no mounts and never touch the real home dir.
//
// Run with the built-in Node test runner (no deps):
// node --test .devcontainer/
@ -12,13 +19,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { buildRe, rewriteDeep } = require('./translate-plugin-registries.cjs');
const { sanitizeClaudeConfig } = require('./seed-claude-config.cjs');
const { buildRe, rewriteDeep, translate } = require('./translate-plugin-registries.cjs');
const { sanitizeClaudeConfig, readHostConfig } = require('./seed-claude-config.cjs');
const { ensurePaths, DIRS, FILES } = require('./ensure-host-config-dirs.cjs');
const CLAUDE = '/home/node/.claude/plugins';
const CURSOR = '/home/node/.cursor/plugins';
// Fresh throwaway directory under the OS temp root. mkdtemp avoids Date.now()/
// random naming and guarantees uniqueness per call.
function tmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dc-'));
}
function rw(value, cli, ctr) {
return rewriteDeep(value, buildRe(cli), ctr);
}
@ -123,3 +141,198 @@ test('sanitizeClaudeConfig: non-object inputs become a valid onboarding-bearing
test('sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding', () => {
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
});
// --- readHostConfig: filesystem read + fallback paths -----------------------
test('readHostConfig: missing file -> {}', () => {
const dir = tmp();
try {
assert.deepEqual(readHostConfig(path.join(dir, 'nope.json')), {});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('readHostConfig: empty (zero-byte) file -> {}', () => {
const dir = tmp();
try {
const f = path.join(dir, 'empty.json');
fs.writeFileSync(f, '');
assert.deepEqual(readHostConfig(f), {});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('readHostConfig: malformed JSON -> {}', () => {
const dir = tmp();
try {
const f = path.join(dir, 'bad.json');
fs.writeFileSync(f, '{ not valid json');
assert.deepEqual(readHostConfig(f), {});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('readHostConfig: valid object is parsed through', () => {
const dir = tmp();
try {
const f = path.join(dir, 'ok.json');
fs.writeFileSync(f, JSON.stringify({ userID: 'u', hasCompletedOnboarding: false }));
const out = readHostConfig(f);
assert.equal(out.userID, 'u');
assert.equal(out.hasCompletedOnboarding, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// --- translate(): real registry files on disk -------------------------------
test('translate: rewrites host absolute paths and writes into the ctr dir', () => {
const hostDir = tmp();
const ctrParent = tmp();
const ctrDir = path.join(ctrParent, 'plugins'); // need not pre-exist; translate mkdirs it
try {
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
fs.writeFileSync(
path.join(hostDir, 'installed_plugins.json'),
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
);
translate(reg);
const out = JSON.parse(fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8'));
assert.equal(out['p@m'][0].installPath, `${ctrDir}/cache/p/1.0`);
} finally {
fs.rmSync(hostDir, { recursive: true, force: true });
fs.rmSync(ctrParent, { recursive: true, force: true });
}
});
test('translate: idempotent — a second run reproduces byte-identical output', () => {
const hostDir = tmp();
const ctrParent = tmp();
const ctrDir = path.join(ctrParent, 'plugins');
try {
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
fs.writeFileSync(
path.join(hostDir, 'installed_plugins.json'),
JSON.stringify({ 'p@m': [{ installPath: 'C:\\Users\\g\\.claude\\plugins\\cache\\p\\1.0' }] }),
);
translate(reg);
const first = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
translate(reg);
const second = fs.readFileSync(path.join(ctrDir, 'installed_plugins.json'), 'utf8');
assert.equal(first, second);
} finally {
fs.rmSync(hostDir, { recursive: true, force: true });
fs.rmSync(ctrParent, { recursive: true, force: true });
}
});
test('translate: malformed host registry is skipped, dst not written', () => {
const hostDir = tmp();
const ctrParent = tmp();
const ctrDir = path.join(ctrParent, 'plugins');
try {
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
fs.writeFileSync(path.join(hostDir, 'installed_plugins.json'), '{ broken');
translate(reg);
assert.equal(fs.existsSync(path.join(ctrDir, 'installed_plugins.json')), false);
} finally {
fs.rmSync(hostDir, { recursive: true, force: true });
fs.rmSync(ctrParent, { recursive: true, force: true });
}
});
test('translate: empty and missing host registries are skipped without error', () => {
const hostDir = tmp();
const ctrParent = tmp();
const ctrDir = path.join(ctrParent, 'plugins');
try {
const reg = [
{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['empty.json', 'missing.json'] },
];
fs.writeFileSync(path.join(hostDir, 'empty.json'), ''); // missing.json never created
translate(reg);
assert.equal(fs.existsSync(path.join(ctrDir, 'empty.json')), false);
assert.equal(fs.existsSync(path.join(ctrDir, 'missing.json')), false);
} finally {
fs.rmSync(hostDir, { recursive: true, force: true });
fs.rmSync(ctrParent, { recursive: true, force: true });
}
});
// --- seed-claude-config main(): end-to-end via the real CLI entry point -----
const SEED_SCRIPT = path.join(__dirname, 'seed-claude-config.cjs');
test('seed main: strips machine fields, keeps account, sets onboarding, chmod 644', () => {
const dir = tmp();
try {
const src = path.join(dir, 'host.claude.json');
const dst = path.join(dir, 'out.claude.json');
fs.writeFileSync(
src,
JSON.stringify({
installMethod: 'native',
userID: 'abc',
oauthAccount: { emailAddress: 'x@y.z' },
}),
);
execFileSync(process.execPath, [SEED_SCRIPT, src, dst]);
const out = JSON.parse(fs.readFileSync(dst, 'utf8'));
assert.equal(out.installMethod, undefined);
assert.equal(out.userID, 'abc');
assert.equal(out.oauthAccount.emailAddress, 'x@y.z');
assert.equal(out.hasCompletedOnboarding, true);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(dst).mode & 0o777, 0o644);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('seed main: missing host file still writes a valid onboarding-bearing file', () => {
const dir = tmp();
try {
const dst = path.join(dir, 'out.claude.json');
execFileSync(process.execPath, [SEED_SCRIPT, path.join(dir, 'nope.json'), dst]);
assert.deepEqual(JSON.parse(fs.readFileSync(dst, 'utf8')), { hasCompletedOnboarding: true });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// --- ensurePaths: host bind-source bootstrap --------------------------------
test('ensurePaths: creates every DIR and FILE under a temp home, idempotently', () => {
const home = tmp();
try {
ensurePaths(home);
for (const d of DIRS) {
assert.equal(fs.statSync(path.join(home, d)).isDirectory(), true, `not a dir: ${d}`);
}
for (const f of FILES) {
assert.equal(fs.statSync(path.join(home, f)).isFile(), true, `not a file: ${f}`);
}
// Rerun must not throw and must not clobber existing content.
fs.writeFileSync(path.join(home, '.claude.json'), '{"keep":true}');
ensurePaths(home);
assert.equal(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'), '{"keep":true}');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('ensurePaths: does NOT pre-create settings.json / config.toml (no gratuitous host mutation)', () => {
const home = tmp();
try {
ensurePaths(home);
assert.equal(fs.existsSync(path.join(home, '.claude', 'settings.json')), false);
assert.equal(fs.existsSync(path.join(home, '.codex', 'config.toml')), false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

View file

@ -65,5 +65,9 @@ jobs:
# 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.
# Version-pinned: bare `npx --yes @devcontainers/cli` resolves @latest at
# run time, so a breaking or malicious publish could change CI behavior
# (or how devcontainer.json is interpreted) with no diff. Bump
# deliberately alongside the Dockerfile/devcontainer.json pins.
- name: Build devcontainer via @devcontainers/cli
run: npx --yes @devcontainers/cli build --workspace-folder .
run: npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .