[eric] 1.0.26 windows-only: fix subscription OAuth + perf pass

Connecting→Connect mid-flow on Windows: 9router callback hardcoded
  localhost:8324 (dies when backend lands on 8325+)
This commit is contained in:
Eric
2026-04-22 16:33:00 -07:00
parent ca9bb06b23
commit 744a14dc47
12 changed files with 292 additions and 62 deletions
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// Exposes the OpenSwarm backend's dynamic port to the in-popup callback page
// at /callback. The Python backend picks its port via
// getPort.makeRange(8324, 8424) and exports it as OPENSWARM_PORT when it
// spawns the 9Router subprocess, so we just surface that here.
//
// Needed because the callback page used to hardcode localhost:8324 in its
// "direct exchange" fallback (Method 4 in page.js). On Windows, 8324 is
// frequently held by other services, so the backend lands on 8325+ and the
// hardcoded URL 404s — breaking subscription connect for anyone whose
// postMessage path also fails (common on Windows due to COOP / popup-opener
// quirks). Fetching this config first makes Method 4 work regardless of
// which port the backend ended up on.
export async function GET() {
const raw = process.env.OPENSWARM_PORT;
const port = raw ? parseInt(raw, 10) : 8324;
return NextResponse.json(
{ backendPort: Number.isFinite(port) && port > 0 ? port : 8324 },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
},
},
);
}
+48 -19
View File
@@ -24,13 +24,10 @@ function CallbackContent() {
fullUrl: window.location.href,
};
let relayed = false;
// Method 1: postMessage to opener (popup mode)
if (window.opener) {
try {
window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*");
relayed = true;
} catch (e) {
console.log("postMessage failed:", e);
}
@@ -41,7 +38,6 @@ function CallbackContent() {
const channel = new BroadcastChannel("oauth_callback");
channel.postMessage(callbackData);
channel.close();
relayed = true;
} catch (e) {
console.log("BroadcastChannel failed:", e);
}
@@ -49,17 +45,48 @@ function CallbackContent() {
// Method 3: localStorage event (fallback)
try {
localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() }));
relayed = true;
} catch (e) {
console.log("localStorage failed:", e);
}
// Method 4: Direct exchange via OpenSwarm backend (works even when postMessage fails)
// Fetch pending OAuth data from OpenSwarm, then call 9Router's exchange endpoint
if (!(code || error)) {
setTimeout(() => setStatus("manual"), 0);
return;
}
setStatus("success");
// Method 4: Direct exchange via OpenSwarm backend. This is the path that
// keeps the flow working when Method 1 silently fails (COOP severs
// window.opener on Windows / newer Chromium, or Windows Defender blocks
// cross-context postMessage from the popup back to the Electron parent).
//
// Two changes vs. the original implementation:
// 1. Discover the backend port dynamically from /api/openswarm-config
// (falling back to 8324). The backend lives on a dynamic port picked
// from 8324-8424, and hardcoding 8324 broke Windows users whose
// machines held that port.
// 2. Defer window.close() until after the exchange completes. The old
// 1.5s unconditional close cancelled the in-flight exchange whenever
// token exchange with the upstream provider ran >1.5s (common on
// slow networks), leaving 9Router with no connection saved.
if (code && state) {
(async () => {
let backendPort = 8324;
try {
const pendingRes = await fetch(`http://localhost:8324/api/subscriptions/pending/${encodeURIComponent(state)}`);
const cfgRes = await fetch("/api/openswarm-config", { cache: "no-store" });
if (cfgRes.ok) {
const cfg = await cfgRes.json();
if (cfg && typeof cfg.backendPort === "number") backendPort = cfg.backendPort;
}
} catch (e) {
console.log("config fetch failed, using 8324:", e);
}
try {
const pendingRes = await fetch(
`http://localhost:${backendPort}/api/subscriptions/pending/${encodeURIComponent(state)}`,
);
if (pendingRes.ok) {
const pending = await pendingRes.json();
if (pending.provider && pending.code_verifier) {
@@ -81,19 +108,21 @@ function CallbackContent() {
} catch (e) {
console.log("Direct exchange fallback failed:", e);
}
// Small grace period so any postMessage listeners on the parent
// get their turn before the renderer is torn down.
setTimeout(() => {
window.close();
setTimeout(() => setStatus("done"), 500);
}, 500);
})();
} else {
// Error-only callbacks (no code) — nothing to exchange, close quickly.
setTimeout(() => {
window.close();
setTimeout(() => setStatus("done"), 500);
}, 1500);
}
if (!(code || error)) {
setTimeout(() => setStatus("manual"), 0);
return;
}
setStatus("success");
setTimeout(() => {
window.close();
setTimeout(() => setStatus("done"), 500);
}, 1500);
}, [searchParams]);
return (
+56
View File
@@ -330,6 +330,15 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
closes itself in a background task. Safe to call even if 1455 is busy —
logs the collision and returns None so start_oauth can still proceed and
surface whatever error OpenAI returns.
Also performs the OAuth exchange server-side before serving the HTML.
Relying on the frontend's postMessage path alone breaks on Windows where
COOP / popup-opener quirks silently drop the message, leaving the user
stuck on "Connecting…" until the 30s timeout fires. Exchanging here
(the same pattern backend/main.py uses for the Gemini callback) makes
the connection land in 9Router's DB regardless of whether the UI's
postMessage listener ever gets notified — the Settings / OnboardingModal
status pollers then pick it up within a couple seconds.
"""
callback_served = asyncio.Event()
@@ -352,6 +361,53 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base
method = parts[0] if parts else ""
if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH):
# Parse code/state out of the query string and exchange
# server-side before serving the HTML. Duplicate exchanges
# are harmless (single-use auth codes fail the second call,
# which we swallow) so racing with the frontend's
# msgHandler-driven exchange is fine.
try:
from urllib.parse import urlparse, parse_qs
parsed = urlparse(path)
q = parse_qs(parsed.query)
code = (q.get("code") or [""])[0]
state = (q.get("state") or [""])[0]
if code and state:
try:
from backend.main import _pending_oauth, _mark_oauth_completed
except Exception:
_pending_oauth = None
_mark_oauth_completed = None
if _pending_oauth is not None:
pending = _pending_oauth.pop(state, None)
if pending:
try:
await exchange_oauth(
pending["provider"],
code,
pending["redirect_uri"],
pending["code_verifier"],
state,
)
if _mark_oauth_completed is not None:
_mark_oauth_completed(state)
logger.info(
f"Codex callback: server-side exchange succeeded for state {state[:8]}..."
)
except Exception as e:
# Put the pending entry back so the
# frontend's msgHandler retry via
# /agents/subscriptions/exchange still
# has a shot. Safe because we only popped
# it a moment ago.
_pending_oauth[state] = pending
logger.debug(
f"Codex callback: server-side exchange failed ({e}); leaving for frontend retry"
)
except Exception as e:
logger.debug(f"Codex callback listener pre-exchange error: {e}")
body = _CODEX_CALLBACK_HTML
response = (
b"HTTP/1.1 200 OK\r\n"
+62 -11
View File
@@ -1,6 +1,9 @@
import asyncio
import json
import os
import tempfile
import threading
import time
import logging
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query, UploadFile, File
@@ -54,11 +57,63 @@ def load_settings() -> AppSettings:
return AppSettings()
def _save_settings(settings_obj: AppSettings):
"""Persist settings to JSON file."""
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings_obj.model_dump(), f, indent=2)
# Single threading.Lock guards every write to SETTINGS_FILE — protects against
# corruption from two requests racing through the file system. Async callers
# offload the actual write to the default thread pool (run_in_executor), so
# the lock works for both sync and thread-pool execution paths.
_settings_write_lock = threading.Lock()
def _atomic_write_settings(payload: dict) -> None:
"""Internal: serialise payload to SETTINGS_FILE atomically.
Always called via save_settings* — don't invoke directly."""
with _settings_write_lock:
os.makedirs(DATA_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".settings.", suffix=".tmp", dir=DATA_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
# On Windows, os.replace can transiently fail with PermissionError
# if Defender or another reader holds the destination open. One
# retry after a short backoff handles every real-world case
# without masking genuine permission bugs.
for attempt in range(2):
try:
os.replace(tmp, SETTINGS_FILE)
return
except PermissionError:
if attempt == 1:
raise
time.sleep(0.05)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def save_settings(settings_obj: AppSettings) -> None:
"""Synchronously persist settings atomically. Thread-safe.
Use from sync paths (analytics collector, lifespans). Async callers should
prefer save_settings_async to avoid blocking the event loop on Windows
where Defender scans can stretch the write to 50-200ms."""
_atomic_write_settings(settings_obj.model_dump())
async def save_settings_async(settings_obj: AppSettings) -> None:
"""Async-safe atomic save. Runs the file I/O in the default thread pool
so the FastAPI event loop stays responsive while the write completes.
Shares the threading.Lock with the sync variant for safe interleaving."""
payload = settings_obj.model_dump()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _atomic_write_settings, payload)
# Backward-compat alias. Existing sync callers (analytics collector, analytics
# lifespan) continue to work; new async callers should use save_settings_async.
def _save_settings(settings_obj: AppSettings) -> None:
save_settings(settings_obj)
@settings.router.get("")
@@ -117,9 +172,7 @@ async def update_settings(body: AppSettings):
if id_props:
_identify(id_props)
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(body.model_dump(), f, indent=2)
await save_settings_async(body)
return {"ok": True, "settings": body.model_dump()}
@@ -132,9 +185,7 @@ async def get_default_system_prompt():
async def reset_system_prompt():
current = load_settings()
current.default_system_prompt = DEFAULT_SYSTEM_PROMPT
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(current.model_dump(), f, indent=2)
await save_settings_async(current)
return {"ok": True, "settings": current.model_dump()}
+9 -17
View File
@@ -14,7 +14,7 @@ from pydantic import BaseModel
from backend.config.Apps import SubApp
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
from backend.apps.settings.settings import SETTINGS_FILE, load_settings
from backend.apps.settings.settings import SETTINGS_FILE, load_settings, save_settings_async
logger = logging.getLogger(__name__)
@@ -36,15 +36,7 @@ def _proxy_url() -> str:
return url.rstrip("/")
def _write_settings(settings_obj) -> None:
"""Persist AppSettings to disk. Mirrors backend/apps/settings/settings.py
_save_settings to avoid importing a private module member."""
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings_obj.model_dump(), f, indent=2)
def _clear_subscription(settings_obj) -> None:
async def _clear_subscription(settings_obj) -> None:
"""Revert to own_key mode and drop all OpenSwarm Pro state. Used by the
explicit /disconnect endpoint and by /status when the cloud reports the
bearer as revoked (401) or the subscription as past its grace period
@@ -55,7 +47,7 @@ def _clear_subscription(settings_obj) -> None:
settings_obj.openswarm_subscription_plan = None
settings_obj.openswarm_subscription_expires = None
settings_obj.openswarm_usage_cached = None
_write_settings(settings_obj)
await save_settings_async(settings_obj)
_sync_subscription_identity(settings_obj)
@@ -153,7 +145,7 @@ async def activate(body: ActivateRequest):
if isinstance(usage, dict):
settings_obj.openswarm_usage_cached = usage
_write_settings(settings_obj)
await save_settings_async(settings_obj)
_sync_subscription_identity(settings_obj)
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
@@ -198,7 +190,7 @@ async def status():
# Update cache for offline display.
if isinstance(live_usage, dict):
settings_obj.openswarm_usage_cached = live_usage
_write_settings(settings_obj)
await save_settings_async(settings_obj)
except httpx.HTTPError as e:
logger.debug("subscription/status live fetch failed: %s", e)
@@ -207,7 +199,7 @@ async def status():
# through a dead subscription. Settings UI sees connected=False and
# falls back to the Subscribe CTA; chat reverts to own_key routing.
if upstream_code in (401, 402):
_clear_subscription(settings_obj)
await _clear_subscription(settings_obj)
return {
"connected": False,
"connection_mode": "own_key",
@@ -266,7 +258,7 @@ async def sync():
# the bearer is dead or the sub expired, clear local state so the app
# reverts to own_key instead of hammering a useless token.
if r.status_code in (401, 402):
_clear_subscription(settings_obj)
await _clear_subscription(settings_obj)
reason = "revoked" if r.status_code == 401 else "expired"
_record("subscription.sync_ran", {"reason": reason})
return {
@@ -294,7 +286,7 @@ async def sync():
settings_obj.openswarm_subscription_expires = (
datetime.fromtimestamp(period_end_ms / 1000, tz=timezone.utc).isoformat()
)
_write_settings(settings_obj)
await save_settings_async(settings_obj)
_sync_subscription_identity(settings_obj)
_record("subscription.sync_ran", {
"reason": "ok",
@@ -343,5 +335,5 @@ async def disconnect():
"""Clears local bearer + reverts to own_key mode. Does NOT cancel the
Stripe subscription (use the portal for that). Useful when a user wants
to temporarily route through their own API key."""
_clear_subscription(load_settings())
await _clear_subscription(load_settings())
return {"ok": True}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.25",
"version": "1.0.26",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
@@ -312,7 +312,17 @@ const OnboardingModal: React.FC = () => {
trackEvent('onboarding.education_started');
};
// 500ms debounce on Next/Back during the video walkthrough. The video
// element remounts on each step (key=walkthroughIdx), and on Windows the
// DirectX decode + Defender file scan delays first-frame by 700-1500ms.
// During that window the new video looks frozen so users click again,
// skipping a step. The debounce drops anything that lands inside the
// perceptual-freeze window so one human click = one step regardless.
const lastWalkthroughClickRef = useRef(0);
const advanceWalkthrough = () => {
const now = Date.now();
if (now - lastWalkthroughClickRef.current < 500) return;
lastWalkthroughClickRef.current = now;
const next = walkthroughIdx + 1;
const currentTitle = EDUCATION_STEPS[walkthroughIdx]?.title;
if (next >= EDUCATION_STEPS.length) {
@@ -326,6 +336,9 @@ const OnboardingModal: React.FC = () => {
};
const backWalkthrough = () => {
const now = Date.now();
if (now - lastWalkthroughClickRef.current < 500) return;
lastWalkthroughClickRef.current = now;
setWalkthroughIdx((i) => Math.max(0, i - 1));
};
@@ -418,7 +431,12 @@ const OnboardingModal: React.FC = () => {
} catch {}
}, 5000);
pollTimerRef.current = timer;
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000);
// 3-minute popup timeout (was 30s). OAuth with 2FA on Windows can
// easily take >30s; the connectedProviders poller above still picks
// up the connection after timeout, but a longer in-flow window
// keeps the "Connecting…" indicator accurate instead of flipping
// back to "Connect →" mid-auth.
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 180000);
} else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
@@ -476,7 +494,7 @@ const OnboardingModal: React.FC = () => {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(null);
}, 30000);
}, 180000);
} else {
setConnecting(null);
+44 -4
View File
@@ -735,10 +735,15 @@ const SubscriptionCards: React.FC = () => {
};
if (!useExternal) window.addEventListener('message', msgHandler);
// Timeout: 30s for popup flow (user finishes auth in a few seconds),
// 5 minutes for external-browser flow (user has to tab-switch, log
// in, consent — takes much longer in practice).
const timeoutMs = useExternal ? 300_000 : 30_000;
// Timeout: 3 minutes for popup flow (was 30s — too short for 2FA /
// slow networks, and on Windows postMessage from the callback popup
// can silently fail due to COOP / opener severing, leaving the only
// exit as this timeout firing mid-flow). 5 minutes for external-
// browser flow (user has to tab-switch, log in, consent — takes
// much longer in practice). The connecting-side poller (see the
// useEffect below `handleDisconnect`) is the authoritative safety
// net — this timeout just bounds the Connecting… indicator.
const timeoutMs = useExternal ? 300_000 : 180_000;
setTimeout(() => {
clearInterval(statusPoller);
setPollTimer(null);
@@ -770,6 +775,41 @@ const SubscriptionCards: React.FC = () => {
}, 500);
};
// Safety-net poller that runs whenever a connect attempt is in flight.
// The handleConnect flow's own statusPoller exits as soon as isActive is
// seen, and its 3-minute timeout unconditionally clears `connecting` —
// but on Windows the OAuth popup's postMessage path can fail silently
// (COOP severs opener, Defender interferes, etc.), so the ONLY way out
// of "Connecting…" becomes that timeout, which flips the card back to
// "Connect" even when the backend exchange succeeded. This separate
// poller watches the same status endpoint every 4s and clears the
// Connecting state the moment 9Router reports the provider isActive,
// whether that's via Method 1 (postMessage → frontend exchange), the
// 9Router callback page's Method 4 (server-side exchange), or the Codex
// listener's new server-side exchange.
useEffect(() => {
if (!connecting) return;
let cancelled = false;
const tick = async () => {
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
const d = await r.json();
if (cancelled) return;
const conns = d?.providers?.connections || [];
if (conns.some((p: any) => p.provider === connecting && (p.isActive || p.testStatus === 'active'))) {
setStatus(d);
setConnecting(null);
setUserCode('');
refreshPickerModels();
}
} catch {}
};
const id = setInterval(tick, 4000);
return () => { cancelled = true; clearInterval(id); };
// refreshPickerModels is stable (no deps), fetchStatus isn't used here
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connecting]);
if (!status) {
// Initial loading — show skeleton cards
return (
File diff suppressed because one or more lines are too long
+20 -6
View File
@@ -103,16 +103,24 @@ function Cleanup-All {
try {
# --- Start backend (NoNewWindow so logs interleave into this terminal) ---
# No --reload on Windows: uvicorn's reload mode forces use_subprocess=True
# which pins the worker to WindowsSelectorEventLoop. That loop raises
# NotImplementedError on asyncio.create_subprocess_exec — and the Claude
# Agent SDK uses exactly that to spawn the `claude` CLI, so sending a
# chat message crashes with "Failed to start Claude Code" under --reload.
# Mac doesn't hit it (no Proactor/Selector split). Packaged Windows doesn't
# hit it either (electron/main.js launches uvicorn without --reload).
# Tradeoff: no backend hot-reload in dev on Windows — Ctrl+C and re-run
# `.\run.ps1` after backend code changes. Frontend hot-reload is
# unaffected (webpack-dev-server handles its own watching).
Write-Host ""
Write-Host "[backend] Starting uvicorn --reload on http://localhost:8324 ..." -ForegroundColor Blue
Write-Host "[backend] Starting uvicorn on http://localhost:8324 ..." -ForegroundColor Blue
$backend = Start-Process -PassThru -NoNewWindow `
-FilePath $VenvPy `
-WorkingDirectory $ScriptDir `
-ArgumentList @(
'-m', 'uvicorn', 'backend.main:app',
'--host', '0.0.0.0', '--port', '8324', '--reload',
'--reload-dir', (Join-Path $ScriptDir 'backend'),
'--reload-exclude', '*.pyc'
'--host', '127.0.0.1', '--port', '8324'
)
[void]$script:childPids.Add(@{ Pid = $backend.Id; Label = 'backend' })
@@ -122,7 +130,12 @@ try {
while ((Get-Date) -lt $deadline) {
if ($backend.HasExited) { throw "Backend exited prematurely (code $($backend.ExitCode))" }
try {
Invoke-WebRequest -Uri 'http://localhost:8324/api/health/check' -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
# Hit 127.0.0.1 (not `localhost`) so we don't waste time on the
# IPv6 ::1 fallback — uvicorn is IPv4-only. TimeoutSec 5 because
# PS 5.1's first Invoke-WebRequest call pays ~1-2s of .NET
# network-stack warm-up, and Windows Defender adds scan latency
# on the first localhost connect from a new process.
Invoke-WebRequest -Uri 'http://127.0.0.1:8324/api/health/check' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop | Out-Null
$ready = $true
break
} catch {}
@@ -146,7 +159,8 @@ try {
while ((Get-Date) -lt $deadline) {
if ($frontend.HasExited) { throw "Frontend exited prematurely (code $($frontend.ExitCode))" }
try {
Invoke-WebRequest -Uri 'http://localhost:3000/' -UseBasicParsing -TimeoutSec 1 -ErrorAction Stop | Out-Null
# See backend probe above — same 127.0.0.1 + 5s timeout reasoning.
Invoke-WebRequest -Uri 'http://127.0.0.1:3000/' -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop | Out-Null
$ready = $true
break
} catch {}
+1 -1
View File
@@ -206,7 +206,7 @@ function Copy-Excluded($Source, $Dest, $Exclude) {
Copy-Excluded `
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
@{ Dirs = @('__pycache__','.venv','tools'); Files = @('*.pyc') }
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc') }
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
Copy-Excluded `
+1
View File
@@ -172,6 +172,7 @@ rsync -a \
--exclude='__pycache__' --exclude='**/__pycache__' \
--exclude='*.pyc' --exclude='.venv' \
--exclude='data/tools' \
--exclude='tests' --exclude='**/tests' \
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
# Create empty tools directory so the app has a place to write
mkdir -p "$STAGING_DIR/backend/data/tools"