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
This commit is contained in:
Gergo Magyar 2026-05-28 15:31:02 +01:00
parent 6a569e401e
commit abbcacebca
4 changed files with 201 additions and 38 deletions

View file

@ -76,23 +76,63 @@ Same as macOS — open in VS Code and reopen in container. `updateRemoteUserUID:
## How CLI state is shared with your host
The following directories inside the container are **bind-mounted directly from your host's `$HOME`**:
### AI CLIs (Claude Code, Codex, Cursor): read-only host share + per-container credentials
The three AI CLIs use a **hybrid topology** so you get host plugins/skills/memory inside the container without re-installing anything, but each container manages its own credentials with proper Linux permissions:
| Mount | Source | Target | Mode |
|---|---|---|---|
| Host Claude state, read-only stage | `$HOME/.claude` | `/host/.claude` | **read-only** bind |
| Host Codex state, read-only stage | `$HOME/.codex` | `/host/.codex` | **read-only** bind |
| Host Cursor state, read-only stage | `$HOME/.cursor` | `/host/.cursor` | **read-only** bind |
| Host onboarding state | `$HOME/.claude.json` | `/host/.claude.json` | **read-only** bind |
| Container Claude config dir | _named volume_ `claude-config-${devcontainerId}` | `/home/node/.claude` (`CLAUDE_CONFIG_DIR`) | read-write |
| Container Codex config dir | _named volume_ `codex-config-${devcontainerId}` | `/home/node/.codex` (`CODEX_HOME`) | read-write |
| Container Cursor config dir | _named volume_ `cursor-config-${devcontainerId}` | `/home/node/.cursor` | read-write |
`post-create.sh` populates the named volumes on first run:
- **Symlinks shared subdirs from the read-only host stage** into the container's config volume, so installing a plugin on the host shows up in the container after a rebuild. The shared list:
- **Claude**: `plugins/`, `skills/`, `agents/`, `memory/`, `commands/` — your user-installed surface
- **Codex**: `config.toml` — your user prefs
- **Cursor**: nothing shared (cli-config.json conflates auth + settings, no shareable subdirs)
- **Copies these to the container's config volume on first run** (not symlinks — so the container can refresh / rewrite them without touching host):
- `.credentials.json` (Claude), `auth.json` (Codex), `cli-config.json` (Cursor) — credentials
- `.claude.json` — Claude Code's onboarding-state file. **This file lives at `$HOME/.claude.json`, NOT inside `~/.claude/`** — without it, Claude Code fires the onboarding wizard (theme picker → trust dialog → login) every fresh container, even when valid credentials are present. Copied with a `{"hasCompletedOnboarding":true,"installMethod":"global"}` stub fallback for hosts that haven't run Claude Code yet.
**Why read-only stage + named volume instead of a single host bind mount:**
- **No host filesystem write-through.** A compromised npm package inside the container can't drop `plugins/evil/` or `agents/evil.md` into your host config — the read-only mount blocks the write. Without this, the container is a code-execution escape vector that persists after teardown (next host Claude session would auto-load the malicious agent).
- **Proper credential perms.** Docker Desktop's Windows bind mount surfaces every host file as `root:root` mode `777`. Named-volume files inside the container come with proper Linux ownership and `chmod 600` for credentials — what Claude Code, Codex, and Cursor expect.
- **Skips host/container lock-file and ghost-project collisions.** We deliberately do NOT symlink `~/.claude/ide/` (per-process IDE lock files would collide between host and container Claude Code instances), `~/.claude/projects/` (host encodes workspace as `D--development-coding-GitNexus`, container as `-workspace` — symlinking creates two ghost project trees with split memory), or `~/.claude/settings.json` (container is pinned, host floats — bidirectional writes cause silent schema drift).
**What this means for your workflow:**
- Install a plugin on the **host** → rebuild container → it's inside the container.
- Install a plugin **inside the container** → it lives only in that container's named volume; the host is unaffected. Re-install on host if you want it there too.
- `claude logout` inside the container clears the container's named-volume credentials; the host's `.credentials.json` is untouched.
- OAuth refresh-token divergence is real: Anthropic rotates refresh tokens on every use, so if the host refreshes after the container's first-run copy, the container's snapshot eventually goes stale (silent 401). Re-run `claude login` inside the container if you hit this — usually weeks apart.
### Other host bind mounts
| Container path | Host source | Mode | Why |
|---|---|---|---|
| `~/.claude` | `$HOME/.claude` | read-write | Claude Code plugins, skills, agents, memory, settings, OAuth |
| `~/.codex` | `$HOME/.codex` | read-write | Codex CLI auth + config + profiles |
| `~/.cursor` | `$HOME/.cursor` | read-write | Cursor CLI auth + rules + cli-config |
| `~/.config/git` | `$HOME/.config/git` | **read-only** | XDG-style git config / ignore / attributes |
| `~/.ssh` | `$HOME/.ssh` | **read-only** | SSH commit signing + git push over SSH |
| `~/.config/gh` | `$HOME/.config/gh` | read-write | `gh` CLI auth (PR create, issue create, checks) |
| `~/.docker` | `$HOME/.docker` | read-write | Container registry auth (`docker push` to ghcr/dockerhub if you add docker-in-docker) + buildx config |
| `~/.docker` | `$HOME/.docker` | read-write | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) |
| `~/.aws` | `$HOME/.aws` | **read-only** | AWS CLI / SDK credentials (forward-compat — empty by default) |
| `~/.azure` | `$HOME/.azure` | **read-only** | Azure CLI credentials (forward-compat — empty by default) |
`~/.gitconfig` is **not** bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's `user.name` / `user.email` are available inside the container.
If a host source dir doesn't exist when the container is first created, the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) creates it empty — so the bind mount always has a valid source, and the cloud configs you don't use yet are ready when you do.
If a host source dir doesn't exist when the container is first created, the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`) creates it empty — so the bind mount always has a valid source.
### Per-CLI quirks worth knowing
- **Claude Code on macOS** stores credentials in the system Keychain, not in `~/.claude/.credentials.json`. The copy-on-first-run silently no-ops; run `claude login` inside the container once.
- **Codex on macOS / Linux with `cli_auth_credentials_store = "keyring"`** stores auth in the OS keyring (Keychain / Secret Service), so `~/.codex/auth.json` may not exist. Same fallback: `codex login --device-auth` inside the container.
- **Cursor CLI inside containers** has [known upstream auth issues](https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995) — even with a correctly-copied `cli-config.json`, you may need to re-run `cursor-agent login` inside the container.
### What you still don't have inside the container
@ -115,16 +155,19 @@ The bind mount source directories are guaranteed to exist by the `initializeComm
### Trust boundary, concretely
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete. Any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container with these bind mounts active, has direct read access to:
Host and container share a single trust boundary by design — fine for personal-dev, but the consequence is concrete. Any malicious npm package or `postinstall` script in the workspace dep tree, running inside the container, has direct **read** access to:
- OAuth refresh tokens for **Claude Code, Codex, Cursor** (under `~/.claude`, `~/.codex`, `~/.cursor`)
- **Host AI CLI state** at `/host/.claude`, `/host/.codex`, `/host/.cursor` (mounted read-only) — including `.credentials.json`, `auth.json`, `cli-config.json`, plugins, skills, agents, memory store
- The **container's own credential snapshots** at `/home/node/.claude/.credentials.json` etc. (copied from host on first run)
- `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill)
- Your **`gh` token** (`~/.config/gh`)
- Your **SSH private keys** (`~/.ssh/`)
- Docker registry tokens in **`~/.docker/config.json`** (registry passwords/PATs for ghcr / dockerhub if you've `docker login`-ed)
- Docker registry tokens in **`~/.docker/config.json`** (if you've `docker login`-ed)
- AWS/Azure CLI credentials if you've populated `~/.aws/` or `~/.azure/`
- `~/.claude/projects/<workspace>/memory/MEMORY.md` (which may contain user-stored secrets if you've used the `/remember` skill)
Read-only mounts on `~/.ssh`, `~/.config/git`, `~/.aws`, and `~/.azure` prevent container code from modifying or deleting them, but they're still readable. The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would also have unrestricted network to exfiltrate.
What this design **does** prevent (vs. a full bidirectional bind mount): a malicious dep cannot **write back** to your host `~/.claude/plugins/`, `~/.claude/agents/`, or `~/.claude/skills/`. The read-only `/host` mount blocks the write. That matters because a host write-through would mean a single in-container compromise persists across container teardown — your next host Claude session would auto-load the malicious agent. Read-only mounts on `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure` give the same one-way property for those credentials.
The egress firewall is deferred (see "What's not included (yet)" below) so a compromised package would still have unrestricted network to exfiltrate what it can read.
**If a workspace dep is ever found compromised**, rotate credentials at the vendor side — local file deletion is insufficient because tokens may have already left:

View file

@ -42,30 +42,45 @@
// Mount topology, by group:
//
// 1. CLI config dirs — bind-mounted from the host so the developer's
// existing plugins, skills, agents, memory, settings, and credentials
// for Claude Code / Codex / Cursor + git identity + gh auth are
// immediately available inside the container, and changes inside the
// container flow back to the host. ~/.gitconfig is read-only so
// container-side `git config --global` doesn't leak to host config.
// For high-trust environments where host and container should NOT
// share credentials, swap these three CLI bind mounts for
// per-devcontainer named volumes (Anthropic's reference pattern).
// 1. AI CLI host config — read-only bind at /host/.<cli>. `post-create.sh`
// selectively symlinks shareable subdirs (plugins/skills/agents/memory/
// commands) into the container's named-volume config dirs, and copies
// credentials + `.claude.json` (the onboarding-state file Claude Code
// looks for at $HOME, *outside* ~/.claude/) on first run. Read-only
// eliminates write-through from container to host — a compromised npm
// dep can't drop an `agents/evil.md` into your host ~/.claude/agents/
// that the next host Claude session would auto-load. The host stays
// the source of truth for shared content; install new plugins on the
// host and rebuild the container to pick them up.
//
// 2. Per-instance state — `${devcontainerId}` scoped: history and npm
// cache survive container rebuilds but stay isolated between
// sibling devcontainer instances.
// 2. AI CLI container config — per-devcontainer named volumes. These are
// what CLAUDE_CONFIG_DIR / CODEX_HOME / ~/.cursor actually point at.
// Credentials live here with proper Linux perms. Container-managed
// state (sessions, history, caches, projects/, settings.json) stays
// isolated per devcontainer, so two GitNexus checkouts on the same
// host don't corrupt each other's chat history or IDE lock files.
//
// 3. Per-workspace-name AND per-instance state — workspace `node_modules`
// 3. Other host config — read-write or read-only bind mounts for things
// that don't have the perm-flattening / onboarding-state complexity
// Claude Code does. `~/.gitconfig` is handled separately by VS Code's
// auto-copy mechanism (not mounted).
//
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
// npm cache. Survive rebuilds, isolated 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, e.g., ~/work/GitNexus vs
// ~/projects/GitNexus). Keeps tree-sitter native binaries and
// onnxruntime off the workspace bind mount (the real Win/Mac perf win).
// sibling instances of the same repo). Keeps tree-sitter native
// binaries and onnxruntime off the workspace bind mount (Win/Mac perf).
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind",
"source=${localEnv:HOME}/.codex,target=/home/node/.codex,type=bind",
"source=${localEnv:HOME}/.cursor,target=/home/node/.cursor,type=bind",
"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",
"source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly",
"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",
"source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly",
"source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly",
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind",
@ -92,6 +107,7 @@
// VS Code dotfiles repo (see .devcontainer/README.md).
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},

View file

@ -99,3 +99,15 @@ for (const dir of [
fs.mkdirSync(path.join(home, dir), { recursive: true });
}
// Claude Code reads onboarding state (`hasCompletedOnboarding`, `userID`,
// per-project trust) from `~/.claude.json` — a FILE at $HOME, separate
// from the `~/.claude/` directory. devcontainer.json bind-mounts this
// read-only at /host/.claude.json so post-create.sh can seed the
// container's `~/.claude.json` and skip the onboarding wizard. Make sure
// the file exists on the host first (Docker rejects bind mounts with a
// missing source).
const claudeJson = path.join(home, ".claude.json");
if (!fs.existsSync(claudeJson)) {
fs.closeSync(fs.openSync(claudeJson, "a"));
}

View file

@ -8,14 +8,15 @@ set -euo pipefail
cd /workspace
echo "[post-create] 1/6: chown workspace node_modules + named-volume mount points"
echo "[post-create] 1/7: chown workspace node_modules + named-volume mount points"
# `updateRemoteUserUID: true` realigns the `node` user's UID/GID at runtime
# on Linux hosts (no-op on Mac/Windows where Docker Desktop translates UIDs
# via its VM layer). The Dockerfile chown at build time targets the original
# UID; empty named volumes created at first mount inherit that ownership and
# end up owned by the stale UID after realignment. Re-chown here, post-
# realignment, so npm install can write to ~/.npm and zsh history writes to
# /commandhistory succeed on hosts with non-1000 UIDs.
# realignment, so npm install can write to ~/.npm, the AI CLIs can write
# to their config dirs, and zsh history writes to /commandhistory succeed
# on hosts with non-1000 UIDs.
sudo chown -R node:node \
/workspace/node_modules \
/workspace/gitnexus/node_modules \
@ -23,26 +24,117 @@ sudo chown -R node:node \
/workspace/gitnexus-shared/node_modules \
/home/node/.npm \
/home/node/.local \
/home/node/.claude \
/home/node/.codex \
/home/node/.cursor \
/commandhistory
echo "[post-create] 2/6: clear stale .husky/_ runtime cache"
echo "[post-create] 2/7: stage AI CLI config (read-only host share + per-container credentials)"
# Host's ~/.claude / ~/.codex / ~/.cursor are bind-mounted READ-ONLY at
# /host/.<cli>. The container's actual config dirs are per-devcontainer
# named volumes at /home/node/.<cli>. We selectively SYMLINK shareable
# subdirs (plugins, skills, agents, memory, commands) from /host into the
# named volume so installing a plugin on the host lets the container see
# it on next rebuild. Read-only mount means container code can't write
# back — a compromised npm dep can't drop a malicious agent / skill /
# plugin into the host config that the next host Claude session would
# autoload. We deliberately do NOT symlink `ide/` (lock-file PID
# collisions across host/container Claude Code instances), `projects/`
# (host and container encode the workspace path differently — host
# `D--development-coding-GitNexus` vs container `-workspace` — and
# bidirectional writes split memory across two ghost project dirs), or
# `settings.json` (container CLI is version-pinned while host floats;
# bidirectional writes cause silent schema drift). Those stay container-
# local in the named volume.
#
# CREDENTIALS (.credentials.json / auth.json / cli-config.json) and
# `.claude.json` (the onboarding-state file at $HOME) are COPIED on
# first run, not symlinked. The container then manages its own refresh
# in the named volume; the host's copies are untouched. Refresh-token
# divergence is real (Anthropic rotates on every use), so an unattended
# container session can hit a silent 401 if the host has refreshed since
# the copy — re-run `claude login` inside the container to refresh.
link_readonly_share() {
local src_root=$1
local dst_root=$2
shift 2
for name in "$@"; do
if [ -e "$src_root/$name" ] && [ ! -L "$dst_root/$name" ] && [ ! -e "$dst_root/$name" ]; then
ln -s "$src_root/$name" "$dst_root/$name"
fi
done
}
copy_on_first_run() {
local src=$1
local dst=$2
local mode=${3:-600}
if [ -f "$src" ] && [ ! -e "$dst" ]; then
cp "$src" "$dst"
chmod "$mode" "$dst"
fi
}
# Claude Code — share plugins, skills, agents, memory, commands (the
# user-installed surface). Skip ide/projects/settings.json per above.
link_readonly_share /host/.claude /home/node/.claude \
plugins skills agents memory commands
# `~/.claude.json` (FILE at $HOME — not inside .claude/) holds
# hasCompletedOnboarding, userID, oauthAccount, per-project trust state,
# MCP user-scope config. Without it, Claude Code fires the onboarding
# wizard on every fresh container even when credentials are valid. Copy
# the host's version on first run; fall back to a minimal stub so the
# wizard is still bypassed for hosts that never installed Claude Code.
if [ ! -f /home/node/.claude.json ]; then
if [ -s /host/.claude.json ]; then
cp /host/.claude.json /home/node/.claude.json
else
echo '{"hasCompletedOnboarding":true,"installMethod":"global"}' \
> /home/node/.claude.json
fi
chmod 644 /home/node/.claude.json
fi
# Copy credentials on first run. Container manages refresh from here on.
copy_on_first_run \
/host/.claude/.credentials.json /home/node/.claude/.credentials.json
# Codex — share config.toml; copy auth.json on first run. 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.
link_readonly_share /host/.codex /home/node/.codex config.toml
copy_on_first_run \
/host/.codex/auth.json /home/node/.codex/auth.json
# Cursor CLI — cli-config.json conflates auth + settings, no shareable
# subdirs. Copy on first run. 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.
copy_on_first_run \
/host/.cursor/cli-config.json /home/node/.cursor/cli-config.json
echo "[post-create] 3/7: 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 npm install.
rm -rf .husky/_
echo "[post-create] 3/6: npm install at root (husky + lint-staged + prettier + eslint)"
echo "[post-create] 4/7: npm install at root (husky + lint-staged + prettier + eslint)"
npm install
echo "[post-create] 4/6: npm install + build gitnexus-shared"
echo "[post-create] 5/7: npm install + build gitnexus-shared"
# gitnexus and gitnexus-web both consume gitnexus-shared via
# file:../gitnexus-shared, so it must be built before either installs.
cd /workspace/gitnexus-shared
npm install
npm run build
echo "[post-create] 5/6: npm install gitnexus-web"
echo "[post-create] 6/7: npm install gitnexus-web"
# Must install 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
@ -51,7 +143,7 @@ echo "[post-create] 5/6: npm install gitnexus-web"
cd /workspace/gitnexus-web
npm install
echo "[post-create] 6/6: npm install gitnexus (triggers prepare -> scripts/build.js)"
echo "[post-create] 7/7: npm install gitnexus (triggers prepare -> scripts/build.js)"
cd /workspace/gitnexus
npm install