GitNexus/.devcontainer/Dockerfile
Gergo Magyar 1008b0dcf9 fix(devcontainer): resolve ce-code-review findings (doc drift, chown scope, .cjs extraction, CI smoke)
Multi-agent review (9 reviewers) found the devcontainer files carried
comments + README from the abandoned read-only-symlink design, plus real
behavioral gaps. Resolved all actionable findings (no deferrals).

Documentation drift (the headline — stale comments described a security
model opposite to what shipped):
- README "Trust boundary" claimed a malicious dep "cannot write back …
  the read-only /host mount blocks the write." FALSE — the shareable dirs
  are RW-bound. Rewrote to document the bidirectional write-through, what
  stays one-way (credentials never flow back), and how to close it.
- devcontainer.json mount group-1 comment described "selectively symlinks
  … read-only eliminates write-through" — replaced with the RW-bind reality.
- Header "Windows-native is unsupported" -> supported (auto HOME setup).
- containerEnv comment "credentials persist in host-bind-mounted dirs" ->
  they live in the named volumes.
- hooks.json exclusion documented honestly as a partial mitigation, not a
  clean boundary (commands/agents/skills/rules are equally executing).
- ~/.local "named volume" -> image directory.

Behavioral fixes:
- chown -R recursed into the RW host binds (could rewrite host ownership /
  EPERM-abort provisioning on non-UID-aligned Linux). Switched to
  `find -xdev` per dir so chown stays on the volume filesystem.
- Cursor installer wrapped in `timeout 300` — its inner binary download
  isn't covered by curl --max-time and could hang docker build forever.
- Removed dead CURSOR_VERSION ARG/ENV/build-arg (never consumed; "latest"
  implied a pin the installer can't honor). Documented why Cursor is unpinned.

Extraction + tests (the two inline post-create.sh node heredocs were
unlintable and untestable; the path regex had had bugs):
- seed-claude-config.cjs — installMethod-strip seed, now with a non-object
  guard (a bare-value/array host .claude.json could otherwise slip the
  try/catch and silently re-trigger onboarding) and labeled write errors.
- translate-plugin-registries.cjs — plugin-registry path translation with
  labeled errors.
- translate-plugin-registries.test.cjs — 12 tests (Windows/POSIX paths,
  cross-CLI isolation, nested objects, non-object/empty-config guard).
- post-create.sh calls the modules via $SCRIPT_DIR.

CI:
- .github/workflows/ci-devcontainer.yml — runs the unit tests + shell
  syntax checks + a `@devcontainers/cli build` smoke on .devcontainer/**
  changes. Conforms to the repo concurrency convention (validator passes).

Documented (real gaps, fixes are honest docs since no correct auto-fix
exists): user-scope MCP servers with absolute host command paths don't
resolve in-container; user-scope config is copy-on-create so host edits
need a rebuild; in-container plugin installs get shadowed by an empty host
bind on rebuild (recovery noted); plugin installs are single-writer across
checkouts; gh/docker RW-vs-ssh/aws/azure-RO rationale.

Verified: fresh `@devcontainers/cli up` succeeds; installMethod stripped,
registry translated to Linux paths, credentials node:node, 12/12 tests pass.
2026-05-28 21:48:37 +01:00

88 lines
4.2 KiB
Docker

# 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.
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# 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.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
ARG TZ=UTC
ARG USERNAME=node
# NOTE: there is intentionally no CURSOR_VERSION ARG. The cursor.com/install
# script does not honour a version pin, so an ARG would be dead config that
# implies a guarantee the installer can't keep. Cursor is currently installed
# unpinned (see the install step below); pinning is tracked as a follow-up in
# README § "What's not included (yet)".
# Promote build-only ARGs into runtime ENV so shells and lifecycle scripts
# can read them. CLAUDE_CONFIG_DIR is intentionally NOT set here — the
# canonical value lives in devcontainer.json `containerEnv` (single source
# of truth; runtime-time wins anyway).
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
TZ=${TZ} \
DEVCONTAINER=true \
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.
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.
RUN mkdir -p \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local/bin \
/commandhistory \
&& chown -R ${USERNAME}:${USERNAME} \
/home/${USERNAME}/.npm \
/home/${USERNAME}/.local \
/commandhistory
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.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Cursor CLI install. The official cursor.com/install script does not
# expose version pinning or a checksum, so we download to a temp file,
# log the sha256 to build output (so drift across rebuilds shows up in
# CI logs), then execute. Trust assumption: cursor.com's TLS chain is
# reliable. Long-term hardening (tracked as a follow-up): pin a specific
# downloads.cursor.com/lab/<version>/<arch>/agent-cli-package.tar.gz URL
# with a hard sha256 verification and skip the install script entirely.
#
# `timeout 300` wraps the installer because the script makes its OWN network
# requests (it downloads the actual Cursor binary) that `curl --max-time`
# above does NOT cover — without it a slow/hung Cursor CDN would block the
# Docker build indefinitely with no watchdog.
RUN curl -fsS --retry 3 --max-time 60 -o /tmp/cursor-install.sh https://cursor.com/install \
&& echo "Cursor installer sha256:" \
&& sha256sum /tmp/cursor-install.sh \
&& timeout 300 bash /tmp/cursor-install.sh \
&& rm -f /tmp/cursor-install.sh
# ~/.local/bin (where Cursor's installer drops `agent` and `cursor-agent`
# symlinks) on PATH for interactive shells and lifecycle scripts.
ENV PATH=/home/${USERNAME}/.local/bin:${PATH}