diff --git a/api/oauth.py b/api/oauth.py index 4cf4f180..63eb1ed2 100644 --- a/api/oauth.py +++ b/api/oauth.py @@ -1,196 +1,428 @@ -"""In-app OAuth flow implementations for providers like OpenAI Codex. +"""In-app OAuth flow implementations for onboarding. -Uses only stdlib (urllib.request, json, time) — no external dependencies. -Credentials are stored in ~/.hermes/auth.json under the credential_pool. +The browser receives only WebUI-local flow metadata (flow_id, user_code, +verification_uri, high-level status). Provider device/auth codes and OAuth +tokens stay server-side and are persisted to the active Hermes profile's +``auth.json`` credential_pool. """ +from __future__ import annotations + import json import logging +import os +import stat +import threading import time import uuid -import urllib.request -import urllib.parse import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone from pathlib import Path +from typing import Any logger = logging.getLogger(__name__) +# Compatibility for older helper tests and self-heal code that import these. AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json" -# ── Codex OAuth constants (from hermes_cli/auth.py) ── -CODEX_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh" -CODEX_AUTH_URL = "https://auth.openai.com/oauth/device/authorize" -CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" -CODEX_SCOPE = "openid profile email offline_access" -CODEX_GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code" +CODEX_ISSUER = "https://auth.openai.com" +CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CODEX_VERIFICATION_URI = f"{CODEX_ISSUER}/codex/device" +CODEX_USER_CODE_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/usercode" +CODEX_DEVICE_TOKEN_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/token" +CODEX_TOKEN_URL = f"{CODEX_ISSUER}/oauth/token" +CODEX_REDIRECT_URI = f"{CODEX_ISSUER}/deviceauth/callback" +CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +CODEX_FLOW_MAX_WAIT_SECONDS = 15 * 60 + +_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"} +_REJECTED_ONBOARDING_OAUTH_PROVIDERS = { + "anthropic", + "claude", + "claude-code", + "nous", + "qwen-oauth", + "gemini-cli", + "google-gemini-cli", + "minimax", + "minimax-oauth", + "copilot", + "copilot-acp", +} + +_OAUTH_FLOWS: dict[str, dict[str, Any]] = {} +_OAUTH_FLOWS_LOCK = threading.Lock() -# ── auth.json helpers ── +def _get_active_hermes_home() -> Path: + try: + from api.profiles import get_active_hermes_home -def _read_auth_json(): - """Read auth.json and return parsed dict, or empty dict.""" - if AUTH_JSON_PATH.exists(): + return Path(get_active_hermes_home()) + except Exception: + return Path.home() / ".hermes" + + +# ── legacy auth.json helpers ──────────────────────────────────────────────── + +def _read_auth_json(auth_path: Path | None = None) -> dict[str, Any]: + """Read auth.json and return parsed dict, or an empty compatible store.""" + path = auth_path or AUTH_JSON_PATH + if path.exists(): try: - return json.loads(AUTH_JSON_PATH.read_text()) + loaded = json.loads(path.read_text(encoding="utf-8")) + return loaded if isinstance(loaded, dict) else {} except json.JSONDecodeError as exc: - logger.warning("Failed to parse %s: %s", AUTH_JSON_PATH, exc) + logger.warning("Failed to parse %s: %s", path, exc) return {} return {} def read_auth_json(): - """Public wrapper for _read_auth_json. - - Used by the streaming layer's credential self-heal logic (#1401) to - re-read credentials without coupling to the private helper. - """ + """Public wrapper for streaming credential self-heal code.""" return _read_auth_json() -def _write_auth_json(data): - """Atomically write auth.json via temp-file rename. +def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Path: + """Atomically write auth.json with owner-only permissions. - SECURITY: auth.json contains OAuth access/refresh tokens. ``tmp.replace()`` - preserves the temp file's mode (created with the process umask, typically - 0644 or 0664), NOT the prior auth.json mode. Without an explicit chmod, - tokens land world-readable on shared systems. Set 0600 BEFORE the rename - so there is no window where the final file is world-readable. - (Opus pre-release advisor finding.) + OAuth access/refresh tokens live in this file. The temp file is chmod 0600 + before rename so the final path never inherits a permissive process umask. """ - import os, stat - AUTH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = AUTH_JSON_PATH.with_suffix('.tmp') - tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + path = auth_path or AUTH_JSON_PATH + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: - tmp.chmod(0o600) - except OSError as e: - # Best-effort: if chmod fails (e.g. on a filesystem that doesn't - # support POSIX modes), don't abort. The startup permission fixer - # in api.startup will sweep auth.json on the next process start. - logger.warning("Failed to chmod 0600 on %s: %s", tmp, e) - tmp.replace(AUTH_JSON_PATH) + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + try: + tmp.chmod(0o600) + except OSError as exc: + logger.warning("Failed to chmod 0600 on %s: %s", tmp, exc) + tmp.replace(path) + try: + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + return path + finally: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass -# ── Codex device-code flow ── +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") -def start_codex_device_code(): - """Start Codex OAuth device-code flow. - Returns dict: { device_code, user_code, verification_uri, expires_in, interval } - Raises RuntimeError on network error. - """ - params = { - "client_id": CODEX_CLIENT_ID, - "scope": CODEX_SCOPE, +def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> Path: + """Persist Codex OAuth credentials to active-profile auth.json.""" + access_token = str(token_data.get("access_token") or "").strip() + refresh_token = str(token_data.get("refresh_token") or "").strip() + if not access_token: + raise RuntimeError("Codex token exchange did not return an access_token") + + auth_path = Path(hermes_home) / "auth.json" + auth = _read_auth_json(auth_path) + auth.setdefault("version", 1) + pool = auth.setdefault("credential_pool", {}) + if not isinstance(pool, dict): + pool = {} + auth["credential_pool"] = pool + entries = pool.setdefault("openai-codex", []) + if not isinstance(entries, list): + entries = [] + pool["openai-codex"] = entries + + now = _now_iso() + entry = None + for candidate in entries: + if isinstance(candidate, dict) and candidate.get("source") == "manual:device_code": + entry = candidate + break + if entry is None: + entry = { + "id": "codex-oauth-" + uuid.uuid4().hex[:12], + "label": "Codex OAuth", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "base_url": CODEX_BASE_URL, + "created_at": now, + } + entries.insert(0, entry) + + entry.update( + { + "label": "Codex OAuth", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": access_token, + "refresh_token": refresh_token, + "base_url": CODEX_BASE_URL, + "last_refresh": now, + "updated_at": now, + } + ) + auth["updated_at"] = now + path = _write_auth_json(auth, auth_path) + + try: + from api.config import invalidate_credential_pool_cache + + invalidate_credential_pool_cache("openai-codex") + except Exception: + logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True) + + return path + + +# Backward-compatible wrapper used by older code/tests. +def _save_codex_credentials(token_data): + return _persist_codex_credentials(_get_active_hermes_home(), token_data) + + +# ── Codex protocol ────────────────────────────────────────────────────────── + +def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]: + if form: + data = urllib.parse.urlencode(payload).encode("utf-8") + content_type = "application/x-www-form-urlencoded" + else: + data = json.dumps(payload).encode("utf-8") + content_type = "application/json" + req = urllib.request.Request( + url, + data=data, + method="POST", + headers={"Content-Type": content_type, "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _request_codex_user_code() -> dict[str, Any]: + return _json_request(CODEX_USER_CODE_URL, {"client_id": CODEX_CLIENT_ID}) + + +def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str, Any] | None: + try: + return _json_request( + CODEX_DEVICE_TOKEN_URL, + {"device_auth_id": device_auth_id, "user_code": user_code}, + ) + except urllib.error.HTTPError as exc: + if exc.code in (403, 404): + return None + raise + + +def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -> dict[str, Any]: + return _json_request( + CODEX_TOKEN_URL, + { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CODEX_REDIRECT_URI, + "client_id": CODEX_CLIENT_ID, + "code_verifier": code_verifier, + }, + form=True, + ) + + +def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]: + return { + "ok": True, + "provider": "openai-codex", + "flow_id": flow_id, + "status": flow.get("status", "pending"), + "verification_uri": CODEX_VERIFICATION_URI, + "user_code": flow.get("user_code", ""), + "expires_at": flow.get("expires_at"), + "poll_interval_seconds": flow.get("poll_interval_seconds", 5), } - data = urllib.parse.urlencode(params).encode() - req = urllib.request.Request(CODEX_AUTH_URL, data=data, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") + + +def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]: + payload = { + "ok": True, + "provider": "openai-codex", + "flow_id": flow_id, + "status": flow.get("status", "error"), + } + if flow.get("status") == "error" and flow.get("error"): + payload["error"] = str(flow.get("error"))[:200] + return payload + + +def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None: + for key in ( + "device_auth_id", + "authorization_code", + "code_verifier", + "access_token", + "refresh_token", + "token_data", + ): + flow.pop(key, None) + + +def _cleanup_oauth_flows(now: float | None = None) -> None: + now = now or time.time() + cutoff = now - 300 + with _OAUTH_FLOWS_LOCK: + for fid, flow in list(_OAUTH_FLOWS.items()): + status = flow.get("status") + if status == "pending" and float(flow.get("expires_at") or 0) <= now: + flow["status"] = "expired" + _drop_sensitive_flow_fields(flow) + if status in {"success", "expired", "cancelled", "error"} and float(flow.get("updated_at") or 0) < cutoff: + _OAUTH_FLOWS.pop(fid, None) + + +def _spawn_codex_oauth_worker(flow_id: str) -> None: + worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True) + worker.start() + + +def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None: + with _OAUTH_FLOWS_LOCK: + flow = _OAUTH_FLOWS.get(flow_id) + if not flow: + return + flow["status"] = status + flow["updated_at"] = time.time() + flow.update(fields) + if status in {"success", "expired", "cancelled", "error"}: + _drop_sensitive_flow_fields(flow) + + +def _run_codex_oauth_worker(flow_id: str) -> None: + while True: + with _OAUTH_FLOWS_LOCK: + flow = dict(_OAUTH_FLOWS.get(flow_id) or {}) + if not flow: + return + status = flow.get("status") + if status != "pending": + return + if float(flow.get("expires_at") or 0) <= time.time(): + _set_flow_status(flow_id, "expired") + return + + time.sleep(max(1, int(flow.get("poll_interval_seconds") or 5))) + + with _OAUTH_FLOWS_LOCK: + live = dict(_OAUTH_FLOWS.get(flow_id) or {}) + if live.get("status") != "pending": + return + try: + code_resp = _poll_codex_authorization( + str(live.get("device_auth_id") or ""), + str(live.get("user_code") or ""), + ) + if code_resp is None: + continue + authorization_code = str(code_resp.get("authorization_code") or "").strip() + code_verifier = str(code_resp.get("code_verifier") or "").strip() + if not authorization_code or not code_verifier: + raise RuntimeError("Device auth response missing authorization_code or code_verifier") + tokens = _exchange_codex_authorization(authorization_code, code_verifier) + _persist_codex_credentials(Path(live["hermes_home"]), tokens) + _set_flow_status(flow_id, "success") + return + except Exception as exc: + logger.warning("Codex OAuth onboarding flow failed: %s", exc) + _set_flow_status(flow_id, "error", error=str(exc)) + return + + +def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]: + """Start the supported onboarding OAuth flow. + + Currently v1 intentionally supports only OpenAI Codex. Other providers are + rejected instead of silently falling back to terminal-first setup. + """ + _cleanup_oauth_flows() + provider = str((body or {}).get("provider") or "").strip().lower() + if provider not in _ALLOWED_ONBOARDING_OAUTH_PROVIDERS: + if provider in _REJECTED_ONBOARDING_OAUTH_PROVIDERS or provider: + raise ValueError("Only OpenAI Codex OAuth is supported in WebUI onboarding right now") + raise ValueError("provider is required") + + hermes_home = _get_active_hermes_home() try: - with urllib.request.urlopen(req, timeout=15) as resp: - return json.loads(resp.read().decode()) - except Exception as e: - raise RuntimeError(f"Failed to start Codex OAuth: {e}") from e + device = _request_codex_user_code() + except Exception as exc: + raise RuntimeError(f"Failed to start Codex OAuth: {exc}") from exc + + user_code = str(device.get("user_code") or "").strip() + device_auth_id = str(device.get("device_auth_id") or "").strip() + if not user_code or not device_auth_id: + raise RuntimeError("Device code response missing required fields") + + interval = max(3, int(device.get("interval") or 5)) + expires_in = int(device.get("expires_in") or CODEX_FLOW_MAX_WAIT_SECONDS) + expires_at = time.time() + min(max(expires_in, 60), CODEX_FLOW_MAX_WAIT_SECONDS) + flow_id = uuid.uuid4().hex + flow = { + "provider": "openai-codex", + "status": "pending", + "device_auth_id": device_auth_id, + "user_code": user_code, + "expires_at": expires_at, + "poll_interval_seconds": interval, + "hermes_home": str(hermes_home), + "created_at": time.time(), + "updated_at": time.time(), + } + with _OAUTH_FLOWS_LOCK: + _OAUTH_FLOWS[flow_id] = flow + _spawn_codex_oauth_worker(flow_id) + return _public_start_payload(flow_id, flow) + + +def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]: + _cleanup_oauth_flows() + fid = str(flow_id or "").strip() + if not fid: + raise ValueError("flow_id is required") + with _OAUTH_FLOWS_LOCK: + flow = _OAUTH_FLOWS.get(fid) + if not flow: + raise KeyError("OAuth flow not found") + if flow.get("status") == "pending" and float(flow.get("expires_at") or 0) <= time.time(): + flow["status"] = "expired" + flow["updated_at"] = time.time() + _drop_sensitive_flow_fields(flow) + return _public_status_payload(fid, dict(flow)) + + +def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]: + fid = str((body or {}).get("flow_id") or "").strip() + if not fid: + raise ValueError("flow_id is required") + with _OAUTH_FLOWS_LOCK: + flow = _OAUTH_FLOWS.get(fid) + if not flow: + return {"ok": True, "provider": "openai-codex", "flow_id": fid, "status": "cancelled"} + if flow.get("status") == "pending": + flow["status"] = "cancelled" + flow["updated_at"] = time.time() + _drop_sensitive_flow_fields(flow) + return _public_status_payload(fid, dict(flow)) + + +# Backward-compatible names from the abandoned spike. They intentionally do not +# expose provider device secrets to callers anymore. +def start_codex_device_code(): + return start_onboarding_oauth_flow({"provider": "openai-codex"}) def poll_codex_token(device_code, interval=5): - """Poll for Codex OAuth token. Generator that yields status dicts. - - Yields: - {"status": "polling", "attempt": N, "max_attempts": 40} - {"status": "success", "credentials": {...}} - {"status": "error", "error": "..."} - """ - params = { - "grant_type": CODEX_GRANT_TYPE_DEVICE, - "device_code": device_code, - "client_id": CODEX_CLIENT_ID, - } - data = urllib.parse.urlencode(params).encode() - max_attempts = 40 # 40 * 5 = 200s max - - for attempt in range(max_attempts): - yield {"status": "polling", "attempt": attempt + 1, "max_attempts": max_attempts} - - req = urllib.request.Request(CODEX_TOKEN_URL, data=data, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - try: - with urllib.request.urlopen(req, timeout=15) as resp: - token_data = json.loads(resp.read().decode()) - # Save to auth.json credential_pool - _save_codex_credentials(token_data) - yield {"status": "success", "credentials": { - "access_token": "***", - "refresh_token": "***", - "token_type": token_data.get("token_type"), - "expires_in": token_data.get("expires_in"), - }} - return - except urllib.error.HTTPError as e: - body = e.read().decode() - try: - err_data = json.loads(body) - error = err_data.get("error", "") - if error == "authorization_pending": - time.sleep(interval) - continue - elif error == "slow_down": - time.sleep(interval + 5) - continue - elif error == "expired_token": - yield {"status": "error", "error": "Device code expired. Please try again."} - return - else: - yield {"status": "error", "error": err_data.get("error_description", error)} - return - except Exception: - yield {"status": "error", "error": body[:200]} - return - except Exception as e: - yield {"status": "error", "error": str(e)} - return - - yield {"status": "error", "error": "OAuth flow timed out. Please try again."} - - -def _save_codex_credentials(token_data): - """Save Codex OAuth credentials to auth.json credential_pool.""" - auth = _read_auth_json() - if "credential_pool" not in auth: - auth["credential_pool"] = {} - pool = auth["credential_pool"] - - if "openai-codex" not in pool: - pool["openai-codex"] = [] - - # Check if an oauth_device entry already exists (update in place) - updated = False - _now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - for entry in pool["openai-codex"]: - if entry.get("source") == "oauth_device": - entry["access_token"] = token_data.get("access_token", "") - entry["refresh_token"] = token_data.get("refresh_token", "") - entry["auth_type"] = "oauth" - entry["updated_at"] = _now_iso - updated = True - break - - if not updated: - existing_ids = {e["id"] for e in pool.get("openai-codex", [])} - for _ in range(3): # retry on collision - cred_id = "codex-oauth-" + uuid.uuid4().hex[:8] - if cred_id not in existing_ids: - break - pool["openai-codex"].append({ - "id": cred_id, - "label": "Codex OAuth", - "auth_type": "oauth", - "source": "oauth_device", - "access_token": token_data.get("access_token", ""), - "refresh_token": token_data.get("refresh_token", ""), - "priority": 1, - "created_at": _now_iso, - }) - - auth["updated_at"] = _now_iso - _write_auth_json(auth) + yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"} diff --git a/api/onboarding.py b/api/onboarding.py index b572c542..af9d82ce 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -185,8 +185,9 @@ _PROVIDER_CATEGORIES = [ ] _UNSUPPORTED_PROVIDER_NOTE = ( - "OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub " - "Copilot are still terminal-first. Use `hermes model` for those flows." + "Advanced provider flows such as Nous Portal and GitHub Copilot are still " + "terminal-first. OpenAI Codex can be authenticated in this onboarding flow " + "when your Hermes config selects the openai-codex provider." ) diff --git a/api/routes.py b/api/routes.py index 5a0ae736..8ef98b63 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1253,6 +1253,11 @@ from api.onboarding import ( complete_onboarding, probe_provider_endpoint, ) +from api.oauth import ( + cancel_onboarding_oauth_flow, + poll_onboarding_oauth_flow, + start_onboarding_oauth_flow, +) # Approval system (optional -- graceful fallback if agent not available) try: @@ -2280,38 +2285,19 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"error": "not found"}, status=404) return _handle_clarify_inject(handler, parsed) - # ── OAuth (Codex device-code) ── - if parsed.path == "/api/oauth/codex/start": - """Start Codex device-code OAuth flow. Returns user_code + verification_uri.""" - try: - from api.oauth import start_codex_device_code - result = start_codex_device_code() - return j(handler, result) - except Exception as e: - return j(handler, {"error": str(e)}, status=500) - - if parsed.path == "/api/oauth/codex/poll": - """SSE endpoint for polling Codex OAuth token.""" + if parsed.path == "/api/onboarding/oauth/poll": qs = parse_qs(parsed.query) - device_code = qs.get("device_code", [""])[0] - if not device_code: - return j(handler, {"error": "device_code required"}, status=400) - handler.send_response(200) - handler.send_header("Content-Type", "text/event-stream") - handler.send_header("Cache-Control", "no-cache") - handler.send_header("Connection", "keep-alive") - handler.end_headers() + flow_id = qs.get("flow_id", [""])[0] try: - from api.oauth import poll_codex_token - for event in poll_codex_token(device_code): - handler.wfile.write(f"data: {json.dumps(event)}\n\n".encode()) - handler.wfile.flush() - if event.get("status") in ("success", "error"): - break - except Exception as e: - handler.wfile.write(f"data: {json.dumps({'status': 'error', 'error': str(e)})}\n\n".encode()) - handler.wfile.flush() - return # SSE handled, no JSON response + return j( + handler, + poll_onboarding_oauth_flow(flow_id), + extra_headers={"Cache-Control": "no-store"}, + ) + except ValueError as e: + return bad(handler, str(e)) + except KeyError as e: + return bad(handler, str(e), 404) # ── Cron API (GET) ── # All cron handlers touch cron.jobs which resolves HERMES_HOME from @@ -3281,6 +3267,34 @@ def handle_post(handler, parsed) -> bool: handler.wfile.write(response_body) return True + if parsed.path == "/api/onboarding/oauth/start": + from api.auth import is_auth_enabled + import os as _os + if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"): + import ipaddress + try: + _xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip() + _xri = handler.headers.get("X-Real-IP", "").strip() + _raw = handler.client_address[0] + addr = ipaddress.ip_address(_xff or _xri or _raw) + is_local = addr.is_loopback or addr.is_private + except ValueError: + is_local = False + if not is_local: + return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403) + try: + return j(handler, start_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"}) + except ValueError as e: + return bad(handler, str(e)) + except RuntimeError as e: + return bad(handler, str(e), 500) + + if parsed.path == "/api/onboarding/oauth/cancel": + try: + return j(handler, cancel_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"}) + except ValueError as e: + return bad(handler, str(e)) + if parsed.path == "/api/onboarding/setup": # Writing API keys to disk - restrict to local/private networks unless auth is active. # In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1, diff --git a/docs/pr-media/1362/codex-oauth-onboarding.png b/docs/pr-media/1362/codex-oauth-onboarding.png new file mode 100644 index 00000000..9d184151 Binary files /dev/null and b/docs/pr-media/1362/codex-oauth-onboarding.png differ diff --git a/static/onboarding.js b/static/onboarding.js index 05582576..bd0f9650 100644 --- a/static/onboarding.js +++ b/static/onboarding.js @@ -265,6 +265,9 @@ function _renderOnboardingBody(){ if(currentIsOauth){ const isReady=!!(ONBOARDING.status.system||{}).chat_ready; const providerLabel=esc(currentProviderName); + const codexOauthPendingBody=currentProviderName==='openai-codex' + ? 'This instance is configured to use openai-codex, which uses OAuth rather than an API key. Use the button below to authenticate with ChatGPT, then continue once provider status refreshes.' + : t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel); if(isReady){ _setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success'); body.innerHTML=` @@ -288,9 +291,10 @@ function _renderOnboardingBody(){ body.innerHTML=`
${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}
+${codexOauthPendingBody}
+ ${currentProviderName==='openai-codex'?``:''}${t('onboarding_oauth_switch_hint')}
@@ -314,17 +318,6 @@ function _renderOnboardingBody(){ ${_renderOnboardingApiKeyField()} ${_renderOnboardingBaseUrlField(showBaseUrl)}${keyHelp}
-- ${t('onboarding_oauth_switch_hint')} -
-${t('onboarding_base_url_help')}
`:''}${esc(setup.unsupported_note||'')||''}
`; return; @@ -552,23 +545,100 @@ async function nextOnboardingStep(){ } /* ── Codex OAuth device-code flow ── */ -let _codexOAuthSSE=null; +let _codexOAuthPollTimer=null; +let _codexOAuthFlowId=null; + +function _clearCodexOAuthPoll(){ + if(_codexOAuthPollTimer){clearTimeout(_codexOAuthPollTimer);_codexOAuthPollTimer=null;} +} + +function _setCodexOAuthButton(enabled){ + const btn=$('codexOAuthBtn'); + if(btn){btn.disabled=!enabled;btn.textContent=enabled?t('oauth_login_codex'):'...';} +} + +async function copyCodexOAuthCode(code){ + try{ + await navigator.clipboard.writeText(code||''); + showToast('Code copied'); + }catch(e){ + showToast(code||''); + } +} + +async function cancelCodexOAuth(){ + const flowDiv=$('codexOAuthFlow'); + const flowId=_codexOAuthFlowId; + _clearCodexOAuthPoll(); + _codexOAuthFlowId=null; + if(flowId){ + try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId})});}catch(e){} + } + _setCodexOAuthButton(true); + if(flowDiv){ + flowDiv.innerHTML=`Start again whenever you're ready.
${esc(message||'')}
Starting device-code flow…
${t('oauth_codex_step2')}
-${esc(user_code)}
+ ${esc(user_code)}
+
+
+ ${t('oauth_codex_polling')}
Token saved to credential pool. You can now use Codex as a provider.
${esc(data.error||'Unknown error')}
Connection lost. Please try again.
${esc(e.message||String(e))}