diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a770d078..ec36bc42 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1075,11 +1075,14 @@ class AgentManager: ) backend_port = os.environ.get("OPENSWARM_PORT", "8324") pre_selected_bids = self._get_pre_selected_browser_ids(session.dashboard_id) + from backend.auth import get_auth_token as _get_auth_token + _auth_tok = _get_auth_token() mcp_servers["openswarm-browser-agent"] = { "command": sys.executable, "args": [browser_agent_server_path], "env": { "OPENSWARM_PORT": backend_port, + "OPENSWARM_AUTH_TOKEN": _auth_tok, "OPENSWARM_AGENT_MODEL": session.model, "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), @@ -1099,11 +1102,13 @@ class AgentManager: os.path.dirname(__file__), "invoke_agent_mcp_server.py" ) backend_port = os.environ.get("OPENSWARM_PORT", "8324") + from backend.auth import get_auth_token as _get_auth_token2 mcp_servers["openswarm-invoke-agent"] = { "command": sys.executable, "args": [invoke_agent_server_path], "env": { "OPENSWARM_PORT": backend_port, + "OPENSWARM_AUTH_TOKEN": _get_auth_token2(), "OPENSWARM_PARENT_SESSION_ID": session.id, "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", }, @@ -1179,9 +1184,21 @@ class AgentManager: # WebSearch delegation through 9Router would fail. Only # consider Anthropic reachable if 9Router can actually serve # the Anthropic-format request. + # Deliberately exclude openswarm-pro from the "has anthropic + # path" heuristic when the primary is non-Claude. Reason: if + # a Pro user picks GPT or Gemini as their primary, we + # shouldn't drag their WebSearch/subagent calls through our + # Pro Anthropic pool — they're already paying for a + # ChatGPT/Gemini subscription we can use for free. Pro still + # kicks in when they switch the primary to a Claude model. + _primary_is_claude = _m.startswith("cc/") or ( + isinstance(_router_model_id, str) + and not _router_model_id.startswith(("cc/", "cx/", "gc/", "ag/", "gemini/")) + and _api_type_for_session == "anthropic" + ) _has_anthropic_path = ( bool(getattr(global_settings, "anthropic_api_key", None)) # direct env bypass - or _9r_has_anthropic + or (_9r_has_anthropic and _primary_is_claude) ) _need_web_mcp = not _has_anthropic_path @@ -1197,11 +1214,13 @@ class AgentManager: _primary_hint = "openai" else: _primary_hint = "" + from backend.auth import get_auth_token as _get_auth_token3 mcp_servers["openswarm-web"] = { "command": sys.executable, "args": [web_mcp_server_path], "env": { "OPENSWARM_PORT": backend_port, + "OPENSWARM_AUTH_TOKEN": _get_auth_token3(), "OPENSWARM_PRIMARY_API": _primary_hint, }, "type": "stdio", @@ -1375,26 +1394,61 @@ class AgentManager: # `cc/*` pinned-Claude routes (user has a real Claude # sub via 9Router) we stay on 9Router directly so that # sub quota is used — no need to proxy through Pro. - _is_pro = getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro" - _has_bearer = bool(getattr(global_settings, "openswarm_bearer_token", None)) - _use_proxy = _is_pro and _has_bearer and api_type != "anthropic" - if _use_proxy: - env = { - "ANTHROPIC_API_KEY": "anthropic-proxy", - "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{backend_port}/api/anthropic-proxy", - # See claude-sonnet branch above. Pinning these - # ensures subagents and CLI's WebSearch - # delegation use model IDs the Pro cloud accepts. - "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6", - "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", - } - logger.info(f"[MCP-DEBUG] Using anthropic-proxy (Pro bearer + non-Claude primary)") + # Pro + non-Claude primary: intentionally do NOT route + # subagents/WebSearch through our Pro Anthropic pool. + # The user is already paying for a ChatGPT or Gemini + # subscription — use that lane (free to us) and keep + # the Pro credit for when they actually select a Claude + # primary. The plain 9Router branch below picks a + # subagent model that matches whichever OAuth lane they + # have active. + if False: # reserved for future Pro-only routing cases + pass else: env = { "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } + # No Pro bearer → the CLI's default subagent model + # (`claude-haiku-4-5-20251001`) would hit 9Router + # with no Anthropic route and fail with "No + # credentials for provider: anthropic". Pick a + # subagent model that matches whatever lane the + # user DOES have, in priority order: own Anthropic + # key > Claude-sub > ChatGPT-Plus > Antigravity > + # Gemini-CLI. If none of those are connected, leave + # it unset and the CLI will fail gracefully. + try: + _sub_conns = _conns # reuse the list fetched above + except NameError: + _sub_conns = [] + _active = {c.get("provider") for c in _sub_conns + if isinstance(c, dict) and c.get("isActive")} + _sub_model = None + _small_model = None + if global_settings.anthropic_api_key: + _sub_model = "claude-sonnet-4-6" + _small_model = "claude-haiku-4-5-20251001" + elif "claude" in _active or "anthropic" in _active: + _sub_model = "cc/claude-sonnet-4-6" + _small_model = "cc/claude-haiku-4-5-20251001" + elif "antigravity" in _active: + _sub_model = "ag/gemini-3-flash" + _small_model = "ag/gemini-3-flash" + elif "gemini-cli" in _active: + _sub_model = "gc/gemini-2.5-flash" + _small_model = "gc/gemini-2.5-flash" + elif "codex" in _active: + _sub_model = "cx/gpt-5.4-mini" + _small_model = "cx/gpt-5.4-mini" + if _sub_model: + env["CLAUDE_CODE_SUBAGENT_MODEL"] = _sub_model + if _small_model: + env["ANTHROPIC_SMALL_FAST_MODEL"] = _small_model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = _small_model + logger.info( + f"[MCP-DEBUG] 9Router direct — subagent_model={_sub_model}, small_fast={_small_model}" + ) # ENABLE_TOOL_SEARCH=auto is Claude-specific. It keeps the # deferred-tool pool (WebSearch, NotebookEdit, TodoWrite, # EnterPlanMode, Cron*, Task*, etc.) reachable via the diff --git a/backend/apps/agents/anthropic_proxy.py b/backend/apps/agents/anthropic_proxy.py index 863b97a8..3ffe8849 100644 --- a/backend/apps/agents/anthropic_proxy.py +++ b/backend/apps/agents/anthropic_proxy.py @@ -122,6 +122,15 @@ async def proxy(rest: str, request: Request): for k, v in request.headers.items(): if k.lower() in _HOP_HEADERS: continue + # The CLI we spawn carries our per-install auth token via + # `x-api-key` (we set `ANTHROPIC_API_KEY=` on the + # spawn env, and the CLI forwards that value as x-api-key). We + # must NOT forward that header to the real upstream — it would + # leak our local token to api.openswarm.com / 9Router, AND it + # would shadow the real upstream auth (bearer or `9router` + # literal) that `_pick_upstream` wants to set. Strip it here. + if k.lower() == "x-api-key": + continue forward_headers[k] = v forward_headers.update(auth_headers) diff --git a/backend/apps/agents/browser_agent_mcp_server.py b/backend/apps/agents/browser_agent_mcp_server.py index 0f7f4b6b..b864e1b6 100644 --- a/backend/apps/agents/browser_agent_mcp_server.py +++ b/backend/apps/agents/browser_agent_mcp_server.py @@ -21,6 +21,7 @@ except ImportError: HAS_PIL = False BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/browser-agent/run" MODEL = os.environ.get("OPENSWARM_AGENT_MODEL", "sonnet") DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") @@ -138,10 +139,13 @@ def call_backend(tasks: list[dict]) -> dict: "pre_selected_browser_ids": pre_selected, "parent_session_id": PARENT_SESSION_ID, }).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" req = urllib.request.Request( BACKEND_URL, data=payload, - headers={"Content-Type": "application/json"}, + headers=headers, method="POST", ) try: diff --git a/backend/apps/agents/invoke_agent_mcp_server.py b/backend/apps/agents/invoke_agent_mcp_server.py index cf614b03..8bd5bb76 100644 --- a/backend/apps/agents/invoke_agent_mcp_server.py +++ b/backend/apps/agents/invoke_agent_mcp_server.py @@ -14,6 +14,7 @@ import urllib.request import urllib.error BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/invoke-agent/run" PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") @@ -69,10 +70,13 @@ def call_backend(session_id: str, message: str) -> dict: "parent_session_id": PARENT_SESSION_ID, "dashboard_id": DASHBOARD_ID, }).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" req = urllib.request.Request( BACKEND_URL, data=payload, - headers={"Content-Type": "application/json"}, + headers=headers, method="POST", ) try: diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index c58b0e8f..4fd06b1e 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -28,6 +28,7 @@ import urllib.error import urllib.request BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") SEARCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/search" FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch" @@ -100,10 +101,13 @@ def send_response(id_, result=None, error=None): def _post(url: str, body: dict, timeout: float = 60.0) -> dict: payload = json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" req = urllib.request.Request( url, data=payload, - headers={"Content-Type": "application/json"}, + headers=headers, method="POST", ) try: diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 00000000..5b4e41a8 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,200 @@ +"""Per-install auth token for the localhost API. + +OpenSwarm's backend runs a FastAPI server on `127.0.0.1:` +and streams sensitive agent data (tool inputs, approval requests, +messages) over WebSockets. Without auth, any webpage loaded in any +browser on the same machine can connect to those endpoints — WebSockets +aren't subject to Same-Origin Policy — and impersonate the user. + +This module issues a cryptographically random token at backend startup, +writes it 0600 to `/auth.token`, and provides validation +helpers. The token changes every backend restart. Only code running as +the same OS user can read the file. + +Delivery to legitimate consumers: + +- Electron main process reads the file and exposes it to the renderer + via a contextBridge method in preload.js (NOT plain window global). +- Our Python MCP subprocesses receive it via env var + `OPENSWARM_AUTH_TOKEN` that agent_manager passes when spawning. +- The Claude Code CLI we spawn receives it as `ANTHROPIC_API_KEY` in + env; the anthropic-proxy route trusts that value. + +None of those paths are accessible from a third-party webpage. +""" + +from __future__ import annotations + +import logging +import os +import secrets + +from backend.config.paths import AUTH_TOKEN_FILE, DATA_ROOT + +logger = logging.getLogger(__name__) + +_TOKEN: str = "" + + +def _write_atomic(path: str, data: str, mode: int = 0o600) -> None: + """Write `data` to `path` atomically with the given file mode. + + Uses `os.open(..., O_CREAT|O_WRONLY|O_TRUNC, mode)` + rename so the + final file is never world-readable and never left half-written if + the backend crashes mid-write. Windows-safe (rename of a file over + an existing one works on NTFS when the source was just closed). + """ + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, mode) + try: + os.write(fd, data.encode("utf-8")) + finally: + os.close(fd) + try: + os.chmod(tmp, mode) + except Exception: + pass + os.replace(tmp, path) + + +def init_auth_token() -> str: + """Generate a fresh token, persist to disk, return it. + + Called once at backend startup before the HTTP port is bound. + """ + global _TOKEN + _TOKEN = secrets.token_urlsafe(32) + try: + _write_atomic(AUTH_TOKEN_FILE, _TOKEN, mode=0o600) + logger.info(f"auth: wrote token to {AUTH_TOKEN_FILE} (mode 0600)") + except Exception as e: + # Fail open is NOT an option here — if we can't write the file, + # Electron can't read it, and the user sees a broken app. But + # don't hard-crash the backend either; log loudly. + logger.error(f"auth: failed to write token file: {e}") + return _TOKEN + + +def get_auth_token() -> str: + """Return the current token. Empty string if init_auth_token() hasn't run.""" + return _TOKEN + + +# Paths that never require auth. These are the public surface. +_AUTH_EXEMPT_EXACT = { + # External OAuth providers redirect the user's browser here. The + # browser has no way to inject our bearer token (it's a 302 from + # Google/Anthropic/etc). The `state` query param is already a + # one-time nonce validated against `_pending_oauth`. + "/api/subscriptions/callback", + # Electron's boot handshake pings these before it has a token — + # the HTTP port is up before the token file is readable in some + # races. Safe to expose since they don't return any session data. + "/api/health", + "/api/version", +} + +# Path prefixes that never require auth. Trailing slash optional. +_AUTH_EXEMPT_PREFIX = ( + # FastAPI's default health/docs/schema surface (packaged app never + # ships /docs, but be defensive). + "/docs", + "/openapi", + "/redoc", + "/favicon", +) + + +def is_path_exempt(path: str) -> bool: + """True if this request path bypasses token auth.""" + if path in _AUTH_EXEMPT_EXACT: + return True + for p in _AUTH_EXEMPT_PREFIX: + if path.startswith(p): + return True + return False + + +def extract_bearer(header_value: str | None) -> str: + """Pull the token out of `Authorization: Bearer `.""" + if not header_value: + return "" + if header_value.startswith("Bearer "): + return header_value[len("Bearer "):].strip() + if header_value.startswith("bearer "): + return header_value[len("bearer "):].strip() + return "" + + +def request_matches_token(request_headers: dict, query_params: dict | None = None) -> bool: + """Validate that an incoming HTTP / WS request carries our token. + + Accepts any of: + - `Authorization: Bearer ` + - `x-openswarm-token: ` (custom header for callers that + can't easily set Authorization — e.g. future CLI clients) + - `?token=` query param (WS only; browsers can't easily + set custom WS headers, so the token rides in the URL) + + The token comparison is constant-time via `secrets.compare_digest`. + """ + if not _TOKEN: + # Backend started without auth init — fail closed. This should + # only happen in test fixtures that intentionally bypass main. + return False + + candidates: list[str] = [] + + auth = request_headers.get("authorization") or request_headers.get("Authorization") + bearer = extract_bearer(auth) + if bearer: + candidates.append(bearer) + + openswarm_header = ( + request_headers.get("x-openswarm-token") + or request_headers.get("X-OpenSwarm-Token") + ) + if openswarm_header: + candidates.append(openswarm_header.strip()) + + if query_params: + qp_token = query_params.get("token") + if qp_token: + candidates.append(qp_token) + + for candidate in candidates: + if secrets.compare_digest(candidate, _TOKEN): + return True + return False + + +# Origin allowlist for WS handshakes. Electron's renderer loads from +# `file://` when packaged; `http://localhost:3000` (Vite dev server) and +# `http://127.0.0.1:3000` in dev. A bare `null` Origin is sent by some +# Electron contexts. +_ORIGIN_ALLOWLIST_DEV = { + "http://localhost:3000", + "http://127.0.0.1:3000", + # Electron may load prod build from file:// or an app:// scheme. + "file://", + "null", +} + + +def is_origin_allowed(origin: str | None) -> bool: + """True if the WS connection's Origin header is from our app.""" + if origin is None: + # No Origin header = curl / native WS client / MCP subprocess. + # Token check is still required, so allow. + return True + if origin in _ORIGIN_ALLOWLIST_DEV: + return True + # file:// origins in Electron prod sometimes include paths like + # file:///Applications/OpenSwarm.app/... — match by prefix. + if origin.startswith("file://"): + return True + # localhost + any port (dev servers, tools the developer is running). + if origin.startswith("http://localhost:") or origin.startswith("http://127.0.0.1:"): + return True + return False diff --git a/backend/config/paths.py b/backend/config/paths.py index 2ffecb54..63024e14 100644 --- a/backend/config/paths.py +++ b/backend/config/paths.py @@ -35,4 +35,11 @@ SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace") DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout") BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json") +# Per-install auth token for the localhost WS + HTTP API. Regenerated +# every backend start. Only code running as the current OS user (Electron +# main process, our Python MCP subprocesses, the Claude Code CLI we +# spawn) can read this file. Webpages loaded in any browser on the +# machine cannot — which is the whole point. See auth.py. +AUTH_TOKEN_FILE = os.path.join(DATA_ROOT, "auth.token") + BACKEND_DIR = _BACKEND_DIR diff --git a/backend/main.py b/backend/main.py index ccc00831..b6623ab6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -48,28 +48,90 @@ import json main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription, 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, + is_path_exempt, + request_matches_token, + is_origin_allowed, +) +init_auth_token() + + +# 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=["*"], + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + ], + allow_origin_regex=r"^(file://.*|http://localhost:\d+|http://127\.0\.0\.1:\d+)$", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Chrome's Private Network Access check: a page on https://api.openswarm.com -# POSTing to http://127.0.0.1:8324 triggers a preflight that requires this -# header. Without it the request is blocked and the post-checkout activation -# flow silently fails. Harmless on every other request — it's only read when -# the browser is crossing from a public origin into a private network. @app.middleware("http") -async def _allow_private_network(request, call_next): - response = await call_next(request) - response.headers["Access-Control-Allow-Private-Network"] = "true" +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") + auth_ok = request_matches_token(headers) + if not auth_ok and x_api_key: + import secrets as _s + from backend.auth import get_auth_token as _gt + auth_ok = _s.compare_digest(x_api_key, _gt() or "\x00") + if not auth_ok: + logger.warning( + f"auth: rejecting {request.method} {request.url.path} " + f"(origin={headers.get('origin', '-')}, no valid token)" + ) + return JSONResponse( + {"error": "unauthorized", "detail": "missing or invalid token"}, + status_code=401, + ) + response = await call_next(request) + + # Private-Network-Access header for the one remaining public-origin + # path (OAuth callback). Harmless on other requests. + response.headers.setdefault("Access-Control-Allow-Private-Network", "true") return response @app.websocket("/ws/agents/{session_id}") async def websocket_session(websocket: WebSocket, session_id: str): + if not _ws_auth_ok(websocket): + return await ws_manager.connect_session(session_id, websocket) try: while True: @@ -108,8 +170,32 @@ async def websocket_session(websocket: WebSocket, session_id: str): except WebSocketDisconnect: ws_manager.disconnect_session(session_id, websocket) +def _ws_auth_ok(websocket: WebSocket) -> bool: + """Validate token + origin before accepting a WS. Returns True if OK. + + On failure closes with 4401 (custom app-level code) and returns False + — the caller must NOT call `websocket.accept()` or read any data. + """ + headers = dict(websocket.headers) + qp = dict(websocket.query_params) + origin = headers.get("origin") or headers.get("Origin") + token_ok = request_matches_token(headers, query_params=qp) + origin_ok = is_origin_allowed(origin) + if not (token_ok and origin_ok): + reason = "bad token" if not token_ok else f"bad origin ({origin})" + logger.warning(f"ws: rejecting connection to {websocket.url.path} — {reason}") + # Can't `await websocket.close()` before accept(), so schedule the + # close in a task. The client receives a 403 on handshake. + import asyncio as _asyncio + _asyncio.create_task(websocket.close(code=4401)) + return False + return True + + @app.websocket("/ws/dashboard") async def websocket_dashboard(websocket: WebSocket): + if not _ws_auth_ok(websocket): + return await ws_manager.connect_global(websocket) try: while True: diff --git a/electron/main.js b/electron/main.js index a526c59f..7504aa17 100644 --- a/electron/main.js +++ b/electron/main.js @@ -271,6 +271,58 @@ async function startBackend() { await waitForBackend(backendPort); console.log(`Backend ready on port ${backendPort}`); + + // Backend writes a per-install auth token file at startup. Read it + // here so the renderer can include it in WS URLs (`?token=...`) and + // HTTP Authorization headers. Without this, any webpage loaded in + // any browser on the machine could hit our localhost API and + // impersonate the user. See backend/auth.py. + await loadAuthToken(); +} + +// Per-install auth token read from /auth.token (backend +// generates this at startup). Cached here so `get-auth-token` IPC +// calls are fast. If reads fail initially (race with backend) we +// retry a few times. +let authToken = ''; + +function getAuthTokenFilePath() { + // Mirrors backend/config/paths.py. On macOS the file lives at + // ~/Library/Application Support/OpenSwarm/data/auth.token; on + // Windows under %APPDATA%/OpenSwarm/data/; on Linux under + // ~/.local/share/OpenSwarm/data/. In dev the backend writes it to + // backend/data/auth.token instead. + if (isPackaged) { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'OpenSwarm', 'data', 'auth.token'); + } else if (process.platform === 'win32') { + return path.join(process.env.APPDATA || os.homedir(), 'OpenSwarm', 'data', 'auth.token'); + } else { + const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(xdg, 'OpenSwarm', 'data', 'auth.token'); + } + } + // Dev: backend/data/auth.token relative to repo root. + return path.join(__dirname, '..', 'backend', 'data', 'auth.token'); +} + +async function loadAuthToken() { + const tokenPath = getAuthTokenFilePath(); + // Retry up to 20 × 100ms = 2s in case backend is still writing the + // file. Backend writes BEFORE binding HTTP port though, so this + // usually returns on the first attempt. + for (let attempt = 0; attempt < 20; attempt++) { + try { + const contents = fs.readFileSync(tokenPath, 'utf8').trim(); + if (contents) { + authToken = contents; + console.log(`[auth] loaded token from ${tokenPath}`); + return; + } + } catch (_) {} + await new Promise(r => setTimeout(r, 100)); + } + console.warn(`[auth] FAILED to load auth token from ${tokenPath} after 2s — WS/HTTP will be rejected`); } function createWindow() { @@ -754,6 +806,18 @@ app.on('activate', () => { }); ipcMain.handle('get-backend-port', () => backendPort); +ipcMain.handle('get-auth-token', () => { + // Re-read the file every time. The backend rotates the token on each + // start, and during dev hot-reload the cached value could go stale + // while the renderer stays alive. Re-reading is cheap (small file, + // OS caches it) and guarantees the renderer never holds a dead token. + try { + const p = getAuthTokenFilePath(); + const current = fs.readFileSync(p, 'utf8').trim(); + if (current) authToken = current; + } catch (_) {} + return authToken; +}); ipcMain.handle('get-app-version', () => app.getVersion()); ipcMain.handle('get-webview-preload-path', () => { return `file://${path.join(__dirname, 'webview-preload.js')}`; diff --git a/electron/preload.js b/electron/preload.js index bb3bd6cb..1f8ff253 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -10,6 +10,16 @@ const { contextBridge, ipcRenderer } = require('electron'); getBackendPort: () => port, getWebviewPreloadPath: () => webviewPreloadPath, + // Per-install auth token required for WS + HTTP calls to the + // localhost backend. Returns a Promise. The renderer should + // await this on startup and include the token on every WS URL + // (`?token=...`) and HTTP request (`Authorization: Bearer ...`). + // We deliberately do NOT expose the token as a plain window global + // or a sync getter — contextBridge + IPC keeps it off the + // renderer's global object so third-party scripts (including any + // code that leaks through ) can't scrape it. + getAuthToken: () => ipcRenderer.invoke('get-auth-token'), + getAppVersion: () => ipcRenderer.invoke('get-app-version'), openExternal: (url) => ipcRenderer.invoke('open-external', url), connectSlack: () => ipcRenderer.invoke('connect-slack'), diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 70816600..cf054836 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,24 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import Main from './app/Main'; +import { ensureAuthToken } from './shared/config'; -const root = document.getElementById('root')!; -createRoot(root).render(
); +// Resolve the per-install auth token from Electron BEFORE first render +// so the very first fetch/WS carries the Authorization header. The +// token IPC is fast (synchronous file read in main process). We bound +// the wait at 3s so a missing Electron bridge (e.g. running the React +// app in a plain browser) doesn't hang forever — in that case +// `getAuthToken()` returns '' and backend calls will 401, which is +// the desired behavior (plain browsers can't be allowed to impersonate +// the user). +async function bootstrap() { + try { + await Promise.race([ + ensureAuthToken(), + new Promise(resolve => setTimeout(resolve, 3000)), + ]); + } catch {} + const root = document.getElementById('root')!; + createRoot(root).render(
); +} +bootstrap(); diff --git a/frontend/src/shared/config.ts b/frontend/src/shared/config.ts index 48854857..9cb8378d 100644 --- a/frontend/src/shared/config.ts +++ b/frontend/src/shared/config.ts @@ -4,3 +4,79 @@ const host = window.location.hostname || 'localhost'; export const API_BASE = `http://${host}:${port}/api`; export const WS_BASE = `ws://${host}:${port}`; export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai'; + +// Per-install auth token. Fetched from Electron's main process via the +// preload contextBridge. We cache it after first resolution so every +// API/WS call is synchronous. On Electron hot-reload the token rotates; +// call `refreshAuthToken()` from a 4401 WS handler to pick up a new +// one without a full page reload. +let _authTokenCache: string = ''; +let _authTokenPromise: Promise | null = null; + +export function getAuthToken(): string { + return _authTokenCache; +} + +export async function refreshAuthToken(): Promise { + const ow = (window as any).openswarm; + if (ow && typeof ow.getAuthToken === 'function') { + try { + const tok = await ow.getAuthToken(); + _authTokenCache = typeof tok === 'string' ? tok : ''; + } catch { + _authTokenCache = ''; + } + } + return _authTokenCache; +} + +// Resolve-once helper: the first call kicks off the IPC request; any +// concurrent calls reuse the same promise. Frontend bootstrap awaits +// this before the first API call so the token is ready. +export function ensureAuthToken(): Promise { + if (_authTokenPromise) return _authTokenPromise; + _authTokenPromise = refreshAuthToken(); + return _authTokenPromise; +} + +// Install a global fetch interceptor so every fetch(API_BASE + ...) +// call site gets the Authorization header without touching each site. +// Covers the analytics, settings, agents, dashboards, etc. fetches. +// Only applies to requests that target our own API_BASE — pass-through +// for every other URL (3rd-party APIs, asset CDNs, etc.). +function _installAuthFetchInterceptor() { + if ((window as any).__OPENSWARM_FETCH_PATCHED__) return; + (window as any).__OPENSWARM_FETCH_PATCHED__ = true; + + const originalFetch = window.fetch.bind(window); + window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + try { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + // Only attach token for our own API. Everything else flows through. + const isOurApi = url.startsWith(API_BASE) || url.startsWith(`http://${host}:${port}/`); + if (!isOurApi) return originalFetch(input, init); + + // Don't override an explicit Authorization the caller already set. + const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (existingHeaders.has('Authorization') || existingHeaders.has('authorization')) { + return originalFetch(input, init); + } + + const token = _authTokenCache || (await ensureAuthToken()); + if (!token) return originalFetch(input, init); + + existingHeaders.set('Authorization', `Bearer ${token}`); + const newInit: RequestInit = { ...(init ?? {}), headers: existingHeaders }; + return originalFetch(input, newInit); + } catch { + return originalFetch(input, init); + } + }; +} + +// Call immediately on module load — config.ts is imported by the main +// entry point, so this runs before any component-level fetch. +_installAuthFetchInterceptor(); +// Kick off token resolution in the background so it's warm by the +// time the first request goes out. +ensureAuthToken(); diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index a9b40ae6..d54b96b9 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -17,6 +17,15 @@ import { trackAgentNotification, } from '../state/agentsSlice'; import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { getAuthToken } from '../config'; + +// Thin wrapper around getAuthToken so the connect() call site stays +// synchronous. If the token isn't cached yet, returns '' and the WS +// handshake will 4401 — onclose catches that and refreshes the token +// before the next reconnect. +const _getAuthTokenSafe = (): string => { + try { return getAuthToken() || ''; } catch { return ''; } +}; type WSEvent = { event: string; @@ -104,7 +113,19 @@ class WebSocketManager { connect() { if (this.ws?.readyState === WebSocket.OPEN) return; - this.ws = new WebSocket(this.url); + // Append our per-install auth token to the URL. The backend's WS + // handshake validates this before accepting; without it, any + // webpage loaded on the same machine could open a WS and read + // agent traffic. See backend/auth.py + main.py:_ws_auth_ok. + // Token is fetched async from Electron's preload, but we cache it + // after first resolution. If it isn't cached yet, `getAuthToken()` + // returns '' and the connection will be rejected — the + // onclose handler below retries, by which time the token is + // usually loaded. + const token = _getAuthTokenSafe(); + const sep = this.url.includes('?') ? '&' : '?'; + const urlWithToken = token ? `${this.url}${sep}token=${encodeURIComponent(token)}` : this.url; + this.ws = new WebSocket(urlWithToken); this.ws.onopen = () => { this.reconnectDelay = 1000; @@ -119,7 +140,13 @@ class WebSocketManager { } }; - this.ws.onclose = () => { + this.ws.onclose = (ev) => { + // 4401 = our backend's auth-failure code. Happens on stale token + // after backend restart (dev hot-reload). Re-fetch from Electron + // IPC before retrying. + if (ev && ev.code === 4401) { + import('@/shared/config').then(mod => mod.refreshAuthToken().catch(() => {})); + } this.scheduleReconnect(); };