This commit is contained in:
commit
c07d821586
6 changed files with 720 additions and 262 deletions
546
api/oauth.py
546
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"}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
BIN
docs/pr-media/1362/codex-oauth-onboarding.png
Normal file
BIN
docs/pr-media/1362/codex-oauth-onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
182
tests/test_issue1362_codex_oauth_onboarding.py
Normal file
182
tests/test_issue1362_codex_oauth_onboarding.py
Normal 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
|
||||
Loading…
Reference in a new issue