diff --git a/.env.example b/.env.example index 1525bb2..73cdb9f 100644 --- a/.env.example +++ b/.env.example @@ -69,11 +69,6 @@ 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 397a240..34824f4 100755 --- a/bin/disinto +++ b/bin/disinto @@ -545,12 +545,6 @@ 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 @@ -964,15 +958,6 @@ 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 bbf3931..485fce9 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -3,32 +3,19 @@ disinto-chat server — minimal HTTP backend for Claude chat UI. Routes: - 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/ + 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) 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, urlencode +from urllib.parse import urlparse, parse_qs # Configuration HOST = os.environ.get("CHAT_HOST", "0.0.0.0") @@ -37,30 +24,6 @@ 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", @@ -74,101 +37,14 @@ 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 with Forgejo OAuth.""" + """HTTP request handler for disinto-chat.""" def log_message(self, format, *args): - """Log to stderr.""" + """Log to stdout instead of stderr.""" print(f"[{self.log_date_time_string()}] {format % args}", file=sys.stderr) - def send_error_page(self, code, message=None): + def send_error(self, code, message=None): """Custom error response.""" self.send_response(code) self.send_header("Content-Type", "text/plain; charset=utf-8") @@ -176,148 +52,47 @@ 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 in ("/", "/chat", "/chat/"): - if not self._require_session(): - return + if path == "/" or path == "/chat": self.serve_index() return # Serve static files - if path.startswith("/chat/static/") or path.startswith("/static/"): - if not self._require_session(): - return + if path.startswith("/static/"): self.serve_static(path) return # Reserved WebSocket endpoint (future use) if path == "/ws" or path.startswith("/ws"): - self.send_error_page(501, "WebSocket upgrade not yet implemented") + self.send_error(501, "WebSocket upgrade not yet implemented") return # 404 for unknown paths - self.send_error_page(404, "Not found") + self.send_error(404, "Not found") def do_POST(self): """Handle POST requests.""" parsed = urlparse(self.path) path = parsed.path - # Chat endpoint (session required) - if path in ("/chat", "/chat/"): - if not self._require_session(): - return + # Chat endpoint + if path == "/chat" or path == "/chat/": self.handle_chat() return # 404 for unknown paths - 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() + self.send_error(404, "Not found") 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_page(500, "UI not found") + self.send_error(500, "UI not found") return try: @@ -329,23 +104,19 @@ class ChatHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(content.encode("utf-8")) except IOError as e: - self.send_error_page(500, f"Error reading index.html: {e}") + self.send_error(500, f"Error reading index.html: {e}") def serve_static(self, path): """Serve static files from the static directory.""" - # Strip /chat/static/ or /static/ prefix - if path.startswith("/chat/static/"): - relative_path = path[len("/chat/static/"):] - else: - relative_path = path[len("/static/"):] - + # Sanitize path to prevent directory traversal + relative_path = path[len("/static/"):] if ".." in relative_path or relative_path.startswith("/"): - self.send_error_page(403, "Forbidden") + self.send_error(403, "Forbidden") return file_path = os.path.join(STATIC_DIR, relative_path) if not os.path.exists(file_path): - self.send_error_page(404, "Not found") + self.send_error(404, "Not found") return # Determine MIME type @@ -361,7 +132,7 @@ class ChatHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(content) except IOError as e: - self.send_error_page(500, f"Error reading file: {e}") + self.send_error(500, f"Error reading file: {e}") def handle_chat(self): """ @@ -371,7 +142,7 @@ class ChatHandler(BaseHTTPRequestHandler): # Read request body content_length = int(self.headers.get("Content-Length", 0)) if content_length == 0: - self.send_error_page(400, "No message provided") + self.send_error(400, "No message provided") return body = self.rfile.read(content_length) @@ -381,16 +152,16 @@ class ChatHandler(BaseHTTPRequestHandler): params = parse_qs(body_str) message = params.get("message", [""])[0] except (UnicodeDecodeError, KeyError): - self.send_error_page(400, "Invalid message format") + self.send_error(400, "Invalid message format") return if not message: - self.send_error_page(400, "Empty message") + self.send_error(400, "Empty message") return # Validate Claude binary exists if not os.path.exists(CLAUDE_BIN): - self.send_error_page(500, "Claude CLI not found") + self.send_error(500, "Claude CLI not found") return try: @@ -415,7 +186,7 @@ class ChatHandler(BaseHTTPRequestHandler): # Check for errors if proc.returncode != 0: - self.send_error_page(500, f"Claude CLI failed with exit code {proc.returncode}") + self.send_error(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") @@ -424,9 +195,9 @@ class ChatHandler(BaseHTTPRequestHandler): self.wfile.write(response.encode("utf-8")) except FileNotFoundError: - self.send_error_page(500, "Claude CLI not found") + self.send_error(500, "Claude CLI not found") except Exception as e: - self.send_error_page(500, f"Error: {e}") + self.send_error(500, f"Error: {e}") def main(): @@ -434,12 +205,7 @@ 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}/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) + print(f"UI available at http://localhost:{PORT}/", file=sys.stderr) httpd.serve_forever() diff --git a/lib/AGENTS.md b/lib/AGENTS.md index bfc7435..01869e2 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_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/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/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 319e83e..0386c37 100644 --- a/lib/ci-setup.sh +++ b/lib/ci-setup.sh @@ -4,9 +4,7 @@ # # 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 # @@ -92,54 +90,6 @@ _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 @@ -150,9 +100,44 @@ _create_woodpecker_oauth_impl() { echo "" echo "── Woodpecker OAuth2 setup ────────────────────────────" - _create_forgejo_oauth_app "woodpecker-ci" "http://localhost:8000/authorize" || return 0 - local client_id="${_OAUTH_CLIENT_ID}" - local client_secret="${_OAUTH_CLIENT_SECRET}" + # 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 # Store Woodpecker forge config in .env # WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references @@ -181,39 +166,6 @@ _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 33b1c1d..e876272 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -501,12 +501,7 @@ 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:-} - # Point Claude to separate identity directory (#707) + # Point Claude to separate identity directory CLAUDE_CONFIG_DIR: /home/chat/.claude-chat/config CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials networks: