Merge pull request 'fix: disinto init: bootstrap shared CLAUDE_CONFIG_DIR for OAuth lock coherence (#641)' (#654) from fix/issue-641 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
dev-bot 2026-04-10 20:25:12 +00:00
commit b593635d64
4 changed files with 223 additions and 2 deletions

View file

@ -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."
}

103
lib/claude-config.sh Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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