This window will close automatically...
+ +""" + + +async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base_events.Server | None: + """Spawn a one-shot HTTP listener on 127.0.0.1:1455 for the Codex OAuth callback. + + Serves GET /auth/callback with _CODEX_CALLBACK_HTML. After serving the + callback (or after `timeout` seconds with no callback) the listener + 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. + """ + + callback_served = asyncio.Event() + + async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + try: + # Read the request line ("GET /auth/callback?... HTTP/1.1\r\n") + raw_request_line = await asyncio.wait_for(reader.readline(), timeout=5.0) + request_line = raw_request_line.decode("latin-1", errors="replace").strip() + # Drain headers so the browser's request is fully consumed + while True: + line = await asyncio.wait_for(reader.readline(), timeout=5.0) + if not line or line in (b"\r\n", b"\n"): + break + + # Only respond to the OAuth callback path. Chrome preflights and + # favicon fetches get a 404 so they don't trigger the served-event. + parts = request_line.split(" ") + path = parts[1] if len(parts) >= 2 else "" + method = parts[0] if parts else "" + + if method == "GET" and path.startswith(_CODEX_CALLBACK_PATH): + body = _CODEX_CALLBACK_HTML + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/html; charset=utf-8\r\n" + b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" + b"Cache-Control: no-store\r\n" + b"Connection: close\r\n\r\n" + + body + ) + writer.write(response) + await writer.drain() + callback_served.set() + else: + # Unrelated request (favicon, preflight) — 404 and move on + writer.write( + b"HTTP/1.1 404 Not Found\r\n" + b"Content-Length: 0\r\n" + b"Connection: close\r\n\r\n" + ) + await writer.drain() + except Exception as e: + logger.debug(f"Codex callback listener handler error: {e}") + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + try: + server = await asyncio.start_server(_handle, "127.0.0.1", _CODEX_CALLBACK_PORT) + except OSError as e: + # Port already in use — probably another Codex connect attempt still + # running, or an actual Codex CLI process holding 1455. Log and bail. + logger.warning( + f"Could not start Codex callback listener on port {_CODEX_CALLBACK_PORT}: {e}. " + "If another connection attempt is in progress, wait for it to finish or time out." + ) + return None + + async def _lifecycle(): + try: + await asyncio.wait_for(callback_served.wait(), timeout=timeout) + # Give the served HTML a moment to run its JS (postMessage + + # window.close) before we close the socket. Chromium closes + # the tab on window.close() but the JS needs to run first. + await asyncio.sleep(2.0) + except asyncio.TimeoutError: + logger.info(f"Codex callback listener timed out after {timeout}s") + except Exception as e: + logger.debug(f"Codex callback listener lifecycle error: {e}") + finally: + try: + server.close() + await server.wait_closed() + except Exception: + pass + + asyncio.create_task(_lifecycle()) + logger.info(f"Started Codex callback listener on http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}") + return server + + +# Providers that cannot use the in-Electron `window.open` popup flow and +# must be opened in the user's system browser instead. +# +# Google enforces an "Embedded WebView Restrictions" policy on its OAuth +# consent pages that uses JS-based fingerprinting, not just user-agent +# sniffing. We tried defeating it with a combination of Chrome UA spoof + +# sandboxed webPreferences + fresh session partition + a preload script +# that patches navigator.webdriver/plugins/mimeTypes/languages/chrome and +# overrides navigator.permissions.query — it was still rejected. Google's +# detection is a moving target and actively adversarial. The supported +# workaround (and what Google recommends for Desktop app OAuth) is to run +# the flow in the user's real browser via shell.openExternal. +# +# When a provider is in this set the frontend calls +# window.openswarm.openExternal (shell.openExternal) instead of +# window.open, and the callback lands on OpenSwarm's own +# /api/subscriptions/callback endpoint (backend/main.py:138) which +# exchanges the code and serves a "Connected!" page. Detection on the +# OpenSwarm side happens via the existing status poller on the +# Settings page. +_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli"} + + +def _should_use_external_browser(provider: str) -> bool: + return provider in _EXTERNAL_BROWSER_PROVIDERS + + +def _backend_port() -> int: + """Best-effort lookup of the OpenSwarm backend HTTP port. + + Falls back to 8324 (the default in backend/main.py) if OPENSWARM_PORT + hasn't been set yet. backend/main.py:239 sets this env var at startup + before any request handler runs, so `start_oauth` will always see the + correct value. + """ + try: + return int(os.environ.get("OPENSWARM_PORT", "8324")) + except (TypeError, ValueError): + return 8324 + + +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: + - 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. + """ + if provider == "codex": + return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}" + if provider in _EXTERNAL_BROWSER_PROVIDERS: + return f"http://localhost:{_backend_port()}/api/subscriptions/callback" + return f"http://localhost:{NINE_ROUTER_PORT}/callback" + + async def start_oauth(provider: str) -> dict: """Start OAuth flow for a provider. @@ -267,9 +482,16 @@ async def start_oauth(provider: str) -> dict: except Exception: pass - # Authorization code flow — redirect to 9Router's own callback page - # (Anthropic only accepts redirect URIs registered with 9Router's client ID) - callback_url = f"http://localhost:{NINE_ROUTER_PORT}/callback" + # Authorization code flow. Most providers accept 9Router's own + # callback page at port 20128, but Codex's OAuth client is bound + # to a fixed http://localhost:1455/auth/callback URI — spawn an + # in-process listener on that port before returning the auth URL, + # so the popup can redirect there after login and relay the code + # back to the frontend via postMessage (same flow as Claude). + callback_url = _callback_uri_for_provider(provider) + if provider == "codex": + await _start_codex_callback_listener() + r = await client.get( f"{NINE_ROUTER_API}/oauth/{provider}/authorize", params={"redirect_uri": callback_url}, @@ -282,6 +504,7 @@ async def start_oauth(provider: str) -> dict: "code_verifier": data.get("codeVerifier", ""), "state": data.get("state", ""), "redirect_uri": callback_url, + "use_external_browser": _should_use_external_browser(provider), } diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index ff4a15da..3e34da12 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -377,10 +377,20 @@ async def vibe_code(body: VibeCodeRequest): if context_parts: user_message = "\n\n".join(context_parts) + "\n\nUser request: " + body.prompt + from backend.apps.agents.providers.registry import resolve_aux_model + try: + aux_model, _aux_base = await resolve_aux_model(load_settings(), preferred_tier="sonnet") + except ValueError as e: + return { + "message": f"Error: {str(e)}", + "frontend_code": body.current_frontend_code, + "backend_code": body.current_backend_code, + "input_schema": body.current_schema, + } client = _get_anthropic_client() try: resp = await client.messages.create( - model="claude-sonnet-4-20250514", + model=aux_model, max_tokens=8000, system=VIBE_CODE_SYSTEM_PROMPT, messages=[{"role": "user", "content": user_message}], @@ -438,7 +448,24 @@ async def auto_run_output(body: AutoRunRequest): schema_str = json.dumps(body.input_schema, indent=2) user_message = f"Schema:\n```json\n{schema_str}\n```\n\nGenerate data for: {body.prompt}" - api_model = _resolve_model(body.model) + # Resolve body.model via the registry so non-Anthropic selections are + # routed through 9Router with the correct prefix (cx/, gc/, gh/). + # If body.model is unset or unknown, fall back to whichever aux model + # is available (prefers Claude, else any connected subscription). + from backend.apps.agents.providers.registry import ( + _find_builtin_model, + resolve_model_id_for_sdk, + resolve_aux_model, + ) + settings = load_settings() + if body.model and _find_builtin_model(body.model) is not None: + api_model = resolve_model_id_for_sdk(body.model, settings) + else: + try: + api_model, _ = await resolve_aux_model(settings, preferred_tier="haiku") + except ValueError as e: + return {"error": str(e), "input_data": None, "backend_result": None} + client = _get_anthropic_client() try: resp = await client.messages.create( diff --git a/backend/main.py b/backend/main.py index 93157558..ab38896a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,22 @@ from fastapi import Request # In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri}) _pending_oauth: dict[str, dict] = {} +# Recently-completed OAuth states so the /api/subscriptions/callback handler +# can distinguish a legitimate duplicate callback (browser prefetch, refresh, +# or Google redirect retry after a slow first response) from a truly stale +# request. Bounded FIFO — drops the oldest entries once it grows past +# _MAX_COMPLETED_OAUTH so it can't leak memory. +_completed_oauth: list[str] = [] +_MAX_COMPLETED_OAUTH = 64 + + +def _mark_oauth_completed(state: str) -> None: + if state in _completed_oauth: + return + _completed_oauth.append(state) + # Trim head if we've outgrown the bound + while len(_completed_oauth) > _MAX_COMPLETED_OAUTH: + _completed_oauth.pop(0) from backend.config.Apps import MainApp from backend.apps.health.health import health from backend.apps.agents.agents import agents @@ -135,9 +151,30 @@ async def subscriptions_pending(state: str): }, headers={"Access-Control-Allow-Origin": "*"}) +_SUCCESS_HTML = ( + '' + 'You can close this window
' + 'Please try connecting again.
{e}
You can close this window
' - '