docs(devcontainer): rewrite code comments in plain English

The devcontainer comments had grown dense and jargon-heavy. Rewrite them
across all 9 files into short, plain-English sentences — same facts and
reasoning, just clearer wording.

Comments only; no code changed. Verified: the diff touches comment lines
only, 25/25 config-transform tests pass, devcontainer.json is still valid
JSONC with build.args + readonly mounts unchanged, shell scripts pass
`bash -n`, and prettier is clean.
This commit is contained in:
Gergo Magyar 2026-05-29 07:47:39 +01:00
parent 4169c50a13
commit d2047844f4
9 changed files with 568 additions and 502 deletions

View file

@ -1,43 +1,48 @@
# syntax=docker/dockerfile:1
# 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.
# Base image: Microsoft's TypeScript+Node devcontainer image. It works on both
# linux/amd64 and linux/arm64, gets monthly security patches, and ships the
# non-root `node` user (UID 1000, i.e. user ID 1000), zsh + Oh My Zsh, eslint
# global, and the `gh` CLI.
#
# 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:
# We pin the image by digest, not by tag. That way a silent upstream retag can't
# change the build under us. This matches the Dockerfile.cli /
# gitnexus/Dockerfile.test convention and issue #1451.
#
# We pin it as a bare `name@digest` with NO `:tag` prefix on purpose. The
# production Dockerfiles use plain `docker build`, but this one is built by
# `@devcontainers/cli` / the VS Code Dev Containers resolver. That resolver's
# image-name parser rejects the combined `name:tag@sha256:...` form.
#
# The digest below is for the `1-22-bookworm` tag. It is the multi-arch
# manifest-list digest, so it still picks the right platform. To refresh it when
# bumping the readable tag, run:
# 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
# .devcontainer/` (e.g., CI smoke) must pass each version via --build-arg
# or the build will fail loudly rather than silently drift from the
# devcontainer-canonical pin.
# Build args. We deliberately set no version defaults here. devcontainer.json
# `build.args` is the single source of truth for versions. A standalone
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
# version with --build-arg. Without a default, the build fails loudly instead of
# silently drifting from the version pinned in devcontainer.json.
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.
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
# step below verifies that hash. All three values live in devcontainer.json
# build.args. They follow the same rule as the others: one source of truth, and
# no default so the build fails loudly if a value is missing.
ARG CURSOR_VERSION
ARG CURSOR_SHA256_X64
ARG CURSOR_SHA256_ARM64
ARG TZ=UTC
ARG USERNAME=node
# Promote build-only ARGs into runtime ENV so shells and lifecycle scripts
# can read them. CLAUDE_CONFIG_DIR is intentionally NOT set here — the
# canonical value lives in devcontainer.json `containerEnv` (single source
# of truth; runtime-time wins anyway).
# Copy the build-only ARGs into runtime ENV so shells and lifecycle scripts can
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
# anyway.
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
@ -46,20 +51,21 @@ ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
NODE_OPTIONS=--max-old-space-size=4096 \
POWERLEVEL9K_DISABLE_GITSTATUS=true
# Native build toolchain required by gitnexus/postinstall: tree-sitter
# native bindings, vendored Dart/Proto/Swift grammars, @ladybugdb/core
# N-API addon. python3/make/g++ are non-negotiable; mirrors the apt block
# in the existing Dockerfile.cli / gitnexus/Dockerfile.test images.
# Native build toolchain that gitnexus/postinstall needs. It compiles
# tree-sitter native bindings, the vendored Dart/Proto/Swift grammars, and the
# @ladybugdb/core N-API addon (a native Node add-on). python3, make, and g++ are
# required. This mirrors the apt block in the existing Dockerfile.cli /
# gitnexus/Dockerfile.test images.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 make g++ git curl ca-certificates bash \
&& rm -rf /var/lib/apt/lists/*
# Pre-create + chown the named-volume mount points (~/.npm, ~/.local,
# /commandhistory) so empty volumes inherit `node:node` ownership on first
# mount. The three CLI config dirs (~/.claude, ~/.codex, ~/.cursor) are
# bind-mounted from the host — bind mounts fully shadow image-side
# ownership, so no chown is needed for those paths here.
# Create and chown the named-volume mount points (~/.npm, ~/.local,
# /commandhistory) up front. That way an empty volume inherits `node:node`
# ownership the first time it is mounted. The three CLI config dirs (~/.claude,
# ~/.codex, ~/.cursor) are bind-mounted from the host instead. A bind mount
# completely hides the image-side ownership, so those paths need no chown here.
RUN mkdir -p \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local/bin \
@ -71,30 +77,33 @@ RUN mkdir -p \
USER ${USERNAME}
# Install Claude Code and Codex CLI globally as the `node` user. The base
# image configures /usr/local/share/npm-global as the npm-global prefix
# with the `npm` group writable by `node`, so `npm install -g` works
# without sudo. Both versions are pinned via build args — bump in
# devcontainer.json and rebuild to upgrade.
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
# versions come from build args. To upgrade, bump them in devcontainer.json and
# rebuild.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# 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.
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
# script. The cursor.com/install script just detects os/arch, downloads a
# versioned tarball from
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
# runs no unverified remote code. This matches how we pin the base image and npm
# packages by digest (issue #1451). The download is fail-closed: if the hash
# does not match, the build aborts.
#
# Bump: set CURSOR_VERSION + both CURSOR_SHA256_* in devcontainer.json
# build.args. Re-hash each arch with:
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
# build.args. Get each arch's hash 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`.
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
# must be (re)declared in this stage to be visible. When the build is a
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
# --print-architecture`.
ARG TARGETARCH
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
@ -114,6 +123,6 @@ RUN set -eux; \
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.
# Put ~/.local/bin on PATH for interactive shells and lifecycle scripts. That is
# where Cursor's installer drops the `agent` and `cursor-agent` symlinks.
ENV PATH=/home/${USERNAME}/.local/bin:${PATH}

View file

@ -1,11 +1,12 @@
// 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, 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.
// Devcontainer for GitNexus. It pre-installs Claude Code, the OpenAI Codex
// CLI, and the Cursor CLI, plus the Node.js native build chain. It works on
// macOS, Linux, Windows via WSL2, and Windows native. Windows native needs a
// one-time HOME setup. That setup runs automatically via initializeCommand.
// See .devcontainer/README.md § Windows 11 setup. Open it with the VS Code
// Dev Containers extension.
//
// First-time setup, auth flows, and troubleshooting: .devcontainer/README.md.
// For first-time setup, auth flows, and troubleshooting, see
// .devcontainer/README.md.
{
"name": "GitNexus AI CLI Devcontainer",
@ -15,9 +16,10 @@
"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:
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
// Dockerfile checks the tarball against the hash at build time, so it
// never runs a remote install script. Bump all three values 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",
@ -26,15 +28,16 @@
}
},
// 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).
// Runs on the HOST, not the container, before the container is created. We
// write it as a single string on purpose. The spec treats the single-string
// form as one command that each OS runs its own way. The object form means
// "named parallel tasks", not per-OS dispatch. We run it with Node so the
// same command works in cmd.exe on Windows and in bash/zsh on Linux, macOS,
// and WSL. The script reads `os.homedir()`, which respects $HOME on
// Linux/macOS and %USERPROFILE% on Windows. It then creates the host-side
// bind mount source folders, and it is safe to re-run. Host prerequisite:
// Node on PATH. That is the only host-side tool needed beyond Docker Desktop
// and the VS Code Dev Containers extension.
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
"features": {
@ -49,88 +52,98 @@
// Mount topology, by group:
//
// 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).
// 1. AI CLI host config — this has TWO roles. (a) A read-only stage at
// /host/.<cli>. On container-create, `post-create.sh` COPIES credentials,
// identity, and single config files out of it. It is read-only so a
// container can't write the credential snapshot back to the host.
// (b) Direct read-write bind mounts of the shareable subfolders (Claude
// plugins/skills/agents/memory/commands; Codex plugins/prompts/memories/
// skills; Cursor plugins/rules/commands/agents/skills). These overlay the
// named volume at their sub-paths. These read-write binds go both ways.
// Install a plugin in the container and it lands on the host; add one on
// the host and the container sees it. This write-through is a deliberate
// trade-off. A compromised npm dependency CAN drop files into your host
// plugin/agent/skill folders, which the next host session then loads. See
// README § "Trust boundary, concretely". Credentials never live on this
// surface. They stay in the volume (role 2).
//
// 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.
// 2. AI CLI container config — one named volume per devcontainer. CODEX_HOME
// points here. CLAUDE_CONFIG_DIR is left unset on purpose, so it resolves
// to the default ~/.claude, which is this same path. Credentials,
// identity, and single config files (.credentials.json,
// ~/.claude/.claude.json, settings.json, config.toml, cli-config.json,
// mcp.json) live here with correct Linux permissions. They are NOT
// bind-mounted, because single-file binds break on Docker Desktop Windows
// (the EXDEV error — see the SINGLE-FILE note below). Container-managed
// state (sessions, history, caches, IDE locks) stays separate per
// devcontainer. So two GitNexus checkouts on the same host can't corrupt
// each other.
//
// 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).
// 3. Other host config — read-only bind mounts for credential and identity
// folders that lack the permission-flattening and onboarding-state
// complications Claude Code has (ssh, aws, azure, git config, plus gh and
// docker). gh and docker are read-only so a compromised dependency can't
// rewrite the GitHub token or the Docker credHelper. See the inline note
// at those mounts. `~/.gitconfig` is not mounted here. VS Code auto-copies
// it separately.
//
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
// npm cache. Survive rebuilds, isolated between sibling instances.
// 4. Per-instance state — scoped by `${devcontainerId}`: shell history and
// the npm cache. These survive rebuilds and stay separate between sibling
// instances.
//
// 5. Per-workspace-name AND per-instance state — workspace `node_modules`
// volumes use both `${localWorkspaceFolderBasename}` (debuggable in
// `docker volume ls`) and `${devcontainerId}` (collision-free between
// sibling instances of the same repo). Keeps tree-sitter native
// binaries and onnxruntime off the workspace bind mount (Win/Mac perf).
// 5. Per-workspace-name AND per-instance state — the workspace `node_modules`
// volumes use both `${localWorkspaceFolderBasename}` (so you can spot them
// in `docker volume ls`) and `${devcontainerId}` (so sibling instances of
// the same repo never collide). This keeps tree-sitter native binaries and
// onnxruntime off the workspace bind mount, which is faster on Windows and
// macOS.
"mounts": [
// Per-container named volumes for credentials + identity state. Each
// CLI's actual `~/.<cli>` config dir lives in a volume so credentials
// (with proper Linux 600 perms) and per-container session state stay
// isolated from the host. Login in container vs login on host =
// independent. Bind mounts BELOW these volumes override the volume's
// contents at the bound sub-paths — Docker mount precedence: more
// specific path wins.
// One named volume per container for credentials and identity state. Each
// CLI's real `~/.<cli>` config folder lives in a volume. That keeps
// credentials (with correct Linux 600 permissions) and per-container
// session state separate from the host. Logging in inside the container
// and logging in on the host are independent. The bind mounts BELOW these
// volumes override the volume's contents at the paths they cover. Docker
// mount precedence is: the more specific path wins.
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
// Read-only host stage for post-create.sh to copy credentials +
// identity from on container-create. Kept read-only so a container
// process can't write back to host CLI state files (the write-through
// attack vector). Only the credential/identity files are READ here;
// shareable content is bind-mounted directly RW below, not staged.
// Read-only host stage that post-create.sh copies credentials and
// identity from on container-create. It is read-only so a container
// process can't write back to host CLI state files. That write-back is the
// attack vector we are blocking. Only the credential and identity files
// are READ here. Shareable content is bind-mounted directly read-write
// below, not staged here.
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
// Direct RW bind mounts for shareable subdirs + files. These OVERLAY
// the named volume at their target paths, so reads/writes from inside
// the container go straight to host. `/plugin marketplace add` in
// container = installed on host. New skill on host = visible in
// container next read. Trade-off accepted: a compromised npm dep can
// write into host plugin/skill/agent/memory/command dirs.
// Plugins are split: the SOURCE (marketplaces/ git clones + cache/
// extracted plugin files) is path-independent and bind-mounted
// bidirectionally. The REGISTRY (known_marketplaces.json,
// installed_plugins.json, plugin-catalog-cache.json) carries absolute
// Direct read-write bind mounts for shareable subfolders and files. These
// OVERLAY the named volume at their target paths, so reads and writes
// inside the container go straight to the host. `/plugin marketplace add`
// in the container means it is installed on the host. A new skill on the
// host shows up in the container on the next read. We accept the trade-off:
// a compromised npm dependency can write into host plugin, skill, agent,
// memory, and command folders.
// Plugins are split in two. The SOURCE (the marketplaces/ git clones and
// the cache/ of extracted plugin files) does not depend on absolute paths,
// so it is bind-mounted both ways. The REGISTRY (known_marketplaces.json,
// installed_plugins.json, plugin-catalog-cache.json) holds absolute,
// OS-specific paths (`C:\Users\X\.claude\plugins\...` on Windows,
// `/Users/x/...` on macOS) so it CANNOT be shared as-is — stays in
// the named volume, generated by post-create.sh from host's versions
// with paths translated to the container's Linux paths.
// DIRECTORY binds — bidirectional, atomic writes inside the dir work
// fine (same filesystem). Sub-path writes from container go to host
// immediately; host changes are visible to container immediately.
// `/Users/x/...` on macOS), so it CANNOT be shared as-is. It stays in the
// named volume. post-create.sh generates it from the host's versions, with
// the paths rewritten to the container's Linux paths.
// These are DIRECTORY binds, so they work both ways. Atomic writes inside
// a directory work fine, because the temp file and the target are on the
// same filesystem. Writes under these paths in the container reach the host
// right away, and host changes are visible to the container right away.
//
// Claude: plugins SOURCE dirs (marketplaces/ git clones + cache/
// extracted files) are path-independent; the path-bearing registry
// JSONs at plugins/ root stay in the volume and are translated by
// post-create.sh (see SINGLE-FILE note below).
// Claude: the plugin SOURCE folders (the marketplaces/ git clones and the
// cache/ of extracted files) do not depend on absolute paths. The registry
// JSON files at the plugins/ root DO carry paths, so they stay in the
// volume, and post-create.sh rewrites their paths. See the SINGLE-FILE note
// below.
"source=${localEnv:HOME}/.claude/plugins/marketplaces,target=/home/node/.claude/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.claude/plugins/cache,target=/home/node/.claude/plugins/cache,type=bind",
"source=${localEnv:HOME}/.claude/skills,target=/home/node/.claude/skills,type=bind",
@ -138,37 +151,40 @@
"source=${localEnv:HOME}/.claude/memory,target=/home/node/.claude/memory,type=bind",
"source=${localEnv:HOME}/.claude/commands,target=/home/node/.claude/commands,type=bind",
//
// Codex: bind the WHOLE plugins/ dir (parent of cache/). `codex plugin
// add` stages installs INSIDE plugins/cache/<marketplace>/ and renames
// intra-dir (verified by strace), so under a single 9p bind of plugins/
// the rename stays intra-9p — no EXDEV. Unlike Claude, Codex has NO
// path-bearing registry file under plugins/ (enablement lives in
// config.toml at the .codex root as git URLs, not FS paths), so no
// translation is needed and the whole dir can be bound. `.tmp/` (the
// ext4 marketplace-clone staging area) is deliberately NOT bound — it
// must stay on the volume as the cross-fs source side.
// Codex: bind the WHOLE plugins/ folder (the parent of cache/). `codex
// plugin add` stages installs INSIDE plugins/cache/<marketplace>/ and
// renames within that folder (confirmed with strace). So under a single
// bind of plugins/, the rename stays on the same filesystem and never hits
// the EXDEV cross-filesystem error. Unlike Claude, Codex has NO path-
// bearing registry file under plugins/. Enablement lives in config.toml at
// the .codex root, as git URLs, not filesystem paths. So nothing needs
// rewriting and the whole folder can be bound. `.tmp/` is the ext4 staging
// area for marketplace clones, and it is deliberately NOT bound. It must
// stay on the volume as the source side of the cross-filesystem copy.
"source=${localEnv:HOME}/.codex/plugins,target=/home/node/.codex/plugins,type=bind",
"source=${localEnv:HOME}/.codex/prompts,target=/home/node/.codex/prompts,type=bind",
"source=${localEnv:HOME}/.codex/memories,target=/home/node/.codex/memories,type=bind",
"source=${localEnv:HOME}/.codex/skills,target=/home/node/.codex/skills,type=bind",
//
// Cursor: cursor-agent (CLI, not just the IDE) shares the Cursor 2.5
// plugin/rules/commands/agents/skills surface on disk. Bind the SOURCE
// dirs (path-independent). plugins/installed_plugins.json carries
// 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 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.
// Cursor: cursor-agent (the CLI, not just the IDE) shares the Cursor 2.5
// plugin/rules/commands/agents/skills files on disk. Bind the SOURCE
// folders, which don't depend on absolute paths.
// plugins/installed_plugins.json carries absolute Windows paths, like
// Claude's registry, so it stays in the volume and post-create.sh rewrites
// it. That is why the plugins/ sub-folders are bound one by one instead of
// binding the whole plugins/ folder. mcp.json and hooks.json are single
// files, which are unsafe to bind (the EXDEV error), so they are copied on
// create instead. hooks.json is also NOT synced at all. Cursor hooks run
// shell commands on a timer or event, with no user action, so a poisoned
// host hooks.json would run by itself in the container. NOTE this is a
// partial fix, not a clean boundary. The read-write bound
// commands/agents/skills/rules folders (here and for Claude) are also
// surfaces that hold instructions or run shell commands, and a compromised
// dependency can write through them to the host. Skipping hooks.json just
// removes the one surface that fires WITHOUT an agent choosing to run it.
// The wider write-through trade-off is accepted and documented in README
// § Trust boundary. To fully close it, switch these folders 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",
@ -176,28 +192,29 @@
"source=${localEnv:HOME}/.cursor/agents,target=/home/node/.cursor/agents,type=bind",
"source=${localEnv:HOME}/.cursor/skills,target=/home/node/.cursor/skills,type=bind",
//
// SINGLE-FILE binds for settings.json / .claude.json / config.toml
// are deliberately ABSENT. On Docker Desktop Windows the named-volume
// is ext4 (/dev/sdd) and a single-file bind from host is 9p drvfs —
// different filesystems. Atomic config writes (write `foo.tmp` ->
// rename onto `foo`) trip EXDEV and fail with `Device or resource
// busy` / `inter-device move failed`. Codex's TUI hits this as
// "config/batchWrite failed in TUI"; Claude silently loses writes
// through the same mechanism. Instead: host stage at /host/.claude
// (RO) + post-create.sh copies these files into the named volume on
// every container-create. Host changes propagate on rebuild;
// container changes stay container-local until rebuild.
// SINGLE-FILE binds for settings.json, .claude.json, and config.toml are
// deliberately ABSENT. On Docker Desktop Windows the named volume sits on
// one filesystem (ext4, /dev/sdd) and a single-file bind from the host sits
// on another (the 9p drvfs share). Apps save a config by writing `foo.tmp`
// and renaming it over `foo`. That rename can't cross filesystems: it hits
// the EXDEV error and fails with `Device or resource busy` or `inter-device
// move failed`. Codex's TUI shows this as "config/batchWrite failed in
// TUI"; Claude just silently loses the write the same way. Instead, we use
// a read-only host stage at /host/.claude, and post-create.sh copies these
// files into the named volume on every container-create. Host changes show
// up on the next rebuild. Container changes stay inside the container until
// a rebuild.
"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",
// gh + docker are READ-ONLY. The container reads your EXISTING host login
// (the common case), but a compromised in-container dependency can't
// gh and docker are READ-ONLY. The container reads your EXISTING host login,
// which is 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.
// ~/.docker/config.json (the registry credHelper, which points at a
// binary). The trade-off: `gh auth login` or `docker login` run INSIDE the
// container won't persist back to the host. Re-run them on the host, or
// remove `,readonly` from 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",
@ -210,48 +227,47 @@
"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 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=""`
// as "use this empty key" rather than "fall back to stored login", which
// would silently break `cursor-agent login`. Users who need API key auth
// should `export` the var in their container shell or carry it via their
// VS Code dotfiles repo (see .devcontainer/README.md).
// CLAUDE_CONFIG_DIR is intentionally NOT set. The Claude default is
// Interactive login is the default way to authenticate 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 break on Docker Desktop Windows (the EXDEV error).
// Only shareable content (plugins, skills, agents, memory, commands) is
// bound read-write to the host ~/.claude, ~/.codex, and ~/.cursor folders
// declared in the mounts block above.
// API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, CURSOR_API_KEY) are NOT
// injected via containerEnv. `${localEnv:VAR}` turns an unset host var into
// an empty string. Cursor in particular treats `CURSOR_API_KEY=""` as "use
// this empty key" instead of "fall back to the stored login", which would
// silently break `cursor-agent login`. If you need API-key auth, `export`
// the var in your container shell, or carry it in your VS Code dotfiles repo
// (see .devcontainer/README.md).
// CLAUDE_CONFIG_DIR is left unset on purpose. The Claude default is
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
// claude-config named volume mounts — setting the env var explicitly
// changes which file Claude reads `hasCompletedOnboarding` from: with
// the var set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the
// small identity file); without it, Claude reads `$HOME/.claude.json`
// (the big onboarding-state file that actually carries
// `hasCompletedOnboarding`, MCP user-scope config, and per-project
// trust). Leaving the var unset matches host behavior and lets
// post-create.sh's host-sync of `$HOME/.claude.json` skip the wizard
// claude-config named volume mounts. Setting the env var would change which
// file Claude reads `hasCompletedOnboarding` from. With the var set, Claude
// reads `$CLAUDE_CONFIG_DIR/.claude.json`, the small identity file. Without
// it, Claude reads `$HOME/.claude.json`, the big onboarding-state file that
// actually holds `hasCompletedOnboarding`, the user-scope MCP config, and
// per-project trust. Leaving the var unset matches host behavior. It also
// lets post-create.sh's sync of `$HOME/.claude.json` skip the setup wizard
// on every container-create.
//
// CODEX_HOME is kept (even though it matches the Codex default) as a
// canary: if we ever move the Codex named volume target, the env var
// makes the dependency explicit instead of silently following the
// default.
// CODEX_HOME is kept even though it matches the Codex default, as a canary.
// If we ever move the Codex named volume target, this env var makes the
// dependency explicit instead of silently following the default.
"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.
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
// the npm-global binary detects its own install method. This is a backup
// safeguard for Claude Code issue #17289. The install-checks routine probes
// ~/.local/bin/claude just because that directory EXISTS. It does exist
// here, because Cursor drops agent and cursor-agent symlinks there. So even
// when installMethod is non-native, the routine reports a false "claude
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
// switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},
@ -280,8 +296,8 @@
}
},
// 4747 (gitnexus serve) must not be remapped: gitnexus-web hardcodes
// http://localhost:4747 as the default backend URL.
// Do not remap port 4747 (gitnexus serve). gitnexus-web hardcodes
// http://localhost:4747 as its default backend URL.
"forwardPorts": [5173, 4747, 4173],
"portsAttributes": {
"5173": {
@ -299,17 +315,15 @@
}
},
// Lifecycle split (per Dev Container spec):
// - `updateContentCommand` runs on container-create AND whenever
// workspace content changes (e.g. lockfile updates). It owns
// workspace dependency installation — re-installing on every
// container-create wastes time when nothing changed, but it must
// re-run when deps shift.
// - `postCreateCommand` runs once on container-create. It owns AI CLI
// credential + identity sync from the host — work that should
// happen exactly once per container instance, not on every content
// update.
// Run both via explicit `bash` so they don't depend on the script's
// Lifecycle split (from the Dev Container spec):
// - `updateContentCommand` runs on container-create AND whenever the
// workspace content changes, such as a lockfile update. It owns installing
// the workspace dependencies. Re-installing on every container-create
// wastes time when nothing changed, but it must re-run when deps change.
// - `postCreateCommand` runs once on container-create. It owns syncing the
// AI CLI credentials and identity from the host. That work should happen
// exactly once per container instance, not on every content update.
// Run both with an explicit `bash` so they don't depend on the script's
// executable bit surviving the workspace bind mount.
"updateContentCommand": "bash .devcontainer/install-deps.sh",
"postCreateCommand": "bash .devcontainer/post-create.sh"

View file

@ -1,23 +1,24 @@
// 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.
// This runs on the HOST, not inside the container, before the dev container is
// created. devcontainer.json calls it via `initializeCommand`. Its job is to
// make sure the bind-mount source folders listed in devcontainer.json already
// exist on the host. Docker rejects a bind mount when its source is missing,
// which happens if 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
// — each path is skipped if it already exists. `~/.gitconfig` is intentionally
// not handled here: VS Code's Dev Containers extension auto-copies the host
// gitconfig into the container at attach time, so a bind mount conflicts with
// that mechanism and was removed.
// It works on every platform. `os.homedir()` returns the home folder ($HOME on
// Mac/Linux, %USERPROFILE% on Windows). `fs.mkdirSync({recursive: true})`
// creates folders. It is safe to run repeatedly: a path that already exists is
// left alone. We deliberately do NOT handle `~/.gitconfig` here. VS Code's Dev
// Containers extension copies the host gitconfig into the container when you
// attach, and a bind mount fights with that, so it 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`.
// The path-creating logic is exported (ensurePaths/DIRS/FILES) so tests can use
// it. The Windows HOME side effect only runs when this file is run directly as
// the initializeCommand. That keeps tests able to drive it against a temp dir
// without touching the real home or calling `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.
// Host prerequisite: Node.js must be on PATH. That is the only host requirement
// beyond Docker Desktop and the VS Code Dev Containers extension. Everything
// else runs inside the container.
'use strict';
@ -25,39 +26,39 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
// 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.
// Docker rejects bind mounts whose source doesn't exist — mkdir -p each
// 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.
// Folders that are bind-mount sources. devcontainer.json declares read-write
// binds for the shareable subfolders (plugins/skills/agents/memory/commands for
// Claude; memories/skills for Codex) so reads and writes pass straight between
// host and container. Docker rejects a bind mount whose source is missing, so
// we create each one. We also create the top per-CLI folders themselves
// (~/.claude, ~/.codex, ~/.cursor). Those back the /host/.<cli> read-only stage
// mounts that post-create.sh reads credentials from.
const DIRS = [
'.claude',
path.join('.claude', 'plugins'),
// Claude plugin SOURCE dirs — content is path-independent so these get
// RW bind-mounted bidirectionally. The path-DEPENDENT registry JSONs
// (known_marketplaces.json, installed_plugins.json,
// plugin-catalog-cache.json) stay in the container's named volume,
// translated by post-create.sh.
// Claude plugin source folders. Their content does not depend on absolute
// paths, so they get a two-way read-write bind mount. The registry JSON files
// that DO contain paths (known_marketplaces.json, installed_plugins.json,
// plugin-catalog-cache.json) stay in the container's named volume instead,
// where post-create.sh rewrites their paths.
path.join('.claude', 'plugins', 'marketplaces'),
path.join('.claude', 'plugins', 'cache'),
path.join('.claude', 'skills'),
path.join('.claude', 'agents'),
path.join('.claude', 'memory'),
path.join('.claude', 'commands'),
// Codex shareable surface. The whole plugins/ dir is bound (no
// path-bearing registry inside it — enablement is in config.toml at the
// root), plus prompts/ (saved prompt library), memories/, skills/.
// Codex shareable folders. The whole plugins/ folder is bound, because it has
// no path-bearing registry inside it (enablement lives in config.toml at the
// root). Plus prompts/ (the saved prompt library), memories/, and skills/.
'.codex',
path.join('.codex', 'plugins'),
path.join('.codex', 'prompts'),
path.join('.codex', 'memories'),
path.join('.codex', 'skills'),
// Cursor shareable surface (cursor-agent CLI shares the Cursor 2.5
// plugin/rules/commands/agents/skills dirs). plugins/ sub-dirs are
// bound individually because plugins/installed_plugins.json carries
// absolute paths and is translated (not bound) by post-create.sh.
// Cursor shareable folders. The cursor-agent CLI shares the Cursor 2.5
// plugin/rules/commands/agents/skills folders. The plugins/ subfolders are
// bound one by one. That is because plugins/installed_plugins.json holds
// absolute paths, so post-create.sh rewrites it instead of binding it.
'.cursor',
path.join('.cursor', 'plugins', 'marketplaces'),
path.join('.cursor', 'plugins', 'local'),
@ -73,19 +74,20 @@ const DIRS = [
path.join('.config', 'git'),
];
// 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.
// Files to pre-create. Only `~/.claude.json` is created here. It is the one
// source that is bound as a single file (read-only at /host/.claude.json). If
// that source is missing, Docker would create a FOLDER in its place, so it has
// to exist as a file first. `~/.claude/settings.json` and
// `~/.codex/config.toml` are NOT single-file binds. post-create.sh copies them
// out of the /host/.<cli> read-only folder stage, and `sync_from_host` simply
// does nothing when they are absent (the `[ -f ]` guard). Creating them here
// would needlessly write to the host of someone who 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.
// Create every folder and touch every file under `home`. Safe to run again:
// an existing path is left untouched. The root is a parameter so tests can run
// it against a temp dir.
function ensurePaths(home, dirs = DIRS, files = FILES) {
for (const dir of dirs) {
const full = path.join(home, dir);
@ -104,21 +106,21 @@ function ensurePaths(home, dirs = DIRS, files = FILES) {
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`, ...)
// One-time setup for native Windows. VS Code fills in the bind-mount sources
// using `${localEnv:HOME}`, which reads its own process environment. Windows
// does not set `HOME` by default; it uses `USERPROFILE`. With no `HOME`, the
// bind sources shrink 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.
// The fix is to save `HOME=%USERPROFILE%` into the user's environment with
// `setx`. `setx` writes to `HKCU\Environment`. Every process the user starts
// after that inherits the new value, including VS Code once it restarts. The
// current VS Code process can't see the change, because its environment was
// set when it launched. So we tell 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.
// Later runs see that `HOME` is set, skip this block, and continue normally.
// Mac, Linux, and WSL hosts already have `HOME` set by the shell, so this
// block does nothing on those platforms.
if (process.platform === 'win32' && !process.env.HOME) {
const userprofile = process.env.USERPROFILE;
if (userprofile) {

View file

@ -1,32 +1,34 @@
#!/usr/bin/env bash
# Devcontainer updateContentCommand — runs on container-create AND whenever
# workspace content changes (lockfile updates etc., per the Dev Container
# spec). Handles workspace dependency installation only; AI CLI state sync
# lives in post-create.sh which runs once after this.
# Devcontainer updateContentCommand. The Dev Container spec runs this when the
# container is created AND whenever workspace content changes (for example a
# lockfile update). This script installs workspace dependencies only. Syncing AI
# CLI state lives in post-create.sh, which runs once right after this.
#
# Why split out: `updateContentCommand` re-runs on content updates, while
# `postCreateCommand` runs only on container-create. Putting `npm install`
# here means a rebuild after pulling new dependencies refreshes them
# without re-running the AI CLI credential/path-translation work each time.
# Why the split: updateContentCommand re-runs on content changes, but
# postCreateCommand runs only at container-create. Keeping `npm install` here
# means a rebuild after pulling new dependencies refreshes them. The AI CLI
# credential and path-translation work does not re-run each time.
set -euo pipefail
cd /workspace
echo "[install-deps] 1/4: chown workspace node_modules + npm cache mount points"
# Named volumes (workspace/*/node_modules, ~/.npm) created at first mount
# inherit ownership from the image's pre-realignment UID. After
# `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.
# The named volumes (workspace/*/node_modules and ~/.npm) are created at first
# mount. They inherit ownership from the image's UID before realignment. Then
# `updateRemoteUserUID: true` shifts the `node` user's UID. Now the volumes are
# owned by the old, stale UID and npm install cannot write to them. So we chown
# again here, after realignment. Running it again later changes nothing.
#
# `find -xdev -exec chown -h` (same idiom as post-create.sh) rather than a bare
# `chown -R`. Two distinct guards: `-xdev` bounds find's DESCENT to each
# volume's own filesystem (it won't recurse into a sub-mounted host bind), and
# `-h` makes chown act on a symlink ITSELF rather than dereferencing it. Without
# `-h`, a symlink inside the tree (a dep postinstall dropping one, or a dangling
# node_modules/.bin link) would either redirect the chown onto its cross-fs
# target or abort provisioning under `set -e` with a dereference error. `-h` is
# a no-op for regular files/dirs, so the intended ownership fix is unchanged.
# We use `find -xdev -exec chown -h` (the same idiom as post-create.sh) instead
# of a plain `chown -R`. There are two separate guards. First, `-xdev` stops
# find from descending past each volume's own filesystem, so it won't recurse
# into a host folder mounted underneath. Second, `-h` makes chown change the
# symlink itself instead of following it to its target. Without `-h`, a symlink
# in the tree (one a dependency's postinstall drops, or a dangling
# node_modules/.bin link) would either send the chown onto a target on another
# filesystem, or fail to follow and abort the whole script under `set -e`. For
# regular files and directories `-h` does nothing, so the ownership fix is the
# same.
for d in /workspace/node_modules \
/workspace/gitnexus/node_modules \
/workspace/gitnexus-web/node_modules \
@ -36,28 +38,28 @@ for d in /workspace/node_modules \
done
echo "[install-deps] 2/4: clear stale .husky/_ runtime cache"
# Docker Desktop's Windows bind-mount permission translation refuses to
# let the new container's `node` user overwrite a `.husky/_/h` left by a
# prior container with a different effective UID. `.husky/_` is
# gitignored runtime cache; husky regenerates it during the root
# `npm install`. Upstream husky has no fix for this UID-clash case.
# On Docker Desktop for Windows, the bind-mount permission translation won't let
# the new container's `node` user overwrite a `.husky/_/h` file that an earlier
# container wrote under a different UID. So we delete it. `.husky/_` is a
# gitignored runtime cache, and husky rebuilds it during the root `npm install`.
# Husky upstream has no fix for this UID clash.
rm -rf .husky/_
echo "[install-deps] 3/4: npm install at root, then gitnexus-shared (build required)"
# Install order: root first (lint-staged + husky + prettier), then
# gitnexus-shared (build needed BEFORE gitnexus-web or gitnexus install
# because both consume it via `file:../gitnexus-shared`).
# Install order matters. Root goes first, for lint-staged, husky, and prettier.
# Then gitnexus-shared, which must be built before installing gitnexus-web or
# gitnexus. Both of those depend on it via `file:../gitnexus-shared`.
npm install
cd /workspace/gitnexus-shared
npm install
npm run build
echo "[install-deps] 4/4: npm install gitnexus-web, then gitnexus"
# gitnexus-web before gitnexus: gitnexus's `prepare` script runs
# scripts/build.js, which compiles gitnexus-web when the directory is
# present. In the devcontainer the full workspace is bind-mounted, so
# gitnexus-web/ is present at gitnexus install time (not the case in the
# production Dockerfiles, which COPY selectively).
# gitnexus-web goes before gitnexus. The gitnexus `prepare` script runs
# scripts/build.js, which compiles gitnexus-web when that directory is present.
# In the devcontainer the whole workspace is bind-mounted, so gitnexus-web/ is
# present when gitnexus installs. The production Dockerfiles COPY only selected
# files, so the directory is not present there.
cd /workspace/gitnexus-web
npm install
cd /workspace/gitnexus

View file

@ -1,46 +1,50 @@
#!/usr/bin/env bash
# Devcontainer postCreate driver. Runs once after the container is created
# (per devcontainer.json `postCreateCommand`). Workspace dependency
# installation lives in install-deps.sh (`updateContentCommand`) which
# runs BEFORE this script — see the spec lifecycle. This script only
# handles AI CLI credential + identity sync from the host.
# Devcontainer postCreate script. It runs once, right after the container is
# created. devcontainer.json wires it up via `postCreateCommand`. Workspace
# dependencies are installed elsewhere, in install-deps.sh (`updateContentCommand`).
# That script runs BEFORE this one — that is the order the devcontainer spec
# defines. This script does one job: sync the AI CLI credentials and identity
# from the host.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[post-create] 1/2: chown AI CLI named-volume mount points"
# 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.
# Fix ownership on the named volumes (~/.claude, ~/.codex, ~/.cursor,
# /commandhistory). When they first mount, they take the user ID baked into the
# image, before any realignment. Then `updateRemoteUserUID: true` shifts the
# `node` user to a new ID. Now the volumes are owned by the old, stale ID, and
# writes into them fail. (~/.local is a directory in the image, not a volume.
# We chown it too, just to be safe.) install-deps.sh fixes the workspace side.
# This script fixes the AI CLI side, so each lifecycle hook handles its own part.
#
# Two distinct guards. `-xdev` bounds find's DESCENT to the volume filesystem
# so it won't recurse 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. `-h` makes chown act on a symlink
# ITSELF, never dereferencing it onto a cross-fs target and never aborting on a
# dangling link under `set -e` (it's a no-op for regular files/dirs).
# There are two separate guards here, and they do different things. `-xdev`
# keeps find from descending into other filesystems. It stops at the volume's
# own filesystem and won't walk into the read-write host bind mounts layered at
# sub-paths (plugins/marketplaces, plugins/cache, skills, agents, memory,
# commands, codex/plugins, cursor/*). Those bind mounts live on a DIFFERENT
# filesystem (9p, virtiofs, or bind). Walking into them would rewrite the
# ownership of the host's own files on a Linux host whose user IDs don't line
# up. Worse, a permission error there would kill provisioning before
# credentials ever sync. `-h` tells chown to act on a symlink ITSELF instead of
# following it. So it never lands on a target across a filesystem boundary, and
# it never aborts on a broken symlink under `set -e`. For regular files and
# directories `-h` does nothing extra.
for d in /home/node/.claude /home/node/.codex /home/node/.cursor \
/home/node/.local /commandhistory; do
sudo find "$d" -xdev -exec chown -h node:node {} +
done
echo "[post-create] 2/2: sync AI CLI credentials + identity from host"
# Defensive cleanup for users upgrading from an earlier devcontainer
# design (Option B) where these paths were symlinks into the read-only
# host stage (e.g. /home/node/.claude/plugins -> /host/.claude/plugins).
# The current RW-bind topology overlays sub-paths but not the parent
# symlink itself, so writes to e.g. /home/node/.claude/plugins/known_marketplaces.json
# would resolve through the stale symlink to a read-only host file and
# EROFS. Drop the symlinks; mkdir -p below recreates them as real
# directories in the named volume.
# Clean up after an older devcontainer design (Option B). Back then these paths
# were symlinks pointing into the read-only host stage
# (e.g. /home/node/.claude/plugins -> /host/.claude/plugins). The current setup
# bind-mounts sub-paths read-write, but it does not replace the parent symlink
# itself. So a write to, say, /home/node/.claude/plugins/known_marketplaces.json
# would follow the old symlink to a read-only host file and fail with a
# read-only-filesystem error. Delete those symlinks here. The mkdir -p below
# then recreates them as real directories in the named volume.
for p in plugins skills agents memory commands; do
[ -L "/home/node/.claude/$p" ] && rm "/home/node/.claude/$p"
done
@ -52,29 +56,30 @@ for p in plugins rules commands agents skills; do
done
mkdir -p /home/node/.claude/plugins /home/node/.cursor/plugins
# Shareable content is RW bind-mounted directly from host in
# devcontainer.json — reads/writes go bidirectionally, nothing for this
# script to do for those:
# Shareable content is bind-mounted read-write straight from the host in
# devcontainer.json. Changes flow both ways, so this script does nothing for it:
# - Claude: plugins/{marketplaces,cache}, skills, agents, memory, commands
# - Codex: plugins (whole dir), prompts, memories, skills
# - Cursor: plugins/{marketplaces,local}, rules, commands, agents, skills
#
# What stays per-container (in the named volume) and gets SYNCED from
# host on container-create:
# The rest stays per-container, in the named volume, and is COPIED from the host
# once when the container is created:
# - .credentials.json (Claude OAuth tokens)
# - .claude/.claude.json (Claude identity: userID, oauthAccount,
# migration tracking — different file from $HOME/.claude.json)
# - settings.json (Claude), config.toml (Codex), mcp.json (Cursor) —
# single config files that can't be bind-mounted (EXDEV on Windows)
# - auth.json (Codex), cli-config.json (Cursor — conflates auth+settings)
# - plugins registry JSONs with absolute paths (Claude + Cursor) —
# translated below
# - .claude/.claude.json (Claude identity: userID, oauthAccount, and
# migration tracking — a different file from $HOME/.claude.json)
# - settings.json (Claude), config.toml (Codex), mcp.json (Cursor). These are
# single config files, and single files can't be bind-mounted on Windows
# (the EXDEV error explained below).
# - auth.json (Codex), cli-config.json (Cursor — which mixes auth and settings)
# - the plugin registry JSONs that contain absolute paths (Claude + Cursor).
# Those are translated below.
#
# Sync semantics: ALWAYS overwrite from host on container-create, so a
# fresh container starts logged in as host's user (if host had creds).
# Container manages its own refresh from there until next rebuild.
# Logging out in container doesn't affect host. Per-container login is
# the design goal; bind-mounting these would make logout shared.
# How the sync behaves: it ALWAYS overwrites from the host when the container is
# created. A fresh container then starts logged in as the host's user, if the
# host had credentials. From that point the container manages its own login,
# until the next rebuild copies the host files again. Logging out inside the
# container does NOT log out the host. Per-container login is the goal, and
# bind-mounting these files would instead make a logout shared between both.
sync_from_host() {
local src=$1
@ -92,52 +97,59 @@ sync_from_host \
sync_from_host \
/host/.claude/.claude.json /home/node/.claude/.claude.json 644
# State files that USED to be single-file bind mounts but couldn't be: on
# Docker Desktop Windows the named volume (ext4) and the host bind-mount
# (9p drvfs) are different filesystems, so atomic config writes
# (`tmp -> rename onto target`) trip EXDEV / Device-or-resource-busy.
# Copy host's version into the named volume on container-create; container
# can rewrite freely from there until next rebuild resyncs.
# These config files are COPIED from the host, not bind-mounted. We tried
# bind-mounting them as single files and it didn't work. On Docker Desktop for
# Windows the named volume (ext4) and the host bind mount (9p drvfs) are
# different filesystems. Apps save a config by writing a temp file and renaming
# it over the real one, and that rename fails across filesystems (the "EXDEV" or
# "Device or resource busy" error). So copy the host's version into the named
# volume when the container is created. The container can then rewrite it freely
# until the next rebuild copies the host version again.
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# 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 unit-tested
# and prettier-checked (translate-plugin-registries.test.cjs).
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
# mixes two kinds of state. Some is portable account and onboarding state we
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
# tipsHistory. The rest describes how Claude is installed on the host, and that
# part is never valid here. This image installs Claude with `npm install -g`,
# but the host's `installMethod` (for example "native") makes Claude look for
# ~/.local/bin/claude and fail with
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
# machine-specific fields and forces hasCompletedOnboarding, while handling a
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
# so it can be unit-tested and prettier-checked
# (translate-plugin-registries.test.cjs).
node "$SCRIPT_DIR/seed-claude-config.cjs"
# Plugin registry path translation (Claude + Cursor). Both bake absolute
# Translate the plugin registry paths (Claude + Cursor). Both write 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). The translation (regex + deep rewrite + the REGISTRIES table) lives
# in translate-plugin-registries.cjs so it is unit-tested and prettier-checked. 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.
# on macOS. That means the host versions can't be bind-mounted into the Linux
# container, because the CLI can't resolve a Windows path under Linux and fails
# with `cache-miss`. The translation rewrites those paths. It uses a regex, a
# deep rewrite, and the REGISTRIES table, all in translate-plugin-registries.cjs
# so it can be unit-tested and prettier-checked. Codex needs no translation. Its
# enablement registry is config.toml, and that holds git URLs rather than
# 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
# auth.json on disk — the copy silently no-ops and
# `codex login --device-auth` inside the container is the path.
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
# (`cli_auth_credentials_store = "keyring"`, the default on macOS). Those hosts
# have no auth.json file, so the copy below quietly does nothing. In that case,
# log in inside the container with `codex login --device-auth`.
sync_from_host \
/host/.codex/auth.json /home/node/.codex/auth.json
# Cursor CLI — cli-config.json conflates auth + settings. Cursor has known
# upstream issues authenticating inside Docker even with correctly-copied
# config; if `cursor-agent` reports auth errors after copy, re-run
# `cursor-agent login` inside the container. mcp.json (Cursor's MCP server
# config) is a single file too — copy-on-create, not bind (EXDEV).
# hooks.json is deliberately NOT synced: Cursor hooks run shell commands,
# so sharing them would widen the supply-chain attack surface into the
# container. Add it yourself if you want host hooks inside the container.
# Cursor CLI. Its cli-config.json holds both auth and settings in one file.
# Cursor has known upstream problems authenticating inside Docker, even when the
# config is copied correctly. If `cursor-agent` reports auth errors after the
# copy, run `cursor-agent login` again inside the container. mcp.json (Cursor's
# MCP server config) is also a single file, so it is copied on create rather
# than bind-mounted, for the same EXDEV reason as above. hooks.json is left out
# on purpose. Cursor hooks run shell commands, and sharing the host's hooks
# would widen the supply-chain attack surface inside the container. Copy it in
# yourself if you want the host's hooks in the container.
sync_from_host \
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
sync_from_host \

View file

@ -1,25 +1,31 @@
// 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.
// Builds the container's $HOME/.claude.json from the host's copy. It does NOT
// copy the host file verbatim. The host's ~/.claude.json holds two kinds of
// data. Some is portable account and onboarding state: hasCompletedOnboarding,
// oauthAccount, userID, projects, tipsHistory. We keep that. The rest tracks
// how Claude was installed on the host machine, and that is never right inside
// this container.
//
// Extracted from a post-create.sh heredoc so the pure transform is unit-tested
// and prettier-checked (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.
// Here is why the install fields break things. The image installs Claude with
// `npm install -g`. But if the host's `installMethod` says something like
// "native", Claude looks for ~/.local/bin/claude and fails with
// "claude command not found at /home/node/.local/bin/claude". So we drop the
// install and machine fields. With them gone, the npm-global binary detects its
// own install method. We also force hasCompletedOnboarding so the setup wizard
// is skipped, even when the host has never run Claude before.
//
// This logic was pulled out of a heredoc in post-create.sh. As its own file the
// transform can be unit-tested and prettier-checked (see seed-claude-config.test
// via the translate-plugin-registries test harness). DISABLE_AUTOUPDATER=1 in
// containerEnv already stops runtime updates. This file only quiets the doctor
// mismatch and the native-path 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.
// Fields that describe how Claude was installed on the host machine. They are
// never valid in an `npm install -g` container. Removing them lets Claude
// detect the npm-global install on its own.
const MACHINE_FIELDS = [
'installMethod',
'autoUpdates',
@ -27,11 +33,12 @@ const MACHINE_FIELDS = [
'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).
// Pure transform: take whatever the host file parsed to and return a config
// object suitable for the container. It also guards against a host file that is
// valid JSON but not an object. A bare number, string, or array would pass the
// parse try/catch. Then the field deletes would do nothing, the
// hasCompletedOnboarding assignment would silently fail, and onboarding would
// trigger again on every rebuild. The guard replaces such a value with {}.
function sanitizeClaudeConfig(parsed) {
let cfg = parsed;
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
@ -40,7 +47,7 @@ function sanitizeClaudeConfig(parsed) {
for (const k of MACHINE_FIELDS) {
delete cfg[k];
}
cfg.hasCompletedOnboarding = true; // skip the wizard even on a first-time host
cfg.hasCompletedOnboarding = true; // skip the wizard, even on a first-time host
return cfg;
}
@ -50,8 +57,8 @@ function readHostConfig(src) {
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.
// Host file is malformed or unreadable. Fall back to an empty config so the
// container still gets a valid file that carries hasCompletedOnboarding.
}
return {};
}

View file

@ -1,33 +1,39 @@
// 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.)
// Rewrites the host paths inside Claude and Cursor plugin-registry JSON files
// so they point at the container's Linux paths, then writes the results into
// the named volume.
//
// Extracted from a post-create.sh heredoc so the regex + deep rewrite are
// unit-tested and prettier-checked (the regex has had path-handling bugs before).
// Why: both CLIs store absolute, OS-native install paths in their registry
// JSONs. On Windows that looks like `C:\Users\X\.claude\plugins\...`; on macOS
// like `/Users/X/.cursor/...`. The Linux container can't use those paths. If we
// just bind-mounted the host files in, the CLI would try to resolve a Windows
// path under Linux and fail with `cache-miss`. So 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 is left alone. Its registry is config.toml and holds git URLs, not
// filesystem paths, so there's nothing to translate — its whole plugins/ dir is
// bind-mounted instead.
//
// This code lived inside a post-create.sh heredoc. We pulled it out so the regex
// and the deep rewrite can be unit-tested and prettier-checked. 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.
// Build a regex that matches an absolute path containing
// `<sep>.<cli><sep>plugins<sep><rest>`, where <sep> is `/` or `\`. It's anchored
// at the start of the string. The lazy `.*?` eats 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.
// Walk `obj` and rewrite every string value that matches `re`. A match is
// remapped under `ctr`, the container's plugins dir. Windows backslashes in the
// matched part are switched 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') {
@ -73,7 +79,7 @@ function translate(registries) {
try {
data = JSON.parse(fs.readFileSync(src, 'utf8'));
} catch {
continue; // skip a malformed host registry rather than abort
continue; // Skip a malformed host registry instead of aborting.
}
try {
fs.writeFileSync(dst, JSON.stringify(rewriteDeep(data, re, reg.ctr), null, 2));

View file

@ -1,18 +1,21 @@
// Unit tests for the devcontainer host->container config transforms.
// Coverage for the logic that used to live inline in post-create.sh heredocs
// (invisible to lint and untestable):
//
// This code used to live inside post-create.sh heredocs, where lint could not
// see it and tests could not reach it. We test three things:
// - 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
// filesystem translate() driver). Path handling here has had bugs before.
// - the strip of machine-specific fields from $HOME/.claude.json
// (sanitizeClaudeConfig + readHostConfig + the seed-claude-config main()
// entry point)
// - the host bind-source bootstrap (ensurePaths). One test guards against a
// regression: ensurePaths must 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.
// We test both pure functions and code that touches the filesystem. The
// filesystem tests use throwaway directories under os.tmpdir() and delete them
// when done. 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):
// Run with the built-in Node test runner (no extra dependencies):
// node --test .devcontainer/
'use strict';
@ -31,8 +34,8 @@ 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.
// Make a fresh throwaway directory under the OS temp root. mkdtemp picks a
// unique name on every call, so we don't need Date.now() or random names.
function tmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gn-dc-'));
}
@ -142,7 +145,7 @@ test('sanitizeClaudeConfig: empty object still gets hasCompletedOnboarding', ()
assert.deepEqual(sanitizeClaudeConfig({}), { hasCompletedOnboarding: true });
});
// --- readHostConfig: filesystem read + fallback paths -----------------------
// --- readHostConfig: reading the file, and the fallbacks when it fails ------
test('readHostConfig: missing file -> {}', () => {
const dir = tmp();
@ -188,12 +191,12 @@ test('readHostConfig: valid object is parsed through', () => {
}
});
// --- translate(): real registry files on disk -------------------------------
// --- translate(): runs against 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
const ctrDir = path.join(ctrParent, 'plugins'); // need not exist yet; translate creates it
try {
const reg = [{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['installed_plugins.json'] }];
fs.writeFileSync(
@ -253,7 +256,7 @@ test('translate: empty and missing host registries are skipped without error', (
const reg = [
{ cli: 'claude', host: hostDir, ctr: ctrDir, files: ['empty.json', 'missing.json'] },
];
fs.writeFileSync(path.join(hostDir, 'empty.json'), ''); // missing.json never created
fs.writeFileSync(path.join(hostDir, 'empty.json'), ''); // we never create missing.json
translate(reg);
assert.equal(fs.existsSync(path.join(ctrDir, 'empty.json')), false);
assert.equal(fs.existsSync(path.join(ctrDir, 'missing.json')), false);
@ -263,7 +266,7 @@ test('translate: empty and missing host registries are skipped without error', (
}
});
// --- seed-claude-config main(): end-to-end via the real CLI entry point -----
// --- seed-claude-config main(): end-to-end, through the real CLI entry point
const SEED_SCRIPT = path.join(__dirname, 'seed-claude-config.cjs');
@ -306,13 +309,18 @@ test('seed main: missing host file still writes a valid onboarding-bearing file'
});
test('seed main: chmodSync widens a pre-existing restrictive dst to 0o644', () => {
// POSIX mode bits only. Under the CI default umask (022) a plain writeFileSync
// already yields 0o644, so asserting 0o644 after a fresh write does NOT prove
// the explicit chmodSync did anything. Pre-create dst at 0o600 first: a 'w'
// write truncates content but PRESERVES an existing file's mode, so the file
// can only reach 0o644 via seed-claude-config.cjs's chmodSync. This isolates
// the chmod from the umask-default path (delete the chmodSync line and this
// test fails, where the other seed test would still pass).
// This checks the file's permission bits, which only exist on POSIX systems.
//
// The catch: CI's default umask is 022, so a plain writeFileSync already
// creates files at mode 0o644. Asserting 0o644 right after a fresh write
// would therefore NOT prove the explicit chmodSync did anything.
//
// So we pre-create dst at the stricter mode 0o600. Opening a file in 'w'
// mode replaces its contents but KEEPS the mode of a file that already
// exists. That means the only way dst can end up at 0o644 is the chmodSync
// inside seed-claude-config.cjs. This pins the test to the chmod and not to
// the umask: delete the chmodSync line and this test fails, while the other
// seed test still passes.
if (process.platform === 'win32') return;
const dir = tmp();
try {
@ -329,7 +337,7 @@ test('seed main: chmodSync widens a pre-existing restrictive dst to 0o644', () =
}
});
// --- ensurePaths: host bind-source bootstrap --------------------------------
// --- ensurePaths: sets up the host paths the bind mounts point at -----------
test('ensurePaths: creates every DIR and FILE under a temp home, idempotently', () => {
const home = tmp();
@ -341,7 +349,7 @@ test('ensurePaths: creates every DIR and FILE under a temp home, idempotently',
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.
// Running it again must not throw and must not overwrite 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}');

View file

@ -1,10 +1,11 @@
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).
# Smoke-tests .devcontainer/ whenever it changes. Two things happen here.
# First, unit tests run on the pure host->container config transforms: the
# plugin-registry path translation, and the strip of the machine field from
# $HOME/.claude.json. Second, the devcontainer image is built through the
# standard @devcontainers/cli path. That CLI reads build.args from
# devcontainer.json, so the version pin there stays the single source of truth.
on:
push:
branches: [main]
@ -20,7 +21,8 @@ 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.
# Grouped per branch or tag. Cancel a PR run when a newer one replaces it.
# Never cancel a push-to-main run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
@ -31,8 +33,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# persist-credentials: false — read-only job (tests + syntax checks),
# never pushes; keeps GITHUB_TOKEN out of .git/config (zizmor artipacked).
# persist-credentials: false — this job only reads (tests and syntax
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
# .git/config, which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -51,32 +54,35 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# persist-credentials: false — read-only build smoke, never pushes;
# keeps GITHUB_TOKEN out of .git/config (zizmor artipacked).
# persist-credentials: false — this is a read-only build smoke that
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
# which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- 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.
# Builds the image the same way a developer's "Reopen in Container" does.
# @devcontainers/cli reads devcontainer.json (jsonc format), resolves
# build.args (the CLAUDE_CODE_VERSION / CODEX_VERSION pins), and runs the
# Dockerfile. This smoke catches Dockerfile regressions and any drift from
# the canonical version pins. The lifecycle hooks (post-create.sh) do not
# run here. They need the host config mounts, and CI has none.
#
# ARCH COVERAGE: this runs on an x64 runner with no --platform/QEMU, so it
# exercises the amd64 Cursor branch (CURSOR_SHA256_X64) only. The arm64
# branch (CURSOR_SHA256_ARM64 + the arm64 tarball URL) is pinned by sha256
# verified against the published artifact, but is not BUILT here. Cursor's
# extract+symlink step is arch-independent, so the residual gap is a stale
# arm64 URL/hash; add a linux/arm64 matrix leg (docker/setup-qemu-action +
# `--platform`) if that becomes a concern.
# ARCH COVERAGE: this runs on an x64 runner with no --platform or QEMU, so
# it builds only the amd64 Cursor branch (CURSOR_SHA256_X64). The arm64
# branch (CURSOR_SHA256_ARM64 plus the arm64 tarball URL) is pinned by a
# sha256 checked against the published artifact, but it is not BUILT here.
# Cursor's extract-and-symlink step does not depend on the architecture, so
# the only remaining gap is a stale arm64 URL or hash. If that becomes a
# concern, add a linux/arm64 matrix leg (docker/setup-qemu-action plus
# `--platform`).
#
# 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.
# The @devcontainers/cli version is pinned on purpose. A bare
# `npx --yes @devcontainers/cli` would resolve @latest at run time. A
# breaking or malicious publish could then change CI behavior, or change
# how devcontainer.json is read, with no diff to show for it. Bump this pin
# deliberately, alongside the Dockerfile and devcontainer.json pins.
- name: Build devcontainer via @devcontainers/cli
run: npx --yes @devcontainers/cli@0.87.0 build --workspace-folder .