mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
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:
parent
4169c50a13
commit
d2047844f4
9 changed files with 568 additions and 502 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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}');
|
||||
|
|
|
|||
60
.github/workflows/ci-devcontainer.yml
vendored
60
.github/workflows/ci-devcontainer.yml
vendored
|
|
@ -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 .
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue