From 30e19f71e203144d55e01717f0ce9547228ccc18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:52:16 +0000 Subject: [PATCH 1/3] fix: vision(#623): Forgejo OAuth gate for disinto-chat (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate /chat/* behind Forgejo OAuth2 authorization-code flow. - Extract generic _create_forgejo_oauth_app() helper in lib/ci-setup.sh; Woodpecker OAuth becomes a thin wrapper, chat gets its own app. - bin/disinto init now creates TWO OAuth apps (woodpecker-ci + disinto-chat) and writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env. - docker/chat/server.py: new routes /chat/login (→ Forgejo authorize), /chat/oauth/callback (code→token exchange, user allowlist check, session cookie). All other /chat/* routes require a valid session or redirect to /chat/login. Session store is in-memory with 24h TTL. - lib/generators.sh: pass FORGE_URL, CHAT_OAUTH_CLIENT_ID, CHAT_OAUTH_CLIENT_SECRET, EDGE_TUNNEL_FQDN, DISINTO_CHAT_ALLOWED_USERS to the chat container environment. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 + bin/disinto | 15 +++ docker/chat/server.py | 294 +++++++++++++++++++++++++++++++++++++----- lib/AGENTS.md | 2 +- lib/ci-setup.sh | 124 ++++++++++++------ lib/generators.sh | 5 + 6 files changed, 376 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 73cdb9f..1525bb2 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ WOODPECKER_DB_USER=woodpecker # [CONFIG] Postgres user WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name +# ── Chat OAuth (#708) ──────────────────────────────────────────────────── +CHAT_OAUTH_CLIENT_ID= # [SECRET] Chat OAuth2 client ID (auto-generated by init) +CHAT_OAUTH_CLIENT_SECRET= # [SECRET] Chat OAuth2 client secret (auto-generated by init) +DISINTO_CHAT_ALLOWED_USERS= # [CONFIG] CSV of allowed usernames (disinto-admin always allowed) + # ── Vault-only secrets (DO NOT put these in .env) ──────────────────────── # These tokens grant access to external systems (GitHub, ClawHub, deploy targets). # They live ONLY in .env.vault.enc and are injected into the ephemeral runner diff --git a/bin/disinto b/bin/disinto index 36c8999..bbb11ec 100755 --- a/bin/disinto +++ b/bin/disinto @@ -542,6 +542,12 @@ create_woodpecker_oauth() { _create_woodpecker_oauth_impl "$@" } +# Create Chat OAuth2 app on Forgejo (implementation in lib/ci-setup.sh) +create_chat_oauth() { + _load_ci_context + _create_chat_oauth_impl "$@" +} + # Generate WOODPECKER_TOKEN via Forgejo OAuth2 flow (implementation in lib/ci-setup.sh) generate_woodpecker_token() { _load_ci_context @@ -860,6 +866,15 @@ p.write_text(text) _WP_REPO_ID="" create_woodpecker_oauth "$forge_url" "$forge_repo" + # Create OAuth2 app on Forgejo for disinto-chat (#708) + local chat_redirect_uri + if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then + chat_redirect_uri="https://${EDGE_TUNNEL_FQDN}/chat/oauth/callback" + else + chat_redirect_uri="http://localhost/chat/oauth/callback" + fi + create_chat_oauth "$chat_redirect_uri" + # Generate WOODPECKER_AGENT_SECRET for server↔agent auth local env_file="${FACTORY_ROOT}/.env" if ! grep -q '^WOODPECKER_AGENT_SECRET=' "$env_file" 2>/dev/null; then diff --git a/docker/chat/server.py b/docker/chat/server.py index 485fce9..bbf3931 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -3,19 +3,32 @@ disinto-chat server — minimal HTTP backend for Claude chat UI. Routes: - GET / → serves index.html - GET /static/* → serves static assets (htmx.min.js, etc.) - POST /chat → spawns `claude --print` with user message, returns response - GET /ws → reserved for future streaming upgrade (returns 501) + GET /chat/login → 302 to Forgejo OAuth authorize + GET /chat/oauth/callback → exchange code for token, validate user, set session + GET /chat/ → serves index.html (session required) + GET /chat/static/* → serves static assets (session required) + POST /chat → spawns `claude --print` with user message (session required) + GET /ws → reserved for future streaming upgrade (returns 501) + +OAuth flow: + 1. User hits any /chat/* route without a valid session cookie → 302 /chat/login + 2. /chat/login redirects to Forgejo /login/oauth/authorize + 3. Forgejo redirects back to /chat/oauth/callback with ?code=...&state=... + 4. Server exchanges code for access token, fetches /api/v1/user + 5. Asserts user is in allowlist, sets HttpOnly session cookie + 6. Redirects to /chat/ The claude binary is expected to be mounted from the host at /usr/local/bin/claude. """ +import json import os +import secrets import subprocess import sys +import time from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs +from urllib.parse import urlparse, parse_qs, urlencode # Configuration HOST = os.environ.get("CHAT_HOST", "0.0.0.0") @@ -24,6 +37,30 @@ UI_DIR = "/var/chat/ui" STATIC_DIR = os.path.join(UI_DIR, "static") CLAUDE_BIN = "/usr/local/bin/claude" +# OAuth configuration +FORGE_URL = os.environ.get("FORGE_URL", "http://localhost:3000") +CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "") +CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "") +EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "") + +# Allowed users — disinto-admin always allowed; CSV allowlist extends it +_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "") +ALLOWED_USERS = {"disinto-admin"} +if _allowed_csv: + ALLOWED_USERS.update(u.strip() for u in _allowed_csv.split(",") if u.strip()) + +# Session cookie name +SESSION_COOKIE = "disinto_chat_session" + +# Session TTL: 24 hours +SESSION_TTL = 24 * 60 * 60 + +# In-memory session store: token → {"user": str, "expires": float} +_sessions = {} + +# Pending OAuth state tokens: state → expires (float) +_oauth_states = {} + # MIME types for static files MIME_TYPES = { ".html": "text/html; charset=utf-8", @@ -37,14 +74,101 @@ MIME_TYPES = { } +def _build_callback_uri(): + """Build the OAuth callback URI based on tunnel configuration.""" + if EDGE_TUNNEL_FQDN: + return f"https://{EDGE_TUNNEL_FQDN}/chat/oauth/callback" + return "http://localhost/chat/oauth/callback" + + +def _session_cookie_flags(): + """Return cookie flags appropriate for the deployment mode.""" + flags = "HttpOnly; SameSite=Lax; Path=/chat" + if EDGE_TUNNEL_FQDN: + flags += "; Secure" + return flags + + +def _validate_session(cookie_header): + """Check session cookie and return username if valid, else None.""" + if not cookie_header: + return None + for part in cookie_header.split(";"): + part = part.strip() + if part.startswith(SESSION_COOKIE + "="): + token = part[len(SESSION_COOKIE) + 1:] + session = _sessions.get(token) + if session and session["expires"] > time.time(): + return session["user"] + # Expired — clean up + _sessions.pop(token, None) + return None + return None + + +def _gc_sessions(): + """Remove expired sessions (called opportunistically).""" + now = time.time() + expired = [k for k, v in _sessions.items() if v["expires"] <= now] + for k in expired: + del _sessions[k] + expired_states = [k for k, v in _oauth_states.items() if v <= now] + for k in expired_states: + del _oauth_states[k] + + +def _exchange_code_for_token(code): + """Exchange an authorization code for an access token via Forgejo.""" + import urllib.request + import urllib.error + + data = urlencode({ + "grant_type": "authorization_code", + "code": code, + "client_id": CHAT_OAUTH_CLIENT_ID, + "client_secret": CHAT_OAUTH_CLIENT_SECRET, + "redirect_uri": _build_callback_uri(), + }).encode() + + req = urllib.request.Request( + f"{FORGE_URL}/login/oauth/access_token", + data=data, + headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + except (urllib.error.URLError, json.JSONDecodeError, OSError) as e: + print(f"OAuth token exchange failed: {e}", file=sys.stderr) + return None + + +def _fetch_user(access_token): + """Fetch the authenticated user from Forgejo API.""" + import urllib.request + import urllib.error + + req = urllib.request.Request( + f"{FORGE_URL}/api/v1/user", + headers={"Authorization": f"token {access_token}", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + except (urllib.error.URLError, json.JSONDecodeError, OSError) as e: + print(f"User fetch failed: {e}", file=sys.stderr) + return None + + class ChatHandler(BaseHTTPRequestHandler): - """HTTP request handler for disinto-chat.""" + """HTTP request handler for disinto-chat with Forgejo OAuth.""" def log_message(self, format, *args): - """Log to stdout instead of stderr.""" + """Log to stderr.""" print(f"[{self.log_date_time_string()}] {format % args}", file=sys.stderr) - def send_error(self, code, message=None): + def send_error_page(self, code, message=None): """Custom error response.""" self.send_response(code) self.send_header("Content-Type", "text/plain; charset=utf-8") @@ -52,47 +176,148 @@ class ChatHandler(BaseHTTPRequestHandler): if message: self.wfile.write(message.encode("utf-8")) + def _require_session(self): + """Check session; redirect to /chat/login if missing. Returns username or None.""" + user = _validate_session(self.headers.get("Cookie")) + if user: + return user + self.send_response(302) + self.send_header("Location", "/chat/login") + self.end_headers() + return None + def do_GET(self): """Handle GET requests.""" parsed = urlparse(self.path) path = parsed.path + # OAuth routes (no session required) + if path == "/chat/login": + self.handle_login() + return + + if path == "/chat/oauth/callback": + self.handle_oauth_callback(parsed.query) + return + # Serve index.html at root - if path == "/" or path == "/chat": + if path in ("/", "/chat", "/chat/"): + if not self._require_session(): + return self.serve_index() return # Serve static files - if path.startswith("/static/"): + if path.startswith("/chat/static/") or path.startswith("/static/"): + if not self._require_session(): + return self.serve_static(path) return # Reserved WebSocket endpoint (future use) if path == "/ws" or path.startswith("/ws"): - self.send_error(501, "WebSocket upgrade not yet implemented") + self.send_error_page(501, "WebSocket upgrade not yet implemented") return # 404 for unknown paths - self.send_error(404, "Not found") + self.send_error_page(404, "Not found") def do_POST(self): """Handle POST requests.""" parsed = urlparse(self.path) path = parsed.path - # Chat endpoint - if path == "/chat" or path == "/chat/": + # Chat endpoint (session required) + if path in ("/chat", "/chat/"): + if not self._require_session(): + return self.handle_chat() return # 404 for unknown paths - self.send_error(404, "Not found") + self.send_error_page(404, "Not found") + + def handle_login(self): + """Redirect to Forgejo OAuth authorize endpoint.""" + _gc_sessions() + + if not CHAT_OAUTH_CLIENT_ID: + self.send_error_page(500, "Chat OAuth not configured (CHAT_OAUTH_CLIENT_ID missing)") + return + + state = secrets.token_urlsafe(32) + _oauth_states[state] = time.time() + 600 # 10 min validity + + params = urlencode({ + "client_id": CHAT_OAUTH_CLIENT_ID, + "redirect_uri": _build_callback_uri(), + "response_type": "code", + "state": state, + }) + self.send_response(302) + self.send_header("Location", f"{FORGE_URL}/login/oauth/authorize?{params}") + self.end_headers() + + def handle_oauth_callback(self, query_string): + """Exchange authorization code for token, validate user, set session.""" + params = parse_qs(query_string) + code = params.get("code", [""])[0] + state = params.get("state", [""])[0] + + # Validate state + expected_expiry = _oauth_states.pop(state, None) if state else None + if not expected_expiry or expected_expiry < time.time(): + self.send_error_page(400, "Invalid or expired OAuth state") + return + + if not code: + self.send_error_page(400, "Missing authorization code") + return + + # Exchange code for access token + token_resp = _exchange_code_for_token(code) + if not token_resp or "access_token" not in token_resp: + self.send_error_page(502, "Failed to obtain access token from Forgejo") + return + + access_token = token_resp["access_token"] + + # Fetch user info + user_info = _fetch_user(access_token) + if not user_info or "login" not in user_info: + self.send_error_page(502, "Failed to fetch user info from Forgejo") + return + + username = user_info["login"] + + # Check allowlist + if username not in ALLOWED_USERS: + self.send_response(403) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write( + f"Not authorised: user '{username}' is not in the allowed users list.\n".encode() + ) + return + + # Create session + session_token = secrets.token_urlsafe(48) + _sessions[session_token] = { + "user": username, + "expires": time.time() + SESSION_TTL, + } + + cookie_flags = _session_cookie_flags() + self.send_response(302) + self.send_header("Set-Cookie", f"{SESSION_COOKIE}={session_token}; {cookie_flags}") + self.send_header("Location", "/chat/") + self.end_headers() def serve_index(self): """Serve the main index.html file.""" index_path = os.path.join(UI_DIR, "index.html") if not os.path.exists(index_path): - self.send_error(500, "UI not found") + self.send_error_page(500, "UI not found") return try: @@ -104,19 +329,23 @@ class ChatHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(content.encode("utf-8")) except IOError as e: - self.send_error(500, f"Error reading index.html: {e}") + self.send_error_page(500, f"Error reading index.html: {e}") def serve_static(self, path): """Serve static files from the static directory.""" - # Sanitize path to prevent directory traversal - relative_path = path[len("/static/"):] + # Strip /chat/static/ or /static/ prefix + if path.startswith("/chat/static/"): + relative_path = path[len("/chat/static/"):] + else: + relative_path = path[len("/static/"):] + if ".." in relative_path or relative_path.startswith("/"): - self.send_error(403, "Forbidden") + self.send_error_page(403, "Forbidden") return file_path = os.path.join(STATIC_DIR, relative_path) if not os.path.exists(file_path): - self.send_error(404, "Not found") + self.send_error_page(404, "Not found") return # Determine MIME type @@ -132,7 +361,7 @@ class ChatHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(content) except IOError as e: - self.send_error(500, f"Error reading file: {e}") + self.send_error_page(500, f"Error reading file: {e}") def handle_chat(self): """ @@ -142,7 +371,7 @@ class ChatHandler(BaseHTTPRequestHandler): # Read request body content_length = int(self.headers.get("Content-Length", 0)) if content_length == 0: - self.send_error(400, "No message provided") + self.send_error_page(400, "No message provided") return body = self.rfile.read(content_length) @@ -152,16 +381,16 @@ class ChatHandler(BaseHTTPRequestHandler): params = parse_qs(body_str) message = params.get("message", [""])[0] except (UnicodeDecodeError, KeyError): - self.send_error(400, "Invalid message format") + self.send_error_page(400, "Invalid message format") return if not message: - self.send_error(400, "Empty message") + self.send_error_page(400, "Empty message") return # Validate Claude binary exists if not os.path.exists(CLAUDE_BIN): - self.send_error(500, "Claude CLI not found") + self.send_error_page(500, "Claude CLI not found") return try: @@ -186,7 +415,7 @@ class ChatHandler(BaseHTTPRequestHandler): # Check for errors if proc.returncode != 0: - self.send_error(500, f"Claude CLI failed with exit code {proc.returncode}") + self.send_error_page(500, f"Claude CLI failed with exit code {proc.returncode}") return self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") @@ -195,9 +424,9 @@ class ChatHandler(BaseHTTPRequestHandler): self.wfile.write(response.encode("utf-8")) except FileNotFoundError: - self.send_error(500, "Claude CLI not found") + self.send_error_page(500, "Claude CLI not found") except Exception as e: - self.send_error(500, f"Error: {e}") + self.send_error_page(500, f"Error: {e}") def main(): @@ -205,7 +434,12 @@ def main(): server_address = (HOST, PORT) httpd = HTTPServer(server_address, ChatHandler) print(f"Starting disinto-chat server on {HOST}:{PORT}", file=sys.stderr) - print(f"UI available at http://localhost:{PORT}/", file=sys.stderr) + print(f"UI available at http://localhost:{PORT}/chat/", file=sys.stderr) + if CHAT_OAUTH_CLIENT_ID: + print(f"OAuth enabled (client_id={CHAT_OAUTH_CLIENT_ID[:8]}...)", file=sys.stderr) + print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr) + else: + print("WARNING: CHAT_OAUTH_CLIENT_ID not set — OAuth disabled", file=sys.stderr) httpd.serve_forever() diff --git a/lib/AGENTS.md b/lib/AGENTS.md index 01869e2..bfc7435 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -29,7 +29,7 @@ sourced as needed. | `lib/forge-push.sh` | `push_to_forge()` — pushes a local clone to the Forgejo remote and verifies the push. `_assert_forge_push_globals()` validates required env vars before use. Requires `FORGE_URL`, `FORGE_PASS`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. **Auth**: uses `FORGE_PASS` (bot password) for git HTTP push — Forgejo 11.x rejects API tokens for `git push` (#361). | bin/disinto (init) | | `lib/git-creds.sh` | Shared git credential helper configuration. `configure_git_creds([HOME_DIR] [RUN_AS_CMD])` — writes a static credential helper script and configures git globally to use password-based HTTP auth (Forgejo 11.x rejects API tokens for `git push`, #361). `repair_baked_cred_urls([--as RUN_AS_CMD] DIR ...)` — rewrites any git remote URLs that have credentials baked in to use clean URLs instead; uses `safe.directory` bypass for root-owned repos (#671). Requires `FORGE_PASS`, `FORGE_URL`, `FORGE_TOKEN`. | entrypoints (agents, edge) | | `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence, sprints). Evidence subdirectories seeded: engagement/, red-team/, holdout/, evolution/, user-test/. Also seeds sprints/ for architect output. Exports `_ACTUAL_OPS_SLUG`. `migrate_ops_repo(ops_root, [primary_branch])` — idempotent migration helper that seeds missing directories and .gitkeep files on existing ops repos (pre-#407 deployments). | bin/disinto (init) | -| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_woodpecker_oauth_impl()` — creates OAuth2 app on Forgejo for Woodpecker. `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) | +| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_forgejo_oauth_app()` — generic helper to create an OAuth2 app on Forgejo (shared by Woodpecker and chat). `_create_woodpecker_oauth_impl()` — creates Woodpecker OAuth2 app (thin wrapper). `_create_chat_oauth_impl()` — creates disinto-chat OAuth2 app, writes `CHAT_OAUTH_CLIENT_ID`/`CHAT_OAUTH_CLIENT_SECRET` to `.env` (#708). `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) | | `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume; all `depends_on` now use `condition: service_healthy/started` instead of bare service names), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80, `/chat/*` → chat:8080; root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) | | `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) | | `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) | diff --git a/lib/ci-setup.sh b/lib/ci-setup.sh index 0386c37..319e83e 100644 --- a/lib/ci-setup.sh +++ b/lib/ci-setup.sh @@ -4,7 +4,9 @@ # # Internal functions (called via _load_ci_context + _*_impl): # _install_cron_impl() - Install crontab entries (bare-metal only; compose uses polling loop) +# _create_forgejo_oauth_app() - Generic: create an OAuth2 app on Forgejo (shared helper) # _create_woodpecker_oauth_impl() - Create OAuth2 app on Forgejo for Woodpecker +# _create_chat_oauth_impl() - Create OAuth2 app on Forgejo for disinto-chat # _generate_woodpecker_token_impl() - Auto-generate WOODPECKER_TOKEN via OAuth2 flow # _activate_woodpecker_repo_impl() - Activate repo in Woodpecker # @@ -90,6 +92,54 @@ _install_cron_impl() { fi } +# Create an OAuth2 application on Forgejo. +# Generic helper used by both Woodpecker and chat OAuth setup. +# Sets _OAUTH_CLIENT_ID and _OAUTH_CLIENT_SECRET on success. +# Usage: _create_forgejo_oauth_app +_create_forgejo_oauth_app() { + local oauth2_name="$1" + local redirect_uri="$2" + local forge_url="${FORGE_URL}" + + _OAUTH_CLIENT_ID="" + _OAUTH_CLIENT_SECRET="" + + local existing_app + existing_app=$(curl -sf \ + -H "Authorization: token ${FORGE_TOKEN}" \ + "${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \ + | jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true + + if [ -n "$existing_app" ]; then + echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})" + _OAUTH_CLIENT_ID="$existing_app" + return 0 + fi + + local oauth2_resp + oauth2_resp=$(curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${forge_url}/api/v1/user/applications/oauth2" \ + -d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \ + 2>/dev/null) || oauth2_resp="" + + if [ -z "$oauth2_resp" ]; then + echo "Warning: failed to create OAuth2 app '${oauth2_name}' on Forgejo" >&2 + return 1 + fi + + _OAUTH_CLIENT_ID=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty') + _OAUTH_CLIENT_SECRET=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty') + + if [ -z "$_OAUTH_CLIENT_ID" ]; then + echo "Warning: OAuth2 app creation returned no client_id" >&2 + return 1 + fi + + echo "OAuth2: ${oauth2_name} created (client_id=${_OAUTH_CLIENT_ID})" +} + # Set up Woodpecker CI to use Forgejo as its forge backend. # Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo. # Usage: create_woodpecker_oauth @@ -100,44 +150,9 @@ _create_woodpecker_oauth_impl() { echo "" echo "── Woodpecker OAuth2 setup ────────────────────────────" - # Create OAuth2 application on Forgejo for Woodpecker - local oauth2_name="woodpecker-ci" - local redirect_uri="http://localhost:8000/authorize" - local existing_app client_id client_secret - - # Check if OAuth2 app already exists - existing_app=$(curl -sf \ - -H "Authorization: token ${FORGE_TOKEN}" \ - "${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \ - | jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true - - if [ -n "$existing_app" ]; then - echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})" - client_id="$existing_app" - else - local oauth2_resp - oauth2_resp=$(curl -sf -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${forge_url}/api/v1/user/applications/oauth2" \ - -d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \ - 2>/dev/null) || oauth2_resp="" - - if [ -z "$oauth2_resp" ]; then - echo "Warning: failed to create OAuth2 app on Forgejo" >&2 - return - fi - - client_id=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty') - client_secret=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty') - - if [ -z "$client_id" ]; then - echo "Warning: OAuth2 app creation returned no client_id" >&2 - return - fi - - echo "OAuth2: ${oauth2_name} created (client_id=${client_id})" - fi + _create_forgejo_oauth_app "woodpecker-ci" "http://localhost:8000/authorize" || return 0 + local client_id="${_OAUTH_CLIENT_ID}" + local client_secret="${_OAUTH_CLIENT_SECRET}" # Store Woodpecker forge config in .env # WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references @@ -166,6 +181,39 @@ _create_woodpecker_oauth_impl() { echo "Config: Woodpecker forge vars written to .env" } +# Create OAuth2 app on Forgejo for disinto-chat. +# Writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env. +# Usage: _create_chat_oauth_impl +_create_chat_oauth_impl() { + local redirect_uri="$1" + + echo "" + echo "── Chat OAuth2 setup ──────────────────────────────────" + + _create_forgejo_oauth_app "disinto-chat" "$redirect_uri" || return 0 + local client_id="${_OAUTH_CLIENT_ID}" + local client_secret="${_OAUTH_CLIENT_SECRET}" + + local env_file="${FACTORY_ROOT}/.env" + local chat_vars=() + if [ -n "${client_id:-}" ]; then + chat_vars+=("CHAT_OAUTH_CLIENT_ID=${client_id}") + fi + if [ -n "${client_secret:-}" ]; then + chat_vars+=("CHAT_OAUTH_CLIENT_SECRET=${client_secret}") + fi + + for var_line in "${chat_vars[@]}"; do + local var_name="${var_line%%=*}" + if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then + sed -i "s|^${var_name}=.*|${var_line}|" "$env_file" + else + printf '%s\n' "$var_line" >> "$env_file" + fi + done + echo "Config: Chat OAuth vars written to .env" +} + # Auto-generate WOODPECKER_TOKEN by driving the Forgejo OAuth2 login flow. # Requires _FORGE_ADMIN_PASS (set by setup_forge when admin user was just created). # Called after compose stack is up, before activate_woodpecker_repo. diff --git a/lib/generators.sh b/lib/generators.sh index 9ea6c7f..2b2a2c4 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -500,6 +500,11 @@ services: environment: CHAT_HOST: "0.0.0.0" CHAT_PORT: "8080" + FORGE_URL: http://forgejo:3000 + CHAT_OAUTH_CLIENT_ID: ${CHAT_OAUTH_CLIENT_ID:-} + CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-} + EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-} + DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-} networks: - disinto-net From a15f0763b7fd1bf7dfdc60804fbaad0c380b1d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:50:23 +0000 Subject: [PATCH 2/3] 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 36c8999..34824f4 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" @@ -634,6 +637,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() { @@ -762,6 +857,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)" @@ -976,9 +1074,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) @@ -1006,15 +1105,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 9ea6c7f..e876272 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,11 +496,14 @@ 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" + # Point Claude to separate identity directory + CLAUDE_CONFIG_DIR: /home/chat/.claude-chat/config + CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials networks: - disinto-net @@ -509,7 +513,6 @@ volumes: agent-data: project-repos: caddy_data: - chat-config: networks: disinto-net: From 94a66e1957807ddd0ca539b29bb8992449a65ccc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:50:23 +0000 Subject: [PATCH 3/3] 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: