mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-05 02:43:32 +00:00
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:
parent
6a569e401e
commit
abbcacebca
4 changed files with 201 additions and 38 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue