From f93d6e02d11b2605f528b8d2eedf21ee7bdb7d69 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 20:27:39 -0700 Subject: [PATCH] [eric] 9router: port the 0.4.x /api auth gate (x-9r-cli-token) to unblock a version bump --- backend/apps/nine_router/__init__.py | 4 + backend/apps/nine_router/oauth.py | 8 +- backend/apps/nine_router/process.py | 131 +++++++++++++++++++----- backend/apps/nine_router/sync.py | 4 +- backend/apps/nine_router/sync_custom.py | 20 ++-- 5 files changed, 123 insertions(+), 44 deletions(-) diff --git a/backend/apps/nine_router/__init__.py b/backend/apps/nine_router/__init__.py index ce818d3d..6e27ee33 100644 --- a/backend/apps/nine_router/__init__.py +++ b/backend/apps/nine_router/__init__.py @@ -24,6 +24,8 @@ from .process import ( NINE_ROUTER_PORT, NINE_ROUTER_URL, NINE_ROUTER_V1, + cli_auth_headers, + cli_auth_token, ensure_running, get_latest_reasoning_tokens, get_providers, @@ -67,6 +69,8 @@ __all__ = [ "NINE_ROUTER_OPENAI_KEYED_PREFIX", "NINE_ROUTER_OPENROUTER_KEYED_NAME", "NINE_ROUTER_CUSTOM_NAME_SUFFIX", + "cli_auth_headers", + "cli_auth_token", "ensure_running", "stop", "is_running", diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index efcc5e75..ebb8d1df 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -10,7 +10,7 @@ import os import httpx -from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1 +from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1, cli_auth_headers from backend.apps.oauth_state import _pending_oauth, _mark_oauth_completed logger = logging.getLogger(__name__) @@ -263,7 +263,7 @@ async def start_oauth(provider: str) -> dict: For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code} For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state} """ - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: try: r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code") if r.status_code == 200: @@ -310,7 +310,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None if extra_data: body["extraData"] = extra_data - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: r = await client.post( f"{NINE_ROUTER_API}/oauth/{provider}/poll", json=body, @@ -321,7 +321,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict: """Exchange OAuth code for tokens via 9Router.""" - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: r = await client.post( f"{NINE_ROUTER_API}/oauth/{provider}/exchange", json={ diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 4117f108..eee3e4a1 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -11,8 +11,10 @@ API at localhost:20128/v1. """ import asyncio +import hashlib import logging import os +import secrets import shutil import subprocess import time @@ -27,34 +29,30 @@ NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}" NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api" NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1" -# Pinned 9router npm package version. Stays at 0.3.60. +# Pinned 9router npm package version. Prod default stays 0.3.60; set +# OPENSWARM_ROUTER_VERSION to stage a bump in dev (keys the dev cache by +# version, so the override pulls a clean install) without shipping it. # -# DO NOT bump to 0.4.x without porting 9Router API auth first. Tested 0.4.66 -# empirically (2026-06-01): it adds an auth gate to its internal /api/* routes, -# so the endpoints our connect/sync flow calls without a token now 401 instead -# of working: -# endpoint 0.3.60 0.4.66 -# /api/oauth//device-code 400 401 Unauthorized -# POST /api/providers 400 401 Unauthorized -# That 401 makes start_oauth() throw, which 500s EVERY subscription connect -# (Claude/Codex/Gemini). oauth.py + sync.py would each need to discover and -# send 9Router 0.4.x's API token on every /api/* call before a bump is viable. +# 0.4.x gates its internal /api/* routes behind auth (the old bump blocker): +# bare `POST /api/providers` / `/api/oauth//device-code` now 401 instead +# of working. That auth is now PORTED here: see cli_auth_token() / cli_auth_headers() +# below, which compute the `x-9r-cli-token` 9Router checks and which every +# /api/* call in this package attaches. The header is empty on 0.3.60 (no +# machine-id file), so the old auth-free path is untouched. # -# What the bump WOULD buy once auth is ported: cc/claude-opus-4-8 and cx/gpt-5.5 -# on the sub routes (gpt-5.5 404s on 0.3.60), and a reworked WebSearch behind a -# new /api/v1/search route. Gemini 3.5 Flash is Antigravity-only there -# (ag/gemini-3.5-flash-low), never on the gc/ Gemini-CLI lane. +# What the bump buys: cc/claude-opus-4-8 and cx/gpt-5.5 on the sub routes +# (gpt-5.5 404s on 0.3.60), a reworked WebSearch behind /api/v1/search, and +# 3 months of cross-provider translator robustness. # -# Original 0.3.60 pin reason (still holds): versions 0.3.60-0.3.96 regressed -# cross-provider WebSearch (a Codex/Gemini primary delegating WebSearch saw -# "claude-haiku-4-5-20251001 unavailable" or hallucinated output). -# -# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens) -# when translating Anthropic->OpenAI, which OpenAI's GPT-5 family rejects. -# The fix lives in our /api/openai-passthrough proxy; see core/openai_passthrough.py -# and sync_openai_api_key for how the translation lane is rerouted via an -# `openai-compatible` provider-node that honors `baseUrl`. -NINE_ROUTER_NPM_VERSION = "0.3.60" +# REMAINING gate before flipping the prod default to 0.4.x: re-qualify +# cross-provider WebSearch. The original 0.3.60 pin reason was that 0.3.60-0.3.96 +# regressed it (a Codex/Gemini primary delegating WebSearch saw +# "claude-haiku-4-5-20251001 unavailable" or hallucinated output); 0.4.x reworked +# it but that's unverified here. Also confirmed on 0.4.80: it STILL emits +# `max_tokens` (not max_completion_tokens) on Anthropic->OpenAI, so our +# /api/openai-passthrough rename (core/openai_passthrough.py + sync_openai_api_key, +# routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary. +NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60") _process: subprocess.Popen | None = None @@ -85,6 +83,83 @@ def is_running() -> bool: return False +def _nine_router_data_dir() -> str: + """Where 9Router persists machine-id + auth/cli-secret, the two files we + hash into the /api/* auth token on 0.4.x. Mirrors 9Router's own default + (DATA_DIR env, else ~/.9router on unix, %APPDATA%/9router on win) so we read + the exact files it writes. We never relocate it: that would orphan a user's + existing subscription connections.""" + env_dir = os.environ.get("DATA_DIR") + if env_dir: + return env_dir + if os.name == "nt": + base = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) + return os.path.join(base, "9router") + return os.path.join(os.path.expanduser("~"), ".9router") + + +_cli_token_cache: str | None = None + + +def cli_auth_token() -> str | None: + """The token 9Router 0.4.x checks in `x-9r-cli-token` on /api/* calls: + sha256(machineId + "9r-cli-auth" + cliSecret)[:16]. machine-id is written + at 9Router boot, cli-secret only lazily on its first self-call, so we create + cli-secret ourselves (atomic O_EXCL, 0600, identical to 9Router's getter) + when missing so connect/sync can auth before that self-call. Returns None on + 0.3.60 (no machine-id) or when 9Router isn't up, so the caller sends no + header and the old auth-free path is untouched. Never raises.""" + global _cli_token_cache + if _cli_token_cache: + return _cli_token_cache + if not is_running(): + return None + try: + data_dir = _nine_router_data_dir() + try: + with open(os.path.join(data_dir, "machine-id"), encoding="utf-8") as f: + machine_id = f.read().strip() + except OSError: + return None # 0.3.60 layout, or 9Router hasn't written it yet + if not machine_id: + return None + secret_path = os.path.join(data_dir, "auth", "cli-secret") + try: + with open(secret_path, encoding="utf-8") as f: + cli_secret = f.read().strip() + except OSError: + cli_secret = "" + if not cli_secret: + cli_secret = secrets.token_hex(32) + try: + os.makedirs(os.path.dirname(secret_path), exist_ok=True) + # O_EXCL: if 9Router won the race and wrote first, read its value. + fd = os.open(secret_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w") as f: + f.write(cli_secret) + except FileExistsError: + with open(secret_path, encoding="utf-8") as f: + cli_secret = f.read().strip() + if not cli_secret: + return None + tok = hashlib.sha256( + (machine_id + "9r-cli-auth" + cli_secret).encode("utf-8") + ).hexdigest()[:16] + _cli_token_cache = tok + return tok + except Exception: + return None + + +def cli_auth_headers() -> dict[str, str]: + """`x-9r-cli-token` header for 9Router 0.4.x /api/* calls; empty dict on + 0.3.60 (no token), where the old auth-free endpoints still answer.""" + tok = cli_auth_token() + return {"x-9r-cli-token": tok} if tok else {} + + def _find_9router_dir() -> str | None: """Locate the bundled 9Router directory (works in both dev and packaged mode).""" _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" @@ -365,7 +440,7 @@ def stop(): async def get_usage_stats(period: str = "all") -> dict | None: """Get usage statistics from 9Router.""" try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period}) if r.status_code == 200: return r.json() @@ -391,7 +466,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No if not is_running(): return None try: - async with httpx.AsyncClient(timeout=2.0) as client: + async with httpx.AsyncClient(timeout=2.0, headers=cli_auth_headers()) as client: params: dict[str, Any] = {"page": 1, "pageSize": 5} if model_hint: params["model"] = model_hint @@ -422,7 +497,7 @@ async def get_providers() -> list[dict]: unwrap so callers always see a plain list of connection dicts. """ try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/providers") if r.status_code == 200: data = r.json() diff --git a/backend/apps/nine_router/sync.py b/backend/apps/nine_router/sync.py index fe32ddab..f24053db 100644 --- a/backend/apps/nine_router/sync.py +++ b/backend/apps/nine_router/sync.py @@ -7,7 +7,7 @@ as OpenSwarm-managed apikey connections. Talks to the already-running import logging -from .process import NINE_ROUTER_API +from .process import NINE_ROUTER_API, cli_auth_headers logger = logging.getLogger(__name__) @@ -64,7 +64,7 @@ async def _sync_apikey_provider( existing = await _find_keyed_connection(provider, name) try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if api_key: payload = { "provider": provider, diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py index 7696270c..d6439f3f 100644 --- a/backend/apps/nine_router/sync_custom.py +++ b/backend/apps/nine_router/sync_custom.py @@ -10,7 +10,7 @@ spawns the subprocess (that's process.py's job). import logging -from .process import NINE_ROUTER_API +from .process import NINE_ROUTER_API, cli_auth_headers from .sync import ( NINE_ROUTER_CLAUDE_PRO_NAME, NINE_ROUTER_OPENAI_KEYED_PREFIX, @@ -36,7 +36,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] except Exception as e: @@ -50,7 +50,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: if not api_key: if existing_node: try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}") logger.info("9Router: removed OpenAI compat node (key cleared)") except Exception as e: @@ -66,7 +66,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: } node_id: str | None = existing_node.get("id") if existing_node else None try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_node: await client.put( f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}", @@ -100,7 +100,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: "apiKey": api_key, "priority": 0, } - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_conn: await client.patch( f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", @@ -161,7 +161,7 @@ async def sync_custom_providers(providers: list) -> None: return try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] except Exception as e: @@ -201,7 +201,7 @@ async def sync_custom_providers(providers: list) -> None: "type": "openai-compatible", } try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if node: await client.put( f"{NINE_ROUTER_API}/provider-nodes/{node['id']}", @@ -236,7 +236,7 @@ async def sync_custom_providers(providers: list) -> None: "apiKey": api_key, "priority": 0, } - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_conn: await client.patch( f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", @@ -259,7 +259,7 @@ async def sync_custom_providers(providers: list) -> None: if prefix in seen_prefixes: continue try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{node['id']}") logger.info(f"9Router: removed orphaned custom node {prefix}") except Exception as e: @@ -288,7 +288,7 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str # is the direct-API id. Use `anthropic`. existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME) try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if bearer_token and proxy_url: payload = { "provider": "anthropic",