diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1defdcc1d..fec432e30 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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////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////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//linux//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} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1bce4b219..5899691d8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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//linux//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/. - // 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/.. 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 `~/.` 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 `~/.` 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// 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// 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/. 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/. 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" diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs index ccb63db52..62d00b2dc 100644 --- a/.devcontainer/ensure-host-config-dirs.cjs +++ b/.devcontainer/ensure-host-config-dirs.cjs @@ -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/. 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/. 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/. -// 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/. 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) { diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh index 46c419711..d574f77be 100644 --- a/.devcontainer/install-deps.sh +++ b/.devcontainer/install-deps.sh @@ -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 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 302283e48..3fafc0dd9 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -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 \ diff --git a/.devcontainer/seed-claude-config.cjs b/.devcontainer/seed-claude-config.cjs index ee998a4c3..6010753a4 100644 --- a/.devcontainer/seed-claude-config.cjs +++ b/.devcontainer/seed-claude-config.cjs @@ -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 {}; } diff --git a/.devcontainer/translate-plugin-registries.cjs b/.devcontainer/translate-plugin-registries.cjs index 0d3998298..16e3b9fcc 100644 --- a/.devcontainer/translate-plugin-registries.cjs +++ b/.devcontainer/translate-plugin-registries.cjs @@ -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 `/./plugins/` to `/home/node/./plugins/`, -// 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 `/./plugins/` to +// `/home/node/./plugins/`, 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 `.plugins` -// where is `/` or `\`. Anchored at start; the lazy `.*?` consumes the -// home prefix up to the FIRST `./plugins` segment. +// Build a regex that matches an absolute path containing +// `.plugins`, where is `/` or `\`. It's anchored +// at the start of the string. The lazy `.*?` eats the home prefix up to the +// FIRST `./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)); diff --git a/.devcontainer/translate-plugin-registries.test.cjs b/.devcontainer/translate-plugin-registries.test.cjs index 6b31b9d46..042487029 100644 --- a/.devcontainer/translate-plugin-registries.test.cjs +++ b/.devcontainer/translate-plugin-registries.test.cjs @@ -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}'); diff --git a/.github/workflows/ci-devcontainer.yml b/.github/workflows/ci-devcontainer.yml index 3984ef17f..aa7785c28 100644 --- a/.github/workflows/ci-devcontainer.yml +++ b/.github/workflows/ci-devcontainer.yml @@ -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 .