From 94a66e1957807ddd0ca539b29bb8992449a65ccc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:50:23 +0000 Subject: [PATCH] fix: vision(#623): Claude identity isolation for disinto-chat (#707) --- bin/disinto | 118 +++++++++++++++++++++++++++++++++++++++-- docker/chat/Dockerfile | 9 +++- lib/claude-config.sh | 67 ++++++++++++++--------- lib/generators.sh | 11 ++-- 4 files changed, 171 insertions(+), 34 deletions(-) diff --git a/bin/disinto b/bin/disinto index bbb11ec..397a240 100755 --- a/bin/disinto +++ b/bin/disinto @@ -31,6 +31,9 @@ FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" export USER="${USER:-$(id -un)}" export HOME="${HOME:-$(eval echo "~${USER}")}" +# Chat Claude identity directory — separate from factory agents (#707) +export CHAT_CLAUDE_DIR="${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}" + source "${FACTORY_ROOT}/lib/env.sh" source "${FACTORY_ROOT}/lib/ops-setup.sh" # setup_ops_repo, migrate_ops_repo source "${FACTORY_ROOT}/lib/hire-agent.sh" @@ -640,6 +643,98 @@ prompt_admin_password() { exit 1 } +# ── Chat identity prompt helper ─────────────────────────────────────────────── +# Prompts for separate Claude identity for disinto-chat (#707). +# On yes, creates ~/.claude-chat/ and runs claude login in subshell. +# Returns 0 on success, 1 on failure/skip. +# Usage: prompt_chat_identity [] +prompt_chat_identity() { + local env_file="${1:-${FACTORY_ROOT}/.env}" + local chat_claude_dir="${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}" + local config_dir="${chat_claude_dir}/config" + + # Skip if ANTHROPIC_API_KEY is set (API key mode — no OAuth needed) + if grep -q '^ANTHROPIC_API_KEY=' "$env_file" 2>/dev/null; then + local api_key + api_key=$(grep '^ANTHROPIC_API_KEY=' "$env_file" | cut -d= -f2-) + if [ -n "$api_key" ]; then + echo "Chat: ANTHROPIC_API_KEY set — skipping OAuth identity setup" + echo "Chat: Chat will use API key authentication (no ~/.claude-chat mount)" + return 0 + fi + fi + + # Check if ~/.claude-chat already exists and has credentials + if [ -d "$chat_claude_dir" ]; then + if [ -d "${config_dir}/credentials" ] && [ -n "$(ls -A "${config_dir}/credentials" 2>/dev/null)" ]; then + echo "Chat: ~/.claude-chat already configured (resuming from existing identity)" + return 0 + fi + fi + + # Non-interactive mode without explicit yes + if [ "$auto_yes" = true ]; then + echo "Chat: --yes provided — creating ~/.claude-chat identity" + # Create directory structure + install -d -m 0700 -o "$USER" "$config_dir" + # Skip claude login in non-interactive mode — user must run manually + echo "Chat: Identity directory created at ${chat_claude_dir}" + echo "Chat: Run 'CLAUDE_CONFIG_DIR=${config_dir} claude login' to authenticate" + return 0 + fi + + # Interactive mode: prompt for separate identity + if [ -t 0 ]; then + echo "" + echo "── Separate Claude identity for disinto-chat ──────────────────────" + echo "Create a separate Claude identity for disinto-chat to avoid OAuth" + echo "refresh races with factory agents sharing ~/.claude?" + echo "" + printf "Use separate identity for chat? [Y/n] " + + local response + IFS= read -r response + response="${response:-Y}" + + case "$response" in + [Nn][Oo]|[Nn]) + echo "Chat: Skipping separate identity — chat will use shared ~/.claude" + echo "Chat: Note: OAuth refresh races may occur with concurrent agents" + return 1 + ;; + *) + # User said yes — create identity and run login + echo "" + echo "Chat: Creating identity directory at ${chat_claude_dir}" + install -d -m 0700 -o "$USER" "$config_dir" + + # Run claude login in subshell with CLAUDE_CONFIG_DIR set + echo "Chat: Launching Claude login for chat identity..." + echo "Chat: (This will authenticate to a separate Anthropic account)" + echo "" + CLAUDE_CONFIG_DIR="$config_dir" claude login + local login_rc=$? + + if [ $login_rc -eq 0 ]; then + echo "" + echo "Chat: Claude identity configured at ${chat_claude_dir}" + echo "Chat: Chat container will use: ${chat_claude_dir}:/home/chat/.claude-chat" + return 0 + else + echo "Error: Claude login failed (exit code ${login_rc})" >&2 + echo "Chat: Identity not configured" + return 1 + fi + ;; + esac + fi + + # Non-interactive, no TTY + echo "Warning: cannot prompt for chat identity (no TTY)" >&2 + echo "Chat: Skipping identity setup — run manually if needed" + return 1 +} + # ── init command ───────────────────────────────────────────────────────────── disinto_init() { @@ -768,6 +863,9 @@ p.write_text(text) # This ensures the password is set before Forgejo user creation prompt_admin_password "${FACTORY_ROOT}/.env" + # Prompt for separate chat identity (#707) + prompt_chat_identity "${FACTORY_ROOT}/.env" + # Set up local Forgejo instance (provision if needed, create users/tokens/repo) if [ "$rotate_tokens" = true ]; then echo "Note: Forcing token rotation (tokens/passwords will be regenerated)" @@ -991,9 +1089,10 @@ p.write_text(text) exit 1 fi - # Write CLAUDE_SHARED_DIR and CLAUDE_CONFIG_DIR to .env (idempotent) + # Write CLAUDE_SHARED_DIR, CLAUDE_CONFIG_DIR, and CHAT_CLAUDE_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" + _env_set_idempotent "CHAT_CLAUDE_DIR" "$CHAT_CLAUDE_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) @@ -1021,15 +1120,24 @@ p.write_text(text) fi echo "" echo "── Claude authentication ──────────────────────────────" - echo " OAuth (shared across containers):" + echo " Factory agents (shared OAuth):" echo " Run 'claude auth login' on the host once." echo " Credentials in ${CLAUDE_CONFIG_DIR} are shared across containers." + echo "" + echo " disinto-chat (separate identity #707):" + echo " Separate identity created at ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}" + echo " Container mounts: ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}:/home/chat/.claude-chat" + echo " CLAUDE_CONFIG_DIR=/home/chat/.claude-chat/config" + echo "" echo " API key (alternative — metered billing, no rotation issues):" echo " Set ANTHROPIC_API_KEY in .env to skip OAuth entirely." + echo " Chat container will not mount identity directory." echo "" - echo "── Claude config directory ────────────────────────────" - echo " CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" - echo " Add this to your shell rc (~/.bashrc or ~/.zshrc):" + echo "── Claude config directories ───────────────────────────" + echo " Factory agents: CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" + echo " Chat service: CLAUDE_CONFIG_DIR=/home/chat/.claude-chat/config" + echo "" + echo " Add factory agents CLAUDE_CONFIG_DIR to your shell rc:" 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." diff --git a/docker/chat/Dockerfile b/docker/chat/Dockerfile index 81aebbe..7674cd5 100644 --- a/docker/chat/Dockerfile +++ b/docker/chat/Dockerfile @@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -# Non-root user — fixed UID 10001 for sandbox hardening (#706) +# Non-root user — fixed UID 10001 for sandbox hardening (#706, #707) RUN useradd -m -u 10001 -s /bin/bash chat # Copy application files @@ -25,9 +25,16 @@ COPY ui/ /var/chat/ui/ RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py +# Create and set ownership of chat identity directory for #707 +RUN install -d -m 0700 /home/chat/.claude-chat/config/credentials \ + && chown -R chat:chat /home/chat/.claude-chat + USER chat WORKDIR /var/chat +# Declare volume for chat identity — mounted from host at runtime (#707) +VOLUME /home/chat/.claude-chat + EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1 diff --git a/lib/claude-config.sh b/lib/claude-config.sh index e6c87c3..ebc290b 100644 --- a/lib/claude-config.sh +++ b/lib/claude-config.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash -# lib/claude-config.sh — Shared Claude config directory helpers (#641) +# lib/claude-config.sh — Shared Claude config directory helpers (#641, #707) # -# Provides setup_claude_config_dir() for creating/migrating CLAUDE_CONFIG_DIR -# and _env_set_idempotent() for writing env vars to .env files. +# Provides: +# setup_claude_dir [] — Create/migrate a Claude config directory +# setup_claude_config_dir [auto_yes] — Wrapper for default CLAUDE_CONFIG_DIR +# _env_set_idempotent() — Write env vars to .env files # # Requires: CLAUDE_CONFIG_DIR, CLAUDE_SHARED_DIR (set by lib/env.sh) @@ -21,24 +23,33 @@ _env_set_idempotent() { 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}" +# Create a Claude config directory, optionally migrating ~/.claude. +# This is the parameterized helper that handles any CLAUDE_CONFIG_DIR path. +# Usage: setup_claude_dir [auto_yes] +# setup_claude_dir /path/to/config [true] +# +# Parameters: +# $1 - Path to the Claude config directory to create +# $2 - Auto-confirm mode (true/false), defaults to false +# +# Returns: 0 on success, 1 on failure +setup_claude_dir() { + local config_dir="$1" + local auto_yes="${2:-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)" + # Create the config directory (idempotent) + install -d -m 0700 -o "$USER" "$config_dir" + echo "Claude: ${config_dir} (ready)" - # If ~/.claude is already a symlink to CLAUDE_CONFIG_DIR, nothing to do + # If ~/.claude is already a symlink to 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") + config_real=$(readlink -f "$config_dir") if [ "$link_target" = "$config_real" ]; then - echo "Claude: ${home_claude} -> ${CLAUDE_CONFIG_DIR} (symlink OK)" + echo "Claude: ${home_claude} -> ${config_dir} (symlink OK)" return 0 fi fi @@ -54,25 +65,25 @@ setup_claude_config_dir() { fi fi - # Check CLAUDE_CONFIG_DIR contents - if [ -n "$(ls -A "$CLAUDE_CONFIG_DIR" 2>/dev/null)" ]; then + # Check config_dir contents + if [ -n "$(ls -A "$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 "ERROR: both ${home_claude} and ${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 + # Case: ~/.claude exists and 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 + read -rp "Migrate ${home_claude} to ${config_dir}? [Y/n] " confirm if [[ ! "$confirm" =~ ^[Nn] ]]; then do_migrate=true fi @@ -83,11 +94,11 @@ setup_claude_config_dir() { 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/" + # Move contents (not the dir itself) to preserve config_dir ownership + cp -a "$home_claude/." "$config_dir/" rm -rf "$home_claude" - ln -sfn "$CLAUDE_CONFIG_DIR" "$home_claude" - echo "Claude: migrated ${home_claude} -> ${CLAUDE_CONFIG_DIR}" + ln -sfn "$config_dir" "$home_claude" + echo "Claude: migrated ${home_claude} -> ${config_dir}" return 0 fi fi @@ -97,7 +108,15 @@ setup_claude_config_dir() { 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)" + ln -sfn "$config_dir" "$home_claude" + echo "Claude: ${home_claude} -> ${config_dir} (symlink created)" fi } + +# Create the shared CLAUDE_CONFIG_DIR, optionally migrating ~/.claude. +# Wrapper around setup_claude_dir for the default config directory. +# Usage: setup_claude_config_dir [auto_yes] +setup_claude_config_dir() { + local auto_yes="${1:-false}" + setup_claude_dir "$CLAUDE_CONFIG_DIR" "$auto_yes" +} diff --git a/lib/generators.sh b/lib/generators.sh index 2b2a2c4..33b1c1d 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -473,9 +473,10 @@ services: - disinto-net command: ["echo", "staging slot — replace with project image"] - # Chat container — Claude chat UI backend (#705) + # Chat container — Claude chat UI backend (#705, #707) # Internal service only; edge proxy routes to chat:8080 # Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps + # Separate identity mount (#707) to avoid OAuth refresh races with factory agents chat: build: context: ./docker/chat @@ -495,8 +496,8 @@ services: volumes: # Mount claude binary from host (same as agents) - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro - # Throwaway named volume for chat config (isolated from host ~/.claude) - - chat-config:/var/chat/config + # Separate Claude identity mount for chat — isolated from factory agents (#707) + - ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}:/home/chat/.claude-chat environment: CHAT_HOST: "0.0.0.0" CHAT_PORT: "8080" @@ -505,6 +506,9 @@ services: CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-} EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-} DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-} + # Point Claude to separate identity directory (#707) + CLAUDE_CONFIG_DIR: /home/chat/.claude-chat/config + CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials networks: - disinto-net @@ -514,7 +518,6 @@ volumes: agent-data: project-repos: caddy_data: - chat-config: networks: disinto-net: -- 2.49.1