From f4749cf1e4eafaa93eade528dcb0c7a562826780 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 28 May 2026 15:51:38 +0100 Subject: [PATCH] fix(devcontainer): resync AI CLI state from host on every container-create Two bugs were causing Claude Code to fire the onboarding wizard inside the container even with valid host credentials: 1. Missing the second state file. Claude Code 2.1.x writes a small `.claude.json` INSIDE `CLAUDE_CONFIG_DIR` (carrying migration tracking + userID), not just the one at `$HOME/.claude.json`. If the userIDs in the two files disagree, Claude treats the session as inconsistent and re-onboards. The previous post-create.sh only copied the `$HOME` one. 2. First-run guards (`[ ! -e $dst ]`) skipped the copy when stale named volumes from earlier rebuilds still had the prior session's state in them, leaving the container desynced from the host. Replace `copy_on_first_run` with `sync_from_host` that always overwrites from host on container-create. `link_readonly_share` now clears stale non-symlink dst entries before linking. Copies both `$HOME/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude.json` so userIDs stay aligned. Container can still mutate its own state between rebuilds; resync only happens on rebuild (postCreate boundary). --- .devcontainer/post-create.sh | 65 +++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 1f25d2716..5b0440b13 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -60,17 +60,33 @@ link_readonly_share() { local dst_root=$2 shift 2 for name in "$@"; do - if [ -e "$src_root/$name" ] && [ ! -L "$dst_root/$name" ] && [ ! -e "$dst_root/$name" ]; then + # If dst exists as a non-symlink (stale from a prior run), remove + # it first so the symlink can land. Skips if dst is already the + # right symlink. + if [ -e "$src_root/$name" ]; then + if [ -L "$dst_root/$name" ]; then + continue # already a symlink — leave alone + fi + rm -rf "$dst_root/$name" ln -s "$src_root/$name" "$dst_root/$name" fi done } -copy_on_first_run() { +sync_from_host() { + # ALWAYS overwrite from host on container-create, so a fresh container + # starts in the same auth/trust state as the host. Guarding on + # "[ ! -e $dst ]" was a bug: stale named volumes left over from earlier + # builds (or from a previous container instance that wrote interim + # state) made the guard return false and skip the copy, leaving the + # container desynced from the host. Container can still mutate the + # files after this point — divergence is reset only on container + # rebuild (which is when `post-create.sh` runs). local src=$1 local dst=$2 local mode=${3:-600} - if [ -f "$src" ] && [ ! -e "$dst" ]; then + if [ -f "$src" ]; then + rm -f "$dst" cp "$src" "$dst" chmod "$mode" "$dst" fi @@ -81,40 +97,41 @@ copy_on_first_run() { 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. +# `~/.claude.json` at $HOME is one state file. There's a SECOND `.claude.json` +# inside CLAUDE_CONFIG_DIR (`/home/node/.claude/.claude.json`) carrying +# migration tracking, userID, and per-project trust state. If the userIDs +# between the two don't match (e.g., the in-config one is left over from +# a prior test session), Claude Code re-onboards. Sync BOTH from host. +sync_from_host /host/.claude.json /home/node/.claude.json 644 +sync_from_host /host/.claude/.claude.json /home/node/.claude/.claude.json 644 + +# If neither host file existed, write a minimal stub at $HOME so the +# wizard is still bypassed for first-time hosts. 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 + echo '{"hasCompletedOnboarding":true,"installMethod":"global"}' \ + > /home/node/.claude.json chmod 644 /home/node/.claude.json fi -# Copy credentials on first run. Container manages refresh from here on. -copy_on_first_run \ +# Credentials. Container managed its own refresh until next rebuild. +sync_from_host \ /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. +# Codex — share config.toml; copy auth.json on container create. 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 \ +sync_from_host \ /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 +# subdirs. Copy on container create. 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 \ +sync_from_host \ /host/.cursor/cli-config.json /home/node/.cursor/cli-config.json echo "[post-create] 3/7: clear stale .husky/_ runtime cache"