From 99407e4341ad8f23e1fa1080ddbe154f7f6fea07 Mon Sep 17 00:00:00 2001 From: Cris Crews Date: Tue, 25 Aug 2026 11:51:51 -0700 Subject: [PATCH] Mount .claude.json so container logins persist; allow isolating state Claude Code keeps state in two places and both are required for a session to survive a restart: ~/.claude/ credentials, history, settings, plugin cache and registry ~/.claude.json FILE at HOME root: account linkage, onboarding, project state The launchers mounted only the directory. So .credentials.json persisted fine, but .claude.json was absent on every start, Claude Code saw no linked account, and prompted for login each launch -- with a perfectly good credential sitting in the mounted directory unused. Mounting the pair fixes that. Both mounts now come from one shared function in localdev-mounts.sh rather than a copy in each launcher, so the three cannot drift apart. Also adds LOCALDEV_CLAUDE_STATE to choose which host directory supplies that pair, defaulting to $HOME (existing shared behaviour). Sharing has a real cost that mounting .claude.json increases: host and container run different Claude Code builds against one mutable registry and reset each other's state. A container session was observed emptying the host's installed_plugins.json, disabling the org's managed plugins on the host. Pointing LOCALDEV_CLAUDE_STATE at a container-only directory gives the container its own credential and plugin state and removes that failure mode, at the cost of one extra login. A nonexistent .claude.json is seeded with '{}' before mounting: podman creates a directory for a missing bind-mount source, which would hand Claude Code a directory where it expects a JSON file. Verified across all three launchers: shared mode mounts the dir and the json from $HOME; isolated mode mounts both from LOCALDEV_CLAUDE_STATE and nothing from $HOME, seeding the directory and file on first use. Documents both in README: that Claude Code state spans a directory and a file, and the LOCALDEV_CLAUDE_STATE choice with the tradeoff behind it. Without that, the variable is discoverable only by reading the library source. --- README.md | 27 ++++++++++++++++++-- localdev | 22 +++-------------- localdev-mounts.sh | 61 ++++++++++++++++++++++++++++++++++++++++++++++ localdevnet | 19 +++------------ localfull | 19 +++------------ 5 files changed, 98 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 3ee1b40..46396b0 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ localdev ``` That launches the container with your current directory mounted read-write as the -workspace at `/`. Your global agent configs (`~/.claude`, -`~/.opencode`, `~/.pi`, `~/.omp`, `~/.kit`, `~/.goprojdex`), SSH keys, and forwarded SSH agent are mounted +workspace at `/`. Your global agent configs (`~/.claude` and +`~/.claude.json`, `~/.opencode`, `~/.pi`, `~/.omp`, `~/.kit`, `~/.goprojdex`), SSH keys, and forwarded SSH agent are mounted automatically. Three launchers are available: - **`localdev`** - lightweight container, isolated networking (default) @@ -22,6 +22,29 @@ automatically. Three launchers are available: Run `localdev -h` for full usage. +### Claude Code state: shared or isolated + +Claude Code keeps its state in **two** places, and both are mounted, because both are needed for a login to survive a container restart: + +| Path | Holds | +|------|-------| +| `~/.claude/` | credentials, history, settings, plugin cache and registry | +| `~/.claude.json` | account linkage, onboarding state, per-project state | + +The second is a *file* at the root of your home directory, not inside the first. Mounting only the directory leaves `.claude.json` absent on every start, so Claude Code sees no linked account and prompts for login each launch — even though the credential is sitting in the mounted directory unused. + +By default both come from your own `$HOME`, so the container shares state with the host. That is convenient: your plugins and your login are immediately available inside. + +It has a cost, though. Host and container run different Claude Code builds against one mutable registry, and they can reset each other's state — a container session emptying the host's `installed_plugins.json`, disabling its plugins, has been observed. If that matters to you, point `LOCALDEV_CLAUDE_STATE` at a directory used only by containers: + +```bash +export LOCALDEV_CLAUDE_STATE="$HOME/.localdev-claude" +``` + +The launcher then mounts `$LOCALDEV_CLAUDE_STATE/.claude` and `$LOCALDEV_CLAUDE_STATE/.claude.json`, creating them on first use. The container gets its own credential and plugin state and cannot damage the host's. The cost is one extra login, and that plugins must be provisioned separately in that directory — for private plugin marketplaces the container may not be able to reach them at all, in which case copy the relevant `plugins/marketplaces/` and `plugins/cache/` subtrees across from your host. + +The startup banner says which mode is in effect — `(shared with host)` or `(isolated from host)`. + ### Mounting extra folders Often you need folders from outside the workspace inside the container - reference diff --git a/localdev b/localdev index 1f00c5c..f1d335f 100755 --- a/localdev +++ b/localdev @@ -46,24 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - # Plugins (~/.claude/plugins) are intentionally shared via this bind, NOT isolated in a - # named volume. The entrypoint symlinks HOST_HOME -> /home/developer so the host's absolute - # plugin install paths resolve inside the container. Do not re-add a separate plugins volume. - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true diff --git a/localdev-mounts.sh b/localdev-mounts.sh index e9028e3..29d42a8 100644 --- a/localdev-mounts.sh +++ b/localdev-mounts.sh @@ -563,3 +563,64 @@ collect_mounts() { _process_env_mounts _process_file_mounts "/${dirname}" } + +# configure_claude_state mounts the host's Claude Code state into the container. +# +# Claude Code keeps its state in two places, and BOTH must be mounted or login does not +# survive a container restart: +# +# ~/.claude/ directory: credentials (.credentials.json), history, settings, +# plugin cache and registry +# ~/.claude.json FILE at HOME root: account linkage, onboarding and project state +# +# Mounting only the directory -- which is what this used to do -- leaves .claude.json +# absent on every start, so Claude Code sees no linked account and prompts for login even +# though .credentials.json is sitting in the mounted directory unused. +# +# LOCALDEV_CLAUDE_STATE selects which host directory supplies that pair (default $HOME): +# +# shared (default) The host's own state. Convenient -- host plugins and login are +# immediately available -- but host and container run different +# Claude Code builds against one mutable registry and can reset each +# other's state. A container session has been observed emptying the +# host's installed_plugins.json. +# +# isolated Point LOCALDEV_CLAUDE_STATE at a directory used only by containers +# (e.g. ~/.localdev-claude). The container gets its own credential and +# plugin state, and cannot damage the host's. Costs one extra login. +# +# Sets: CLAUDE_MOUNT. Appends to: MOUNTS_CONTENT +configure_claude_state() { + local state="${LOCALDEV_CLAUDE_STATE:-$HOME}" + local dir="$state/.claude" + local json="$state/.claude.json" + CLAUDE_MOUNT=() + + mkdir -p "$dir" 2>/dev/null || true + if [[ ! -d "$dir" || ! -r "$dir" ]]; then + echo "Warning: $dir is missing or unreadable; Claude Code state not mounted" >&2 + ((MOUNT_ERRORS++)) + return 0 + fi + + # podman creates a DIRECTORY when bind-mounting a path that does not exist, which would + # make Claude Code fail on a directory where it expects a JSON file. Seed it instead. + if [[ ! -e "$json" ]]; then + printf '{}\n' > "$json" 2>/dev/null || true + fi + + CLAUDE_MOUNT=("-v" "$dir:/home/developer/.claude:rw") + MOUNTS_CONTENT+="$dir -> /home/developer/.claude (read-write)\n" + if [[ -f "$json" && -r "$json" ]]; then + CLAUDE_MOUNT+=("-v" "$json:/home/developer/.claude.json:rw") + MOUNTS_CONTENT+="$json -> /home/developer/.claude.json (read-write)\n" + else + echo "Warning: $json unusable; login will not persist across container restarts" >&2 + fi + + if [[ "$state" == "$HOME" ]]; then + echo "Mounting (read-write): $dir + .claude.json (shared with host)" >&2 + else + echo "Mounting (read-write): $dir + .claude.json (isolated from host)" >&2 + fi +} diff --git a/localdevnet b/localdevnet index 40679c5..6c335ad 100755 --- a/localdevnet +++ b/localdevnet @@ -46,21 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true diff --git a/localfull b/localfull index 922aa7f..36d7d4d 100755 --- a/localfull +++ b/localfull @@ -46,21 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true