diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 75b035f4..885efebe 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -39,6 +39,33 @@ const _http = require('http'); } catch (_) {} })(); +// 9Router's /callback page is a client-side relay (postMessage/BroadcastChannel/ +// localStorage) that fails when the OAuth flow runs in the user's system browser: +// no opener, different cookie jar. 302 to the backend so the exchange happens +// server-side. Idempotent via _completed_oauth (backend/apps/oauth_state.py) so +// a racing renderer-driven exchange in popup mode dedups. +(function patchOauthCallbackRedirect() { + try { + const http = require('http'); + const origEmit = http.Server.prototype.emit; + http.Server.prototype.emit = function patchedEmit(event, req, res) { + if (event === 'request' && req && res) { + try { + const url = req.url || ''; + if (url.startsWith('/callback?')) { + const backendPort = process.env.OPENSWARM_PORT || '8324'; + const target = 'http://localhost:' + backendPort + '/api/subscriptions/callback' + url.slice('/callback'.length); + res.writeHead(302, { Location: target }); + res.end(); + return true; + } + } catch (_) {} + } + return origEmit.apply(this, arguments); + }; + } catch (_) {} +})(); + const TARGET_HOSTS = new Set(['api.openai.com']); const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1'; diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index cec4a8c6..96746f02 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -432,6 +432,11 @@ async def subscriptions_poll(body: dict): async def subscriptions_exchange(body: dict): """Exchange OAuth code for tokens via 9Router.""" from backend.apps.nine_router import exchange_oauth + from backend.apps.oauth_state import ( + _pending_oauth as pending_oauth, + _completed_oauth as completed_oauth, + _mark_oauth_completed as mark_completed, + ) provider = body.get("provider", "") code = body.get("code", "") redirect_uri = body.get("redirect_uri", "") @@ -444,11 +449,18 @@ async def subscriptions_exchange(body: dict): try: result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state) if result.get("success"): - from backend.apps.service.client import sync as _sync + # Claude races this path against /api/subscriptions/callback (popup + 9router patch + # 302 to backend); dedup so the loser sees the success page, not "Session expired". + if state: + pending_oauth.pop(state, None) + mark_completed(state) + from backend.apps.service.client import sync as do_sync from backend.apps.settings.settings import load_settings - _sync(load_settings().model_dump()) + do_sync(load_settings().model_dump()) return result except Exception as e: + if state and state in completed_oauth: + return {"success": True, "deduped": True} raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index 55b7ba85..efcc5e75 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -205,9 +205,14 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base # own Desktop-app OAuth guidance both prescribe the system browser. # - codex: auth.openai.com renders blank in our popup on some machines (newer # embed detection + regional checks); system browser surfaces the real error. +# - claude: email magic-link opens in the user's default browser, which is a +# different cookie jar from the embedded popup, so the popup can never receive +# the auth. Forcing the OAuth flow into the system browser keeps everything +# in one cookie jar. # The callback for gemini-cli/antigravity lands on /api/subscriptions/callback -# and runs the exchange server-side; codex uses its fixed 1455 listener. -_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"} +# and runs the exchange server-side; codex uses its fixed 1455 listener; claude +# is special-cased in _callback_uri_for_provider below. +_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex", "claude"} def _should_use_external_browser(provider: str) -> bool: @@ -232,18 +237,21 @@ def _callback_uri_for_provider(provider: str) -> str: """Return the redirect URI to pass to 9Router's authorize endpoint. Most providers accept 9Router's built-in callback page at port 20128. - Two special cases: + Special cases: - Codex/OpenAI's OAuth client is bound to a fixed http://localhost:1455/auth/callback URI; handled by _start_codex_callback_listener above. - Gemini/Google's OAuth consent page rejects embedded browsers, so we route the callback through OpenSwarm's backend endpoint at - /api/subscriptions/callback (backend/main.py:138) which runs the - exchange itself. This is the only provider where the callback lands - on OpenSwarm's port rather than 9Router's. + /api/subscriptions/callback (backend/main.py) which runs the + exchange itself. """ if provider == "codex": return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}" + # Anthropic's OAuth client only whitelists localhost:20128/callback; + # 9router_gpt5_patch.js 302-rewrites the hit to the backend handler. + if provider == "claude": + return f"http://localhost:{NINE_ROUTER_PORT}/callback" if provider in _EXTERNAL_BROWSER_PROVIDERS: return f"http://localhost:{_backend_port()}/api/subscriptions/callback" return f"http://localhost:{NINE_ROUTER_PORT}/callback" diff --git a/backend/main.py b/backend/main.py index 42daf1d3..9aa7d69f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -453,7 +453,7 @@ _SUCCESS_HTML = ( '
You can close this window
' + 'You can close this tab, and any other Claude login tab still open.
' '