import asyncio import html import logging import os from uuid import uuid4 logger = logging.getLogger(__name__) from fastapi.responses import JSONResponse, HTMLResponse from fastapi import Request from backend.apps.oauth_state import ( _pending_oauth, _completed_oauth, _MAX_COMPLETED_OAUTH, _mark_oauth_completed, ) from backend.config.Apps import MainApp from backend.apps.health.health import health from backend.apps.agents.agents import agents from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.skills.skills import skills from backend.apps.tools_lib.tools_lib import tools_lib from backend.apps.modes.modes import modes from backend.apps.settings.settings import settings from backend.apps.mcp_registry.mcp_registry import mcp_registry from backend.apps.skill_registry.skill_registry import skill_registry from backend.apps.outputs.outputs import outputs from backend.apps.dashboards.dashboards import dashboards from backend.apps.service.service import service from backend.apps.subscription.router import subscription from backend.apps.auth.router import auth from backend.apps.web.web import web from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy from fastapi.middleware.cors import CORSMiddleware from fastapi import WebSocket, WebSocketDisconnect import json main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the # time any request lands, the token file exists. See backend/auth.py. from backend.auth import ( init_auth_token, install_token_scrubber, is_path_exempt, request_matches_token, is_origin_allowed, ) init_auth_token() # Install the log scrubber AFTER the token exists so any log line that # accidentally embeds it (subprocess env dumps, urllib retry traces, # proxied-request error bodies) gets redacted before hitting handlers. install_token_scrubber() # Generate the per-install id (installation_id) at the same pre-bind moment # as the auth token. It is otherwise created lazily on the first analytics # submission, so on a clean install the sign-in window can render and build # its Google/email OAuth URL (which embeds install_id) before that # submission fires, producing an empty install_id that the cloud rejects. # Generating here guarantees the very first GET /api/settings already # carries it. Platform-agnostic; wrapped so a settings hiccup never blocks # startup, and the lazy path stays as a fallback. try: import uuid as _uuid from backend.apps.settings.store import load_settings as _load_boot_settings, save_settings as _save_boot_settings _boot_settings = _load_boot_settings() if not getattr(_boot_settings, "installation_id", None): _boot_settings.installation_id = _uuid.uuid4().hex _save_boot_settings(_boot_settings) except Exception: pass # CORS: previously wide open (`allow_origins=["*"]`), which combined with # `allow_credentials=True` was a security footgun, any external origin # could CORS-preflight us. Now restricted to Electron renderer origins + # localhost dev servers. The token middleware below provides the # *primary* defense; CORS is defense-in-depth so a misconfigured page # can't even reach us. app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:3000", "http://127.0.0.1:3000", "https://api.openswarm.com", "https://openswarm.com", ], allow_origin_regex=r"^(file://.*|http://localhost:\d+|http://127\.0\.0\.1:\d+)$", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], # Every cross-origin POST from the Electron renderer (file:// → http://localhost:8324) # carries Authorization: Bearer, which CORS classifies as non-simple and # forces a preflight OPTIONS before EACH POST. With no max_age the browser # re-preflights on a tight schedule (~5 s in Chromium); under heavy # interaction we observed a 1:1 OPTIONS-to-POST ratio in the dev log, # doubling roundtrip count for no reason. Caching the preflight result # for 10 minutes drops that to one OPTIONS per ~600 POSTs. max_age=600, ) @app.middleware("http") async def _auth_middleware(request: Request, call_next): """Reject HTTP requests without our per-install bearer token. Exemptions (see `auth.is_path_exempt`): - `/api/subscriptions/callback`, external OAuth redirects - `/api/health`, `/api/version`, Electron boot handshake - `OPTIONS` preflights, browsers don't send Authorization on them Anything else requires `Authorization: Bearer ` OR `x-openswarm-token: `. Failure responds with 401 and a short JSON error, no upstream handler sees the request. The anthropic-proxy route (`/api/anthropic-proxy/v1/*`) is NOT exempt. Its caller (the Claude Code CLI we spawn) is configured with `ANTHROPIC_API_KEY=` so the CLI's `x-api-key` header carries our token, which `request_matches_token` accepts via its auth-header branches. """ # Preflights never carry Authorization. if request.method == "OPTIONS": response = await call_next(request) elif is_path_exempt(request.url.path): response = await call_next(request) else: # Accept Authorization Bearer, x-openswarm-token, OR x-api-key # (CLI path, CLI sends x-api-key with our token as value). headers = dict(request.headers) x_api_key = headers.get("x-api-key") or headers.get("X-API-Key") # Accept `?token=` query param too. Required for browser-driven # GETs that can't set headers, notably the App Builder iframe loading # /api/outputs/.../serve/index.html via