[eric] security: lock down the local websocket + http api with a per-install auth token

This commit is contained in:
ciregenz
2026-04-25 02:26:13 -07:00
parent f5cc3a36fb
commit 2379dd92be
13 changed files with 595 additions and 32 deletions
+70 -16
View File
@@ -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
+9
View File
@@ -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=<our_token>` 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)
@@ -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:
@@ -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:
+5 -1
View File
@@ -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:
+200
View File
@@ -0,0 +1,200 @@
"""Per-install auth token for the localhost API.
OpenSwarm's backend runs a FastAPI server on `127.0.0.1:<random-port>`
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 `<DATA_ROOT>/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 <token>`."""
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 <token>`
- `x-openswarm-token: <token>` (custom header for callers that
can't easily set Authorization — e.g. future CLI clients)
- `?token=<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
+7
View File
@@ -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
+95 -9
View File
@@ -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 <token>` OR
`x-openswarm-token: <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=<our_token>` 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:
+64
View File
@@ -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 <data-root>/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')}`;
+10
View File
@@ -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<string>. 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 <webview>) 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'),
+20 -2
View File
@@ -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(<Main />);
// 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(<Main />);
}
bootstrap();
+76
View File
@@ -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<string> | null = null;
export function getAuthToken(): string {
return _authTokenCache;
}
export async function refreshAuthToken(): Promise<string> {
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<string> {
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<Response> {
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();
+29 -2
View File
@@ -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();
};