From 59e71a285b1e2bd0785fb5b00a3c5dbac58ef723 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 20:15:35 +0000 Subject: [PATCH] fix: disinto init: bootstrap shared CLAUDE_CONFIG_DIR for OAuth lock coherence (#641) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 22 ++++++++- lib/claude-config.sh | 103 +++++++++++++++++++++++++++++++++++++++++++ lib/env.sh | 7 +++ tests/smoke-init.sh | 93 +++++++++++++++++++++++++++++++++++++- 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 lib/claude-config.sh diff --git a/bin/disinto b/bin/disinto index ffc9a26..ed66e4c 100755 --- a/bin/disinto +++ b/bin/disinto @@ -32,6 +32,7 @@ source "${FACTORY_ROOT}/lib/generators.sh" source "${FACTORY_ROOT}/lib/forge-push.sh" source "${FACTORY_ROOT}/lib/ci-setup.sh" source "${FACTORY_ROOT}/lib/release.sh" +source "${FACTORY_ROOT}/lib/claude-config.sh" # ── Helpers ────────────────────────────────────────────────────────────────── @@ -948,6 +949,18 @@ p.write_text(text) fi fi + # ── Claude shared config directory (#641) ─────────────────────────── + # Create CLAUDE_CONFIG_DIR for cross-container OAuth lock coherence. + # proper-lockfile uses atomic mkdir(${CLAUDE_CONFIG_DIR}.lock), so all + # containers sharing this path get native cross-container locking. + if ! setup_claude_config_dir "$auto_yes"; then + exit 1 + fi + + # Write CLAUDE_SHARED_DIR and CLAUDE_CONFIG_DIR to .env (idempotent) + _env_set_idempotent "CLAUDE_SHARED_DIR" "$CLAUDE_SHARED_DIR" "$env_file" + _env_set_idempotent "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR" "$env_file" + # Activate default agents (zero-cost when idle — they only invoke Claude # when there is actual work, so an empty project burns no LLM tokens) mkdir -p "${FACTORY_ROOT}/state" @@ -976,10 +989,17 @@ p.write_text(text) echo "── Claude authentication ──────────────────────────────" echo " OAuth (shared across containers):" echo " Run 'claude auth login' on the host once." - echo " Credentials in ~/.claude are mounted into containers." + echo " Credentials in ${CLAUDE_CONFIG_DIR} are shared across containers." echo " API key (alternative — metered billing, no rotation issues):" echo " Set ANTHROPIC_API_KEY in .env to skip OAuth entirely." echo "" + echo "── Claude config directory ────────────────────────────" + echo " CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" + echo " Add this to your shell rc (~/.bashrc or ~/.zshrc):" + echo " export CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" + echo " This ensures interactive Claude Code sessions on this host" + echo " share the same OAuth lock and token store as the factory." + echo "" echo " Run 'disinto status' to verify." } diff --git a/lib/claude-config.sh b/lib/claude-config.sh new file mode 100644 index 0000000..e6c87c3 --- /dev/null +++ b/lib/claude-config.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# lib/claude-config.sh — Shared Claude config directory helpers (#641) +# +# Provides setup_claude_config_dir() for creating/migrating CLAUDE_CONFIG_DIR +# and _env_set_idempotent() for writing env vars to .env files. +# +# Requires: CLAUDE_CONFIG_DIR, CLAUDE_SHARED_DIR (set by lib/env.sh) + +# Idempotent .env writer. +# Usage: _env_set_idempotent KEY VALUE FILE +_env_set_idempotent() { + local key="$1" value="$2" file="$3" + if grep -q "^${key}=" "$file" 2>/dev/null; then + local existing + existing=$(grep "^${key}=" "$file" | head -1 | cut -d= -f2-) + if [ "$existing" != "$value" ]; then + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + fi + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +# Create the shared CLAUDE_CONFIG_DIR, optionally migrating ~/.claude. +# Usage: setup_claude_config_dir [auto_yes] +setup_claude_config_dir() { + local auto_yes="${1:-false}" + local home_claude="${HOME}/.claude" + + # Create the shared config directory (idempotent) + install -d -m 0700 -o "$USER" "$CLAUDE_CONFIG_DIR" + echo "Claude: ${CLAUDE_CONFIG_DIR} (ready)" + + # If ~/.claude is already a symlink to CLAUDE_CONFIG_DIR, nothing to do + if [ -L "$home_claude" ]; then + local link_target + link_target=$(readlink -f "$home_claude") + local config_real + config_real=$(readlink -f "$CLAUDE_CONFIG_DIR") + if [ "$link_target" = "$config_real" ]; then + echo "Claude: ${home_claude} -> ${CLAUDE_CONFIG_DIR} (symlink OK)" + return 0 + fi + fi + + local home_exists=false home_nonempty=false + local config_nonempty=false + + # Check ~/.claude (skip if it's a symlink — already handled above) + if [ -d "$home_claude" ] && [ ! -L "$home_claude" ]; then + home_exists=true + if [ -n "$(ls -A "$home_claude" 2>/dev/null)" ]; then + home_nonempty=true + fi + fi + + # Check CLAUDE_CONFIG_DIR contents + if [ -n "$(ls -A "$CLAUDE_CONFIG_DIR" 2>/dev/null)" ]; then + config_nonempty=true + fi + + # Case: both non-empty — abort, operator must reconcile + if [ "$home_nonempty" = true ] && [ "$config_nonempty" = true ]; then + echo "ERROR: both ${home_claude} and ${CLAUDE_CONFIG_DIR} exist and are non-empty" >&2 + echo " Reconcile manually: merge or remove one, then re-run disinto init" >&2 + return 1 + fi + + # Case: ~/.claude exists and CLAUDE_CONFIG_DIR is empty — offer migration + if [ "$home_nonempty" = true ] && [ "$config_nonempty" = false ]; then + local do_migrate=false + if [ "$auto_yes" = true ]; then + do_migrate=true + elif [ -t 0 ]; then + read -rp "Migrate ${home_claude} to ${CLAUDE_CONFIG_DIR}? [Y/n] " confirm + if [[ ! "$confirm" =~ ^[Nn] ]]; then + do_migrate=true + fi + else + echo "Warning: ${home_claude} exists but cannot prompt for migration (no TTY)" >&2 + echo " Re-run with --yes to auto-migrate, or move files manually" >&2 + return 0 + fi + + if [ "$do_migrate" = true ]; then + # Move contents (not the dir itself) to preserve CLAUDE_CONFIG_DIR ownership + cp -a "$home_claude/." "$CLAUDE_CONFIG_DIR/" + rm -rf "$home_claude" + ln -sfn "$CLAUDE_CONFIG_DIR" "$home_claude" + echo "Claude: migrated ${home_claude} -> ${CLAUDE_CONFIG_DIR}" + return 0 + fi + fi + + # Case: ~/.claude exists but is empty, or doesn't exist — create symlink + if [ "$home_exists" = true ] && [ "$home_nonempty" = false ]; then + rmdir "$home_claude" 2>/dev/null || true + fi + if [ ! -e "$home_claude" ]; then + ln -sfn "$CLAUDE_CONFIG_DIR" "$home_claude" + echo "Claude: ${home_claude} -> ${CLAUDE_CONFIG_DIR} (symlink created)" + fi +} diff --git a/lib/env.sh b/lib/env.sh index 0c1d73e..503aebb 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -132,6 +132,13 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}" unset GITHUB_TOKEN 2>/dev/null || true unset CLAWHUB_TOKEN 2>/dev/null || true +# Shared Claude config directory for cross-container OAuth lock coherence (#641). +# All containers and the host resolve to the same CLAUDE_CONFIG_DIR on a shared +# bind-mounted filesystem, so proper-lockfile's atomic mkdir works across them. +: "${CLAUDE_SHARED_DIR:=/var/lib/disinto/claude-shared}" +: "${CLAUDE_CONFIG_DIR:=${CLAUDE_SHARED_DIR}/config}" +export CLAUDE_SHARED_DIR CLAUDE_CONFIG_DIR + # Disable Claude Code auto-updater, telemetry, error reporting in factory sessions. # Factory processes must never phone home or auto-update mid-session (#725). export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index c324821..8600228 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -28,7 +28,8 @@ cleanup() { # Kill any leftover mock-forgejo.py processes by name pkill -f "mock-forgejo.py" 2>/dev/null || true rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \ - "${FACTORY_ROOT}/projects/smoke-repo.toml" + "${FACTORY_ROOT}/projects/smoke-repo.toml" \ + /tmp/smoke-claude-shared /tmp/smoke-home-claude # Restore .env only if we created the backup if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env" @@ -286,6 +287,96 @@ else fi fi +# ── 7. Verify CLAUDE_CONFIG_DIR setup ───────────────────────────────────── +echo "=== 7/7 Verifying CLAUDE_CONFIG_DIR setup ===" + +# .env should contain CLAUDE_SHARED_DIR and CLAUDE_CONFIG_DIR +if grep -q '^CLAUDE_SHARED_DIR=' "$env_file"; then + pass ".env contains CLAUDE_SHARED_DIR" +else + fail ".env missing CLAUDE_SHARED_DIR" +fi +if grep -q '^CLAUDE_CONFIG_DIR=' "$env_file"; then + pass ".env contains CLAUDE_CONFIG_DIR" +else + fail ".env missing CLAUDE_CONFIG_DIR" +fi + +# Test migration path with a temporary HOME +echo "--- Testing claude config migration ---" +ORIG_HOME="$HOME" +ORIG_CLAUDE_SHARED_DIR="${CLAUDE_SHARED_DIR:-}" +ORIG_CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-}" + +export HOME="/tmp/smoke-home-claude" +export CLAUDE_SHARED_DIR="/tmp/smoke-claude-shared" +export CLAUDE_CONFIG_DIR="${CLAUDE_SHARED_DIR}/config" +mkdir -p "$HOME" + +# Source claude-config.sh for setup_claude_config_dir +source "${FACTORY_ROOT}/lib/claude-config.sh" + +# Sub-test 1: fresh install (no ~/.claude, no config dir) +rm -rf "$HOME/.claude" "$CLAUDE_SHARED_DIR" +setup_claude_config_dir "true" +if [ -d "$CLAUDE_CONFIG_DIR" ]; then + pass "Fresh install: CLAUDE_CONFIG_DIR created" +else + fail "Fresh install: CLAUDE_CONFIG_DIR not created" +fi +if [ -L "$HOME/.claude" ]; then + pass "Fresh install: ~/.claude symlink created" +else + fail "Fresh install: ~/.claude symlink not created" +fi + +# Sub-test 2: migration (pre-existing ~/.claude with content) +rm -rf "$HOME/.claude" "$CLAUDE_SHARED_DIR" +mkdir -p "$HOME/.claude" +echo "test-token" > "$HOME/.claude/credentials.json" +setup_claude_config_dir "true" +if [ -f "$CLAUDE_CONFIG_DIR/credentials.json" ]; then + pass "Migration: credentials.json moved to CLAUDE_CONFIG_DIR" +else + fail "Migration: credentials.json not found in CLAUDE_CONFIG_DIR" +fi +if [ -L "$HOME/.claude" ]; then + link_target=$(readlink -f "$HOME/.claude") + config_real=$(readlink -f "$CLAUDE_CONFIG_DIR") + if [ "$link_target" = "$config_real" ]; then + pass "Migration: ~/.claude is symlink to CLAUDE_CONFIG_DIR" + else + fail "Migration: ~/.claude symlink points to wrong target" + fi +else + fail "Migration: ~/.claude is not a symlink" +fi + +# Sub-test 3: idempotency (re-run after migration) +setup_claude_config_dir "true" +if [ -L "$HOME/.claude" ] && [ -f "$CLAUDE_CONFIG_DIR/credentials.json" ]; then + pass "Idempotency: re-run is a no-op" +else + fail "Idempotency: re-run broke the layout" +fi + +# Sub-test 4: both non-empty — must abort +rm -rf "$HOME/.claude" "$CLAUDE_SHARED_DIR" +mkdir -p "$HOME/.claude" "$CLAUDE_CONFIG_DIR" +echo "home-data" > "$HOME/.claude/home.txt" +echo "config-data" > "$CLAUDE_CONFIG_DIR/config.txt" +if setup_claude_config_dir "true" 2>/dev/null; then + fail "Both non-empty: should have aborted but didn't" +else + pass "Both non-empty: correctly aborted" +fi + +# Restore +export HOME="$ORIG_HOME" +export CLAUDE_SHARED_DIR="$ORIG_CLAUDE_SHARED_DIR" +export CLAUDE_CONFIG_DIR="$ORIG_CLAUDE_CONFIG_DIR" +rm -rf /tmp/smoke-claude-shared /tmp/smoke-home-claude + # ── Summary ────────────────────────────────────────────────────────────────── echo "" if [ "$FAILED" -ne 0 ]; then