From abbcacebcaf62fe796e5351d52a72ebe11a61f8c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 15:31:02 +0100 Subject: [PATCH] =?UTF-8?q?refactor(devcontainer):=20hybrid=20AI=20CLI=20c?= =?UTF-8?q?onfig=20=E2=80=94=20read-only=20host=20share=20+=20per-containe?= =?UTF-8?q?r=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .devcontainer/README.md | 65 ++++++++++--- .devcontainer/devcontainer.json | 54 +++++++---- .devcontainer/ensure-host-config-dirs.cjs | 12 +++ .devcontainer/post-create.sh | 108 ++++++++++++++++++++-- 4 files changed, 201 insertions(+), 38 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 6978f2932..3e672923f 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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//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//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: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 93f93552d..9f8b5a76a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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/.. `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" }, diff --git a/.devcontainer/ensure-host-config-dirs.cjs b/.devcontainer/ensure-host-config-dirs.cjs index 27a5c238c..916aad7b1 100644 --- a/.devcontainer/ensure-host-config-dirs.cjs +++ b/.devcontainer/ensure-host-config-dirs.cjs @@ -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")); +} + diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index d8e5a2429..1f25d2716 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -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/.. The container's actual config dirs are per-devcontainer +# named volumes at /home/node/.. 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