Stage 296: PR #1650 — Codex OAuth onboarding flow (refs #1362) by @Michaelyklam

This commit is contained in:
test 2026-05-04 21:26:52 +00:00
commit c07d821586
6 changed files with 720 additions and 262 deletions

View file

@ -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"}

View file

@ -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."
)

View file

@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View file

@ -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 <strong>openai-codex</strong>, 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=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">⚠</div>
<div>
<div style="flex:1">
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
<p>${codexOauthPendingBody}</p>
${currentProviderName==='openai-codex'?`<div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" type="button">${t('oauth_login_codex')}</button></div><div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>`:''}
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
@ -314,17 +318,6 @@ function _renderOnboardingBody(){
${_renderOnboardingApiKeyField()}
${_renderOnboardingBaseUrlField(showBaseUrl)}
<p class="onboarding-copy">${keyHelp}</p>
<div class="onboarding-oauth-card" id="codexOAuthCard">
<div class="onboarding-oauth-icon">🔑</div>
<div style="flex:1">
<strong>${t('oauth_login_codex')}</strong>
<p style="margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5">
${t('onboarding_oauth_switch_hint')}
</p>
</div>
<button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" style="margin-left:auto;flex-shrink:0">${t('oauth_login_codex')}</button>
</div>
<div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
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=`<div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>OAuth login cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div>`;
}
}
function _renderCodexOAuthTerminal(status,message){
const flowDiv=$('codexOAuthFlow');
if(!flowDiv)return;
const ok=status==='success';
const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌';
const title=ok?t('oauth_codex_success'):(status==='expired'?t('oauth_codex_expired'):(status==='cancelled'?'OAuth login cancelled':t('oauth_codex_error')));
flowDiv.innerHTML=`
<div class="onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" ${ok?'':'style="border-color:var(--error,#e55)"'}>
<div class="onboarding-oauth-icon">${icon}</div>
<div><strong>${title}</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">${esc(message||'')}</p></div>
</div>`;
}
async function _pollCodexOAuth(){
const flowId=_codexOAuthFlowId;
if(!flowId)return;
try{
const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId));
const status=(resp&&resp.status)||'error';
if(status==='pending'){
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,3000);
return;
}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(true);
if(status==='success'){
_renderCodexOAuthTerminal('success','Credentials saved to the Hermes credential pool. Refreshing provider status…');
showToast(t('oauth_codex_success'));
try{await loadOnboardingWizard();}catch(e){}
}else if(status==='expired'){
_renderCodexOAuthTerminal('expired','The code expired. Start a new login flow to try again.');
}else if(status==='cancelled'){
_renderCodexOAuthTerminal('cancelled','The login flow was cancelled.');
}else{
_renderCodexOAuthTerminal('error',(resp&&resp.error)||'OAuth login failed. Please try again.');
}
}catch(e){
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(true);
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
}
}
async function startCodexOAuth(){
const flowDiv=$('codexOAuthFlow');
const btn=$('codexOAuthBtn');
if(!flowDiv)return;
if(btn){btn.disabled=true;btn.textContent='...';}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_setCodexOAuthButton(false);
flowDiv.style.display='block';
flowDiv.innerHTML=`<div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong>${t('oauth_codex_polling')}</strong><p>Starting device-code flow…</p></div></div>`;
try{
const resp=await api('/api/oauth/codex/start',{method:'POST'});
const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'openai-codex'})});
if(resp.error) throw new Error(resp.error);
const{device_code,user_code,verification_uri}=resp;
if(!device_code||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
// Open verification URI in new tab
window.open(verification_uri,'_blank');
// Show user code prominently
const{flow_id,user_code,verification_uri}=resp;
if(!flow_id||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
_codexOAuthFlowId=flow_id;
flowDiv.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">📋</div>
@ -576,60 +646,19 @@ async function startCodexOAuth(){
<strong>${t('oauth_codex_step1')}</strong>
<p><a href="${esc(verification_uri)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(verification_uri)}</a></p>
<p style="margin-top:8px"><strong>${t('oauth_codex_step2')}</strong></p>
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;margin-top:4px;user-select:all">${esc(user_code)}</code>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;user-select:all">${esc(user_code)}</code>
<button class="sm-btn" type="button" onclick="copyCodexOAuthCode('${esc(user_code)}')">Copy code</button>
<button class="sm-btn" type="button" onclick="cancelCodexOAuth()">Cancel</button>
</div>
<p style="margin-top:8px;color:var(--muted);font-size:13px">${t('oauth_codex_polling')}</p>
</div>
</div>`;
// Connect to SSE poll endpoint
const pollUrl=new URL('api/oauth/codex/poll?device_code='+encodeURIComponent(device_code),location.href);
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
_codexOAuthSSE=new EventSource(pollUrl.href);
_codexOAuthSSE.onmessage=function(ev){
let data;
try{data=JSON.parse(ev.data);}catch(e){return;}
if(data.status==='success'){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
flowDiv.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-ready">
<div class="onboarding-oauth-icon">✅</div>
<div><strong>${t('oauth_codex_success')}</strong>
<p>Token saved to credential pool. You can now use Codex as a provider.</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
showToast(t('oauth_codex_success'));
// Refresh onboarding status in background
loadOnboardingWizard().catch(()=>{});
}else if(data.status==='error'){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
const isExpired=(data.error||'').includes('expired');
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${isExpired?t('oauth_codex_expired'):t('oauth_codex_error')}</strong>
<p>${esc(data.error||'Unknown error')}</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
}
// 'polling' status — keep waiting
};
_codexOAuthSSE.onerror=function(){
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
// Don't overwrite if already showing success/error
if(!flowDiv.querySelector('.onboarding-oauth-ready')&&!flowDiv.querySelector('[style*="error"]')){
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${t('oauth_codex_error')}</strong><p>Connection lost. Please try again.</p></div>
</div>`;
}
};
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000));
}catch(e){
flowDiv.innerHTML=`
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
<div class="onboarding-oauth-icon">❌</div>
<div><strong>${t('oauth_codex_error')}</strong><p>${esc(e.message||String(e))}</p></div>
</div>`;
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
_clearCodexOAuthPoll();
_codexOAuthFlowId=null;
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
_setCodexOAuthButton(true);
}
}

View file

@ -0,0 +1,182 @@
"""Regression tests for issue #1362 — Codex OAuth from onboarding."""
from __future__ import annotations
import json
import stat
import time
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
get_idx = routes.find("def handle_get(")
post_idx = routes.find("def handle_post(")
assert get_idx != -1 and post_idx != -1
get_body = routes[get_idx:post_idx]
post_body = routes[post_idx:]
assert '"/api/onboarding/oauth/poll"' in get_body
assert '"/api/onboarding/oauth/start"' not in get_body
assert '"/api/oauth/codex/start"' not in routes
assert '"/api/oauth/codex/poll"' not in routes
assert '"/api/onboarding/oauth/start"' in post_body
assert '"/api/onboarding/oauth/cancel"' in post_body
def test_onboarding_oauth_rejects_non_codex_providers(monkeypatch):
import api.oauth as oauth
for provider in ("anthropic", "claude", "claude-code", "nous", "qwen-oauth", "copilot", "bogus"):
with pytest.raises(ValueError):
oauth.start_onboarding_oauth_flow({"provider": provider})
def test_start_payload_does_not_leak_provider_device_secrets(monkeypatch, tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
monkeypatch.setattr(oauth, "_request_codex_user_code", lambda: {
"device_auth_id": "device-secret",
"user_code": "ABCD-EFGH",
"interval": 3,
})
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)
payload = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
assert payload["ok"] is True
assert payload["provider"] == "openai-codex"
assert payload["status"] == "pending"
assert payload["verification_uri"] == "https://auth.openai.com/codex/device"
assert payload["user_code"] == "ABCD-EFGH"
serialized = json.dumps(payload)
for forbidden in (
"device_auth_id",
"device-secret",
"authorization_code",
"code_verifier",
"access_token",
"refresh_token",
):
assert forbidden not in serialized
def test_poll_returns_high_level_status_only(monkeypatch, tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-test"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"device_auth_id": "device-secret",
"user_code": "ABCD-EFGH",
"code_verifier": "verifier-secret",
"authorization_code": "auth-secret",
"expires_at": time.time() + 60,
"poll_interval_seconds": 3,
"hermes_home": tmp_path,
}
payload = oauth.poll_onboarding_oauth_flow(flow_id)
assert payload == {"ok": True, "provider": "openai-codex", "flow_id": flow_id, "status": "pending"}
serialized = json.dumps(payload)
for forbidden in ("device_auth_id", "device-secret", "code_verifier", "authorization_code"):
assert forbidden not in serialized
def test_cancel_marks_flow_cancelled_and_poll_stops(tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-cancel"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"expires_at": time.time() + 60,
"hermes_home": tmp_path,
}
cancelled = oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
polled = oauth.poll_onboarding_oauth_flow(flow_id)
assert cancelled["status"] == "cancelled"
assert polled["status"] == "cancelled"
def test_expired_flow_reports_expired_and_drops_sensitive_lifecycle(tmp_path):
import api.oauth as oauth
oauth._OAUTH_FLOWS.clear()
flow_id = "flow-expired"
oauth._OAUTH_FLOWS[flow_id] = {
"provider": "openai-codex",
"status": "pending",
"device_auth_id": "device-secret",
"expires_at": time.time() - 1,
"hermes_home": tmp_path,
}
payload = oauth.poll_onboarding_oauth_flow(flow_id)
assert payload["status"] == "expired"
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
assert "device_auth_id" not in oauth._OAUTH_FLOWS[flow_id]
def test_codex_credentials_written_to_active_profile_auth_json(monkeypatch, tmp_path):
import api.oauth as oauth
from api.onboarding import _provider_oauth_authenticated
active_home = tmp_path / "active-profile"
realish_home = tmp_path / "process-home"
active_home.mkdir()
realish_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: realish_home)
auth_path = oauth._persist_codex_credentials(
active_home,
{"access_token": "access-secret", "refresh_token": "refresh-secret"},
)
assert auth_path == active_home / "auth.json"
assert auth_path.exists()
assert not (realish_home / ".hermes" / "auth.json").exists()
mode = stat.S_IMODE(auth_path.stat().st_mode)
assert mode == 0o600
store = json.loads(auth_path.read_text(encoding="utf-8"))
entry = store["credential_pool"]["openai-codex"][0]
assert entry["auth_type"] == "oauth"
assert entry["source"] == "manual:device_code"
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
assert _provider_oauth_authenticated("openai-codex", active_home) is True
def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
assert "/api/onboarding/oauth/start" in js
assert "/api/onboarding/oauth/poll" in js
assert "/api/onboarding/oauth/cancel" in js
assert "window.open(verification_uri" not in js
assert "device_code=" not in js
assert "device_code" not in js
assert "flow_id" in js
assert "copyCodexOAuthCode" in js
assert "cancelCodexOAuth" in js
def test_unsupported_note_no_longer_calls_openai_codex_terminal_first():
src = (REPO / "api" / "onboarding.py").read_text(encoding="utf-8")
start = src.find("_UNSUPPORTED_PROVIDER_NOTE")
body = src[start:start + 400]
assert "OpenAI Codex, and GitHub" not in body
assert "OpenAI Codex" in body and "authenticated in this onboarding flow" in body
assert "Anthropic" not in body
assert "Claude" not in body