Commit graph

20 commits

Author SHA1 Message Date
Gergo Magyar
d2047844f4 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.
2026-05-29 07:47:39 +01:00
Gergo Magyar
bfdd183c95 fix(devcontainer): resolve adversarial review findings (pins, RO mounts, tests)
Resolves the blocking + actionable findings from the PR #1875 review:

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

Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
2026-05-29 07:03:15 +01:00
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
Gergo Magyar
0d4a53e84d feat(devcontainer): Codex + Cursor plugin/config host parity with Claude
Codex plugins installed in the container never reached the Windows host
because, unlike Claude, the Codex plugin tree wasn't bind-mounted —
only memories/ and skills/ were. Verified via live /proc/mounts: Claude
binds 6 shareable dirs (incl. plugins/marketplaces + plugins/cache),
Codex bound 2. So `codex plugin add` wrote into the ext4 named volume
and stayed there.

Codex changes:
- Bind the WHOLE ~/.codex/plugins dir + ~/.codex/prompts (plus existing
  memories/skills). Strace of two real `codex plugin add` runs proved
  the installer stages INSIDE plugins/cache/<marketplace>/ and renames
  intra-dir, so a single 9p bind of plugins/ keeps the rename intra-fs —
  no EXDEV (the bug that broke single-file binds). .tmp/ stays on the
  volume (it's the cross-fs staging source). No path translation needed:
  Codex enablement lives in config.toml as git URLs, not FS paths.
- Verified live: `codex plugin add compound-engineering@...` now writes
  through to C:\Users\...\.codex\plugins\cache\ on the Windows host, and
  host-created files appear in the container (bidirectional).

Cursor changes (review found cursor-agent has a real plugin surface, not
editor-only — Cursor 2.5 Marketplace shared by IDE + CLI):
- Bind plugins/marketplaces, plugins/local, rules, commands, agents,
  skills (dir binds, EXDEV-safe).
- Copy-on-create mcp.json (single file → EXDEV-unsafe as bind), alongside
  the existing cli-config.json.
- Translate plugins/installed_plugins.json (carries absolute Windows
  paths like Claude's) — generalized the existing path-rewrite to run
  for both Claude and Cursor.
- hooks.json deliberately NOT shared (runs shell commands → supply-chain
  surface); documented as opt-in.

ensure-host-config-dirs.cjs pre-creates all new host bind sources.
post-create.sh defensive symlink cleanup extended to the new Codex/Cursor
paths. README updated with the accurate per-CLI share/sync/translate
matrix.

Design adversarially verified (straced installs, EXDEV primitive tests,
sqlite-under-bind check, path-encoding check) before implementing.
2026-05-28 19:52:11 +01:00
Gergo Magyar
2374e2c55b fix(devcontainer): drop single-file binds — fixes Codex batchWrite failed in TUI
On Docker Desktop Windows the named volumes are ext4 (`/dev/sdd`) while
single-file bind mounts from the Windows host land as 9p (drvfs).
Different filesystems → atomic config writes (write `foo.tmp`, then
rename onto `foo`) trip EXDEV `inter-device move failed` /
`Device or resource busy`.

Codex's TUI surfaces this as `config/batchWrite failed in TUI` when
saving model preference. Claude's writes to settings.json / .claude.json
fail the same way, silently.

Reproduction in container:
  $ echo x > /tmp/foo.toml; mv /tmp/foo.toml /home/node/.codex/config.toml
  mv: inter-device move failed: ... Device or resource busy

Fix: drop the three single-file bind mounts. Sync host's versions into
the named volume on container-create via `sync_from_host` (same pattern
already used for credentials). Atomic rename within the volume works
because everything is ext4.

Trade-off: container writes to these files no longer propagate to host;
they stay in the volume until next rebuild, which re-syncs from host.
Host is source of truth on rebuild — same model as credentials. Plugin/
skill/agent/memory/command DIRS still bind-mount bidirectionally (atomic
writes within a dir bind stay on one filesystem, no EXDEV).

Files affected:
- ~/.codex/config.toml
- ~/.claude/settings.json
- ~/.claude.json (HOME-level — added `/host/.claude.json` RO mount back
  for sync_from_host to read)
2026-05-28 19:09:42 +01:00
Gergo Magyar
ed03ae3ea8 refactor(devcontainer): split workspace-deps to updateContentCommand
post-create.sh was doing two unrelated jobs: workspace dependency install
(four `npm install` runs in topological order) and AI CLI credential
sync. They have different lifecycle needs — deps should re-run when
lockfiles change, AI sync should run once per container — but both were
gated on container-create.

Per Dev Container spec lifecycle, `updateContentCommand` is the right
hook for workspace deps: runs at container-create AND on content
changes (lockfile updates). `postCreateCommand` is right for AI CLI
sync: container-create only.

Move steps 3-7 (husky cleanup + four `npm install` runs) into
install-deps.sh wired as `updateContentCommand`. Split the chown step
too — install-deps owns workspace-side dirs (node_modules volumes,
~/.npm), post-create owns AI-side dirs (~/.claude, ~/.codex, ~/.cursor,
/commandhistory, ~/.local). Each script now has one concern.

post-create.sh drops from ~187 lines to 148; install-deps.sh is 56 lines
new. Faster rebuilds when nothing about deps changed (the credential
sync + path translation work still runs every container-create, but the
npm install dance no longer does).

Research backing (no other simplification applies):
- Anthropic's reference devcontainer uses pure named volumes; no
  host-state inheritance pattern is published.
- Path translation has no upstream fix (issues #21916, #10379 closed
  without resolution). Our Node rewrite is the workaround.
- pnpm workspaces (`pnpm -r install`) would replace the four installs
  with one command, but that's a real refactor (touches
  gitnexus/scripts/build.js + 4 package.json files); deferred.
- `HUSKY=0` in containerEnv would drop the `rm -rf .husky/_` hack, but
  would also stop pre-commit hooks from firing inside the container;
  deferred.
2026-05-28 17:16:44 +01:00
Gergo Magyar
1f8c32e2f6 fix(devcontainer): translate host plugin registry paths to Linux on rebuild
The previous topology bind-mounted the entire `~/.claude/plugins/`
directory from host. That brought through plugins, marketplaces, and
extracted cache content correctly — but ALSO brought through the
registry JSONs (`known_marketplaces.json`, `installed_plugins.json`,
`plugin-catalog-cache.json`) which carry absolute OS-native paths:

  "installLocation": "C:\Users\gergo\.claude\plugins\marketplaces\X"
  "installPath": "C:\Users\gergo\.claude\plugins\cache\Y\Z"

Claude in the Linux container fails to resolve these Windows paths and
reports `Marketplace X failed to load: cache-miss`.

Split the topology:
- `plugins/marketplaces/` (git clones) and `plugins/cache/` (extracted
  plugin files) stay bidirectional RW binds — content is path-independent.
- Registry JSONs move into the per-container named volume. post-create.sh
  reads host's versions, rewrites any absolute path ending in
  `/.claude/plugins/<rest>` (Windows `C:\Users\...` and POSIX
  `/Users/...` / `/home/...` patterns) to `/home/node/.claude/plugins/<rest>`,
  and writes the translated result to the volume.

What this gets you:
- Plugin installed on host → next container rebuild has it (translated).
- Plugin installed inside container → lives in volume registry; lost on
  rebuild (consistent with credentials model). Re-install on host for
  persistence.

ensure-host-config-dirs.cjs now also creates `plugins/marketplaces/` and
`plugins/cache/` on host if absent (Docker rejects bind mounts whose
source doesn't exist).
2026-05-28 17:00:11 +01:00
Gergo Magyar
b43fc21c98 refactor(devcontainer): hybrid RW bind + per-container creds — fixes EROFS on in-container plugin install
The previous Option B topology (RO host stage + named volume + symlinks
into the volume) made `/plugin marketplace add` inside the container fail
with EROFS — the symlinks pointed at a read-only mount, so Claude
couldn't create new marketplace dirs. Switch to a hybrid: shareable
content (plugins/skills/agents/memory/commands/settings.json/$HOME/.claude.json
for Claude; config.toml/memories/skills for Codex) gets a direct RW bind
from host so reads and writes go bidirectionally; credentials + the
small identity file stay in per-container named volumes so logout in
container doesn't log out host.

Mount precedence does the heavy lifting: the named volume mounts at
/home/node/.<cli> first, then sub-path bind mounts overlay specific
sub-paths. Container's view at /home/node/.claude/plugins/ is the host
dir; container's view at /home/node/.claude/.credentials.json is the
named volume's file.

What this gives you:
- /plugin marketplace add in container = installed on host
- New skill on host = visible in container immediately (no rebuild)
- claude logout in container = host stays logged in
- compound-engineering plugin enabled on host = enabled in container
- Theme picker fires once (or never if host has theme set)

What it costs:
- Write-through: a compromised npm dep in workspace deps can write to
  host ~/.claude/{plugins,skills,agents,memory,commands}/. Documented
  trade-off; for personal dev, accepted. Credentials still per-container.

post-create.sh becomes much simpler — only syncs the four credential
files from host into the named volumes. No more symlink dance, no more
state-file merging.

ensure-host-config-dirs.cjs gains the new bind sources: the shareable
subdirs and settings.json/config.toml files get mkdir/touched on host
so Docker doesn't reject the mount when a CLI has never been used.
2026-05-28 16:44:56 +01:00
Gergo Magyar
26470d5580 fix(devcontainer): full plugin/config parity by dropping CLAUDE_CONFIG_DIR + syncing settings.json
Two changes that together give the container the same plugins and configs
as the host for all three AI CLIs (login stays per-container):

1. Drop CLAUDE_CONFIG_DIR from containerEnv. The named-volume mount target
   `/home/node/.claude` already matches Claude's default `~/.claude`, so
   the env var added no behavior — but setting it changed which file
   Claude reads `hasCompletedOnboarding` from. With it set, Claude reads
   `$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file that
   does NOT carry `hasCompletedOnboarding`); without it, Claude reads
   `$HOME/.claude.json` (the big onboarding-state file that does). The
   wizard fires every container-create when set, skips when unset.

2. Sync `settings.json` from host (Claude) + symlink `memories/` and
   `skills/` from host (Codex). Theme + `enabledPlugins` +
   `extraKnownMarketplaces` live in `settings.json` — without syncing
   it, the theme picker fires and host-installed plugins stay disabled
   even though their files are symlinked in. Codex's `memories/` and
   `skills/` are the symmetric Codex user-installed surface, now shared
   the same way Claude's plugins/skills/agents/memory/commands are.

Cursor stays as-is — `cli-config.json` conflates auth+settings (already
synced), and there's no separate plugin surface to mirror.

Login details remain per-container by design (acceptable to re-login on
rebuild). Everything else — plugins, skills, agents, memory, MCP user-
scope config, project trust, theme, plugin enablement — now matches
host on every container-create.
2026-05-28 16:26:16 +01:00
Gergo Magyar
abbcacebca refactor(devcontainer): hybrid AI CLI config — read-only host share + per-container credentials
Restructure the Claude Code / Codex / Cursor mount topology to fix the
silent first-run-UI bug surfaced in PR testing, and to harden against
the host-write-through escape class the previous bind-mount design
exposed.

The actual root cause of the first-run wizard firing on the user's
screenshot — confirmed via three parallel research agents (best
practices, framework docs deep dive of the OpenAI Codex Rust source,
adversarial design review) — was NOT a credential permission check.
Claude Code splits state across `~/.claude/.credentials.json` AND
`~/.claude.json` (a FILE at $HOME, sibling of the `.claude/` dir).
The latter holds `hasCompletedOnboarding`, `userID`, `oauthAccount`
metadata, MCP user-scope config, and per-project trust state — and
Claude Code reads it at literal `$HOME/.claude.json`, not via
`CLAUDE_CONFIG_DIR`. The previous design mounted `~/.claude/` but
left `~/.claude.json` outside the topology entirely, so every container
started with a missing onboarding-state file and re-ran the wizard.

Confirmed by tfvchow/field-notes-public#10:
"Persisting .credentials.json alone is NOT sufficient. Without
.claude.json, Claude Code treats the session as a fresh install and
prompts for login regardless of valid credentials being present."

The new topology:

**Mounts**
- `${localEnv:HOME}/.claude` → `/host/.claude` (read-only bind)
- `${localEnv:HOME}/.codex` → `/host/.codex` (read-only bind)
- `${localEnv:HOME}/.cursor` → `/host/.cursor` (read-only bind)
- `${localEnv:HOME}/.claude.json` → `/host/.claude.json` (read-only bind)
- `claude-config-${devcontainerId}` → `/home/node/.claude` (named volume)
- `codex-config-${devcontainerId}` → `/home/node/.codex` (named volume)
- `cursor-config-${devcontainerId}` → `/home/node/.cursor` (named volume)

**containerEnv** gains `CODEX_HOME=/home/node/.codex` (Codex's own env
override, per its public Rust source). `CLAUDE_CONFIG_DIR=/home/node/
.claude` was already set.

**`post-create.sh`** stages the named volumes on first run:

- Symlinks shareable subdirs from `/host/.claude` into the named volume:
  `plugins/`, `skills/`, `agents/`, `memory/`, `commands/`. Codex gets
  `config.toml` symlinked. Cursor has no shareable subdirs (cli-config
  .json conflates auth and settings).
- Copies `.credentials.json`, `auth.json`, `cli-config.json` on first
  run with `chmod 600`. After first run, container manages its own
  refresh; host's credentials untouched.
- Copies `~/.claude.json` on first run (with stub
  `{"hasCompletedOnboarding":true,"installMethod":"global"}` fallback
  for hosts that haven't run Claude Code). This is the fix for the
  observed onboarding-wizard loop.

`ensure-host-config-dirs.cjs` now also touches `~/.claude.json` on the
host if missing, so the bind mount has a valid source on hosts that
have never run Claude Code.

**Why read-only + named volume vs. the previous full bidirectional
bind mount:**

1. **Host filesystem write-through escape, eliminated.** Previous
   design symlinked `plugins/`, `agents/`, `skills/` write-through
   into the host's `~/.claude/` — a malicious npm package in the
   workspace dep tree could drop `agents/evil.md` into the host's
   config, which the next host Claude session would auto-load. The
   read-only `/host` mount blocks this; container compromise no
   longer persists across teardown via host-side autoload.
2. **Windows bind-mount perm-flattening, sidestepped.** Files
   surfaced through a Docker Desktop Windows bind mount appear as
   `root:root` mode `777`. Credentials in the named volume come with
   proper Linux ownership and `chmod 600` — what each CLI expects on
   write (none enforces on read, but write-side hygiene matters for
   the host's understanding of "where credentials live").
3. **No `ide/` lock-file collisions.** Previous design symlinked
   `~/.claude/ide/` write-through, including per-PID lock files. Host
   PID and container PID namespaces are unrelated → lock-file PIDs
   misclassify dead processes as alive. Skipping `ide/` keeps lock
   files container-local.
4. **No `projects/` ghost dirs.** Host encodes the workspace path as
   `D--development-coding-GitNexus`, container as `-workspace`.
   Bidirectional `projects/` symlinks would split memory and session
   state across two ghost project dirs for what is conceptually the
   same project. Skipping `projects/` keeps per-project state
   container-local; host's projects/ stays untouched.
5. **No `settings.json` version drift.** Container is pinned to a
   specific Claude Code version (`CLAUDE_CODE_VERSION` build arg);
   host floats with auto-update. Bidirectional `settings.json` writes
   produced silent schema rollback. Skipping settings.json keeps each
   side authoritative for its own version.

**README** rewritten in the same section to describe the new topology
honestly: what's shared, what isn't, the OAuth refresh-token
divergence between host and container, per-CLI quirks (macOS Keychain
storage, Cursor's known upstream in-container auth bug, Codex
keyring storage). Trust-boundary section updated to name the threat
model accurately — same read surface as before (malicious dep can
still READ all credentials), but write-through into host plugin/agent
dirs is now blocked.

Verified locally: `@devcontainers/cli read-configuration` resolves all
19 mounts correctly on Windows, `post-create.sh` parses, and
`ensure-host-config-dirs.cjs` idempotently touches `~/.claude.json`.

Research backing this design:

- Anthropic Claude Code devcontainer docs (named-volume pattern):
  https://code.claude.com/docs/en/devcontainer
- tfvchow/field-notes-public#10 (both files required):
  https://github.com/tfvchow/field-notes-public/issues/10
- anthropics/claude-code#29029 (VS Code extension strips
  hasCompletedOnboarding):
  https://github.com/anthropics/claude-code/issues/29029
- OpenAI Codex Rust source (no read-side perm check):
  https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs
- Cursor CLI in-Docker auth issue:
  https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995
2026-05-28 15:31:02 +01:00
Gergo Magyar
6a569e401e feat(devcontainer): bind-mount ~/.docker, ~/.aws, ~/.azure for agent workflows
Extend the host bind-mount surface so coding agents inside the container
inherit cloud + container-registry auth from the host without any
per-container setup:

- ~/.docker (read-write) — Docker registry auth (config.json) + buildx
  config. Container-registry pushes (ghcr.io, docker.io) from inside the
  container pick up host `docker login` state. Read-write because the
  Docker CLI refreshes credential-helper tokens.
- ~/.aws (read-only) — AWS CLI / SDK credentials. Read-only because
  rotating creds typically happens via the host. Empty on this dev box,
  so forward-compatible: the moment you `aws configure` on the host the
  container picks it up on the next rebuild.
- ~/.azure (read-only) — Azure CLI credentials. Same pattern as ~/.aws.

`ensure-host-config-dirs.cjs` extends to mkdir these three on init so
the bind mounts always have a valid source even if a CLI has never been
used on this host.

The Docker CLI itself isn't installed in the container by default — the
~/.docker/ mount is inert until you add `docker-outside-of-docker:1` or
similar Feature. README now calls this out under "What you still don't
have inside the container" so it's obvious which CLIs are agent-ready
and which need a feature add to become useful.

README updates:

- Bind-mount table gains a "Why" column and rows for the three new
  mounts, making it clear at a glance what each one enables.
- Trust-boundary section lists Docker registry tokens, AWS, and Azure
  creds in the read-side exfil path so the threat model stays honest as
  the credential surface grows.
- New subsection lists not-included CLIs (Docker, AWS, Azure, gcloud,
  kubectl, private-npm) with the exact Feature ID or mount snippet
  needed to enable each — turns "I want my agent to do X" into a
  one-line config change.

Verified locally: `npx @devcontainers/cli read-configuration` resolves
all 9 host bind mounts to valid C:\Users\<name>/* paths on Windows.
2026-05-28 14:23:30 +01:00
Gergo Magyar
213390a4b5 fix(devcontainer): drop ~/.gitconfig bind mount; defer to VS Code auto-copy
VS Code's Dev Containers extension auto-copies the host's gitconfig into
the container at attach time using `(dd ...) >> /home/node/.gitconfig`.
A read-only bind mount of ~/.gitconfig blocks that write, so attach
failed with `cannot create /home/node/.gitconfig: Read-only file system`.
Making it read-write would let the append succeed, but the bind mount
means the host file and the container file are the same file — VS Code's
append would double the host gitconfig contents on every container
start.

Drop the ~/.gitconfig bind mount entirely. VS Code's auto-copy is the
purpose-built mechanism for this, gives the container the host's
user.name / user.email transparently, and avoids both the read-only
write failure and the append-duplication trap. The container ends up
with a writable /home/node/.gitconfig that's a copy of the host's, not
a mount.

The remaining six bind mounts (.claude, .codex, .cursor, .ssh, .config/git,
.config/gh) keep their existing modes — XDG-style git config under
~/.config/git is unaffected by VS Code's auto-copy (which only targets
~/.gitconfig), so its read-only bind mount stays.

Also remove the `.gitconfig` touch from ensure-host-config-dirs.cjs
(now unnecessary) and update the README CLI-state table, sharing
explanation, and troubleshooting row to reflect that gitconfig flows
in via VS Code auto-copy rather than the bind mount.
2026-05-28 14:14:36 +01:00
Gergo Magyar
f592c804ed fix(devcontainer): Node-based initializeCommand; bind-mount .ssh + .config/git
Two fixes bundled:

1. The previous commit's OS-keyed `initializeCommand` object was based
   on a misread of the Dev Containers spec. The object form on command
   properties is **named parallel tasks**, not OS dispatch — VS Code ran
   all three keys in parallel via cmd.exe on Windows, the POSIX branches
   failed, and container creation aborted before Docker was invoked.

   Restore the single-string Node-based form:
   `node .devcontainer/ensure-host-config-dirs.cjs`. Node works
   identically in cmd.exe on Windows and bash/zsh on Linux/macOS/WSL,
   and `os.homedir()` respects $HOME on POSIX and %USERPROFILE% on
   Windows. The script is idempotent (mkdirSync recursive is a no-op
   for existing dirs; touch is gated on .gitconfig existence).

   Document Node ≥18 on the host as the only host-side prerequisite
   beyond Docker Desktop and the VS Code Dev Containers extension.
   Anyone running Claude Code on the host already has it.

2. Extend the host-bind mount surface with `~/.ssh` and `~/.config/git`,
   both read-only:

   - `~/.ssh` lets commit signing + push over SSH remotes work inside
     the container without copying private keys. Read-only mount means
     container code can read keys but can't modify or delete them.
     (Threat: a malicious dep can still read private keys from inside
     the container; the read-only mount narrows write-side blast
     radius, not read-side. Documented in the trust-boundary section.)
   - `~/.config/git` covers XDG-style git config (`~/.config/git/config`,
     `~/.config/git/ignore`, `~/.config/git/attributes`) for users who
     keep settings there instead of `~/.gitconfig`. Read-only, same as
     `~/.gitconfig`.

   Update the CLI-state-sharing table and trust-boundary paragraph to
   reflect the expanded surface.

Re-adds .devcontainer/ensure-host-config-dirs.cjs (deleted before the
OS-keyed attempt).
2026-05-28 13:52:38 +01:00
Gergo Magyar
32cb7dc28a fix(devcontainer): cross-platform initializeCommand + soften Windows-native posture
The previous commit's `initializeCommand` was POSIX-only (`mkdir -p $HOME/...`).
VS Code on Windows runs the host shell as `cmd.exe /c ...`, which can't
parse POSIX syntax — `$HOME` doesn't expand, `mkdir -p` errors, the init
fails with `The syntax of the command is incorrect`, and container
creation aborts before Docker is invoked.

Switch `initializeCommand` to the spec's OS-keyed object form:
- linux/darwin (covers WSL2 because VS Code runs initializeCommand in
  the WSL shell when attached via the WSL extension): POSIX mkdir+touch,
  as before
- win32: PowerShell snippet that creates the same directories under
  $USERPROFILE and touches the gitconfig if missing

Soften the README's hard "WSL2 required" framing from the previous
commit. Reality per `@devcontainers/cli read-configuration` output:
`${localEnv:HOME}` on Windows-native resolves to `C:\Users\<name>`
(VS Code falls back to USERPROFILE), so the bind mount sources are
valid Windows paths and Docker Desktop handles the translation. The
earlier `accessing specified distro mount service` failure was a
separate Docker Desktop WSL-integration issue, not a HOME-resolution
issue. Windows-native works; it's just slower with more bind-mount
permission edge cases (the husky/_/h EPERM class). The README now
explains the tradeoff and steers toward WSL2 for performance + file
watchers + permission reliability, rather than blocking Windows-native
checkouts outright.

Update the troubleshooting row to reflect the new posture.
2026-05-28 13:46:13 +01:00
Gergo Magyar
cd8ac5f6d1 refactor(devcontainer): address ce-code-review findings (P0 + 4 × P1 + 8 × P2 + 2 × P3)
Walkthrough resolution of the 16-finding ce-code-review on PR #1875. 15 of
16 findings applied; one (F12, Anthropic Feature floating tag) was
superseded by F6's Feature removal.

P0
- F1: WSL2 is now REQUIRED for Windows hosts, not just recommended.
  ${localEnv:HOME} resolves to empty string on Windows-native (no HOME env
  var) — bind mounts then point at /.claude, /.codex etc. and silently
  break. ensure-host-config-dirs.cjs wrote to USERPROFILE-derived paths
  via os.homedir(), so the two surfaces disagreed about which env var was
  "home" on Windows. README header reframed; "Windows 11 — WSL2 is required"
  section explains the mismatch concretely.

P1
- F2: Workspace `node_modules` volume names now include `-${devcontainerId}`
  so two GitNexus checkouts on the same host (~/work/GitNexus and
  ~/projects/GitNexus) don't share volumes and corrupt each other's
  installs.
- F3 + F5: `postCreateCommand` extracted to `.devcontainer/post-create.sh`
  with `set -euo pipefail` and six labeled echo steps so failure logs
  name the step instead of an opaque &&-chain index. Chown step extended
  to cover /home/node/.npm, /commandhistory, and /home/node/.local — these
  named-volume mount points were owned by build-time UID 1000 but the
  container's `node` is re-IDed at runtime by updateRemoteUserUID on
  non-1000 Linux hosts, leaving them unwritable until now.
- F4: Cursor installer downloaded to a temp file with curl --retry +
  --max-time; sha256 logged to build output before execution so drift
  across rebuilds is visible in CI logs. Full hard-pin (to a versioned
  downloads.cursor.com tarball with verified sha256) tracked as a
  follow-up in README "What's not included".

P2
- F6: Anthropic Feature replaced with a direct
  `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` so
  CLAUDE_CODE_VERSION actually pins the installed binary (the Feature
  ignored the ARG and pulled latest at install time). Honors the
  earlier "pin known-good versions" decision and resolves F12's
  floating-tag concern for this Feature.
- F7: Dockerfile ARG defaults dropped for the three version vars;
  `devcontainer.json` `build.args` is now the single source of truth.
  Standalone `docker build .devcontainer/` must pass --build-arg.
- F8: ensure-host-config-dirs.cjs deleted; `initializeCommand` now uses
  POSIX `mkdir -p` + `touch ~/.gitconfig` directly, dropping the
  host-Node-on-PATH prerequisite that broke on fresh Windows+Docker
  Desktop installs without Node.
- F9: ~/.gitconfig bind-mounted read-only so `git commit` inside the
  container uses the host's user.name / user.email. Read-only so
  container-side `git config --global` doesn't leak to host.
- F10: ~/.config/gh bind-mounted (read-write) so `gh pr create` /
  `gh pr checks` / `gh issue create` work inside the container without
  re-auth. AGENTS.md's commit + PR workflow now fully functional for
  agents inside the container.
- F11: CLAUDE_CONFIG_DIR removed from Dockerfile ENV; canonical value
  lives only in devcontainer.json containerEnv. Eliminates the two-file
  edit risk.
- F13: Mounts comment now documents per-instance vs per-workspace-name
  scoping rationale so future contributors don't guess.
- F14: README "Trust boundary, concretely" paragraph names the exfil
  path explicitly (malicious npm postinstall → OAuth tokens →
  ~/.claude/projects/<workspace>/memory/MEMORY.md secrets) and lists
  vendor-side rotation runbook entries.

P3
- F15: Dockerfile pre-create + chown of /home/node/.claude, .codex,
  .cursor dropped — those paths are bind-mounted, which fully shadows
  any image-side ownership. Only .npm, .local, /commandhistory still
  benefit from the pre-create.
- F16: README "Bumping CLI versions" section rewritten against the
  post-F6 reality: CLAUDE_CODE_VERSION and CODEX_VERSION are real
  pins; CURSOR_VERSION is informational only.

Verified locally: `docker build .devcontainer/ --build-arg ...` succeeds.
Smoke-tested image: `claude --version` (2.1.153), `codex --version`
(0.134.0), `cursor-agent --version` all resolve as the non-root `node`
user; named-volume mount points (/home/node/.npm, /commandhistory) are
node-owned at build time so non-1000 host UIDs get the post-create.sh
chown fix instead of EACCES.
2026-05-28 13:38:50 +01:00
Gergo Magyar
35e61a9e66 feat(devcontainer): bind-mount host CLI config dirs for plugin/skill/memory sync
Switch the credential/config mounts from per-devcontainer named volumes
to bind mounts of `${localEnv:HOME}/.claude`, `~/.codex`, and
`~/.cursor`. Effect inside the container:

- Authentication is shared with the host. If you've already run
  `claude login` / `codex login --device-auth` / `cursor-agent login`
  on the host, you're already authenticated in the container.
- Plugins, skills, agents, memory, and settings sync both ways. Install
  a plugin in the container, it shows up on the host; add a custom
  agent on the host, the container sees it immediately.
- All devcontainers on the host share the same CLI state, mirroring
  how host shells already share it. (Per-workspace isolation of plugins
  was never a stated requirement; the previous per-devcontainer named
  volumes leaked nothing useful.)

Add `.devcontainer/ensure-host-config-dirs.cjs` and wire it as
`initializeCommand`. It runs on the host before container create and
guarantees `~/.claude`, `~/.codex`, `~/.cursor` exist, so Docker doesn't
reject the bind mount when a CLI has never been used on this host.
Cross-platform via Node `os.homedir()` + `fs.mkdirSync({recursive: true})`;
idempotent; no third-party deps.

Update `.devcontainer/README.md`:
- New "How CLI state is shared with your host" section explaining the
  bind-mount model up front so users know their host plugins/skills/
  memory carry into the container.
- Mark first-time-login section as skippable when the user is already
  authenticated on the host.
- Note the high-trust escape hatch: replace the three bind mounts with
  `type=volume` named volumes if the host/container trust boundary
  needs to be separated (Anthropic's reference pattern for enterprise).
- Replace the obsolete "rm named volume" troubleshooting row with one
  that covers EACCES/EPERM on the host-bind-mount path.
2026-05-28 12:27:40 +01:00
Gergo Magyar
a75fc2e060 fix(devcontainer): clear stale .husky/_ before npm install
When `npm install` runs the root `prepare` script (husky), husky tries
to copyfile `node_modules/husky/husky` → `.husky/_/h`. On Docker Desktop
Windows bind mounts, if `.husky/_/` already exists from a prior
container run, the new container's `node` user can't overwrite it via
the bind mount's permission translation and the install fails with:

  Error: EPERM: operation not permitted, copyfile
    '/workspace/node_modules/husky/husky' -> '.husky/_/h'

Drop `.husky/_` defensively in `postCreateCommand` before `npm install`
so husky always starts from a clean slate. `.husky/_` is a husky
runtime cache (gitignored), so removing it has no effect on the repo —
husky regenerates it. No-op for WSL2-side checkouts (where this class
of bind-mount permission collision doesn't occur).

Add a troubleshooting row to `.devcontainer/README.md` covering the
manual recovery (`rm -rf .husky/_` on the host) and the long-term fix
(clone in WSL2 — Windows-side bind mounts will keep biting on this
kind of issue across rebuilds with different UID alignment).
2026-05-28 12:19:37 +01:00
Gergo Magyar
326cb06fe7 fix(devcontainer): install gitnexus-web before gitnexus in postCreateCommand
The previous order (root → gitnexus-shared → gitnexus → gitnexus-web)
broke at the `gitnexus` install step because `gitnexus`'s `prepare`
script runs `scripts/build.js`, which compiles `gitnexus-web` whenever
its source tree exists. In the devcontainer the entire workspace is
bind-mounted, so `gitnexus-web/` is present from the start — but its
`node_modules/` wasn't yet, so `tsc -b` failed with:

  error TS2688: Cannot find type definition file for 'vite/client'
  error TS2688: Cannot find type definition file for 'node'

Reorder so `gitnexus-web` installs before `gitnexus`. Verified
end-to-end via `npx @devcontainers/cli up`: container builds clean,
all three CLIs (Claude 2.1.153, Codex 0.134.0, Cursor) respond, and
`npx tsc --noEmit` inside `/workspace/gitnexus` passes.

Production Dockerfiles (`Dockerfile.cli` etc.) don't hit this because
they only COPY `gitnexus/` + `gitnexus-shared/`, so `gitnexus-web/`
doesn't exist at install time and `scripts/build.js` skips the web
step. The devcontainer's full-tree bind mount changes that calculus.
2026-05-28 11:58:12 +01:00
Gergo Magyar
05e9c58fb1 fix(devcontainer): make interactive login the default auth path for all CLIs
The previous `containerEnv` injected `CURSOR_API_KEY: "${localEnv:CURSOR_API_KEY}"`.
When the host had no `CURSOR_API_KEY` set, this resolved to an empty
string and Docker injected `CURSOR_API_KEY=""` into the container.
Cursor CLI treats a set-but-empty `CURSOR_API_KEY` as "use this key"
rather than "fall back to stored login", which silently broke
`cursor-agent login` on the most common path — users who hadn't
explicitly opted into API key auth.

Drop `CURSOR_API_KEY` from `containerEnv`. Login is now the
unconditional default for all three CLIs (Claude Code, Codex CLI,
Cursor CLI); the named-volume + Dockerfile-chown pattern keeps
credentials persistent across container rebuilds for every login path.

Reorganize the README's auth section to put login first for all three
CLIs uniformly (matching the new behavior) and move API key
authentication into a separate "Alternative" section for CI/headless
use. Document that API keys are intentionally not auto-propagated from
the host and explain the export-in-shell or VS Code dotfiles-repo paths
for users who want them. Update the troubleshooting row to reflect the
new design.
2026-05-28 11:14:02 +01:00
Gergo Magyar
9c07ef9cf9 feat(devcontainer): add cross-platform devcontainer for Claude Code, Codex, and Cursor CLIs
Add a Dev Container that pre-installs Claude Code (2.1.153, via Anthropic's
official Feature), OpenAI Codex CLI (pinned 0.134.0), and Cursor CLI alongside
the GitNexus native build chain. Opens via VS Code's Dev Containers extension
on Windows 11 (Docker Desktop + WSL2), macOS, or Linux without OS-specific
branches in devcontainer.json.

Topology and base
- Base image `mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm`
  (multi-arch, monthly patched, ships the `node` non-root user, zsh, `gh`).
- Node 22 LTS satisfies `gitnexus/`'s engines `>=22.0.0` and matches the
  `node:22-bookworm-slim` SHA-pinned base used by `Dockerfile.cli`.
- Single container with all three CLIs co-installed (vs. docker-compose
  per-tool) — prevailing 2026 community pattern, lowest daily-driver friction.

Persistence and auth
- Per-devcontainer named volumes scoped by `${devcontainerId}` for
  `/home/node/.claude`, `/home/node/.codex`, `/home/node/.cursor`,
  `/commandhistory`, and `/home/node/.npm`. Authentication survives rebuilds
  without leaking between workspaces.
- Four sub-workspace `node_modules` volumes (root, gitnexus, gitnexus-web,
  gitnexus-shared) keep tree-sitter native bindings and onnxruntime off the
  bind mount — the actual Win/Mac perf win.
- Credential mount paths are pre-created in the Dockerfile with
  `chown node:node` BEFORE `USER node`, so empty named volumes inherit
  correct ownership on first mount and first-run logins don't EACCES.
- `CURSOR_API_KEY` is injected via `containerEnv: ${localEnv:CURSOR_API_KEY}`
  (Cursor's documented headless path); falls back to interactive
  `cursor-agent login` when the host env var is unset.

Build-arg promotion
- Build args (`CLAUDE_CODE_VERSION`, `CODEX_VERSION`, `CURSOR_VERSION`, `TZ`)
  are promoted to ENV in the Dockerfile so lifecycle commands and shells can
  resolve them. Without this promotion, Docker ARG values are build-only and
  silently no-op at lifecycle time.

Workspace setup
- `postCreateCommand` chowns the four workspace `node_modules` volumes
  (Docker creates them root-owned), then installs in dependency order:
  root → gitnexus-shared (install + build) → gitnexus → gitnexus-web. The
  shared package must build before its consumers (`file:../gitnexus-shared`).

Ports
- 5173 (Vite dev) and 4173 (Vite preview) auto-forwarded.
- 4747 (`gitnexus serve`) marked `requireLocalPort: true` because
  `gitnexus-web/src/services/backend-client.ts` hardcodes
  `http://localhost:4747` as the default backend URL; a remapped port would
  silently break the web UI.

VS Code integration
- Recommended extensions: `anthropic.claude-code`,
  `dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`, `eamodio.gitlens`.
- Settings: format-on-save with Prettier, ESLint auto-fix on save, zsh as
  default terminal profile, persistent zsh history via `HISTFILE` →
  `/commandhistory`.

Documentation
- `.devcontainer/README.md` covers WSL2 setup (clone inside WSL2 for IO and
  file-watcher reliability), first-time auth flows for each CLI, port-
  forwarding notes, LadybugDB container limitations, and the bumping
  procedure for each CLI version.
- `CONTRIBUTING.md` gets a "Containerized development (optional)"
  subsection pointing at the devcontainer README.

Deferred to a follow-up PR
- Opt-in egress firewall (originally planned as a fourth implementation
  unit). The Dev Containers spec makes `runArgs` static — toggling
  `NET_ADMIN`/`NET_RAW` capabilities cleanly requires either a separate
  `devcontainer-firewall.json` profile or an `initializeCommand`-generated
  overlay. Keeping this PR focused on the working baseline.
- Codespaces-specific tuning (works incidentally when the firewall is off,
  not actively tested).
- Inside-container Playwright e2e (needs Chromium libs not in the base
  image).

Verification deferred to user
- This change introduces a new dev tooling artifact. Validate by running
  `docker build .devcontainer/`, opening the repo in VS Code via
  "Dev Containers: Reopen in Container", confirming `claude --version`,
  `codex --version`, `cursor-agent --version` resolve inside the container,
  and `cd gitnexus && npm run test:unit` runs clean against the
  named-volume `node_modules`.
2026-05-28 09:13:56 +01:00