HERMES-GIELIE/api/oauth.py

868 lines
34 KiB
Python
Raw Normal View History

2026-05-04 21:07:16 +00:00
"""In-app OAuth flow implementations for onboarding.
2026-05-04 21:07:16 +00:00
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.
"""
2026-05-04 21:07:16 +00:00
from __future__ import annotations
import json
import logging
2026-05-04 21:07:16 +00:00
import os
import stat
import threading
import time
import uuid
import urllib.error
2026-05-04 21:07:16 +00:00
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
2026-05-04 21:07:16 +00:00
from typing import Any
logger = logging.getLogger(__name__)
2026-05-04 21:07:16 +00:00
# Compatibility for older helper tests and self-heal code that import these.
AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
2026-05-04 21:07:16 +00:00
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", "anthropic", "claude", "claude-code"}
_ANTHROPIC_PROVIDER_ALIASES = {"anthropic", "claude", "claude-code"}
2026-05-04 21:07:16 +00:00
_REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
"nous",
"qwen-oauth",
"gemini-cli",
"google-gemini-cli",
"minimax",
"minimax-oauth",
"copilot",
"copilot-acp",
}
ANTHROPIC_CREDENTIAL_POLL_SECONDS = 5
ANTHROPIC_FLOW_MAX_WAIT_SECONDS = 15 * 60
ANTHROPIC_PUBLIC_LINK_ERROR = "Claude Code credential linking failed. Check server logs."
2026-05-04 21:07:16 +00:00
_OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
_OAUTH_FLOWS_LOCK = threading.Lock()
_OAUTH_START_LOCKS: dict[tuple[str, str], threading.Lock] = {}
_OAUTH_START_LOCKS_LOCK = threading.Lock()
_ANTHROPIC_ENV_KEYS = ("ANTHROPIC_TOKEN", "ANTHROPIC_API_KEY")
def _oauth_start_key(provider: str, hermes_home: Path) -> tuple[str, str]:
"""Return the canonical single-flight key for onboarding OAuth starts."""
return (_normalize_onboarding_oauth_provider(provider), str(Path(hermes_home)))
def _oauth_start_lock(provider: str, hermes_home: Path) -> threading.Lock:
"""Return the per provider/profile lock that serializes OAuth flow creation."""
key = _oauth_start_key(provider, hermes_home)
with _OAUTH_START_LOCKS_LOCK:
lock = _OAUTH_START_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_OAUTH_START_LOCKS[key] = lock
return lock
def _pending_oauth_flow_for_locked(provider: str, hermes_home: Path) -> tuple[str, dict[str, Any]] | None:
"""Return an existing live onboarding OAuth flow for the provider/profile.
Onboarding OAuth starts are unauthenticated while first-run auth is disabled.
Keep them single-flight per provider/profile so repeated start requests reuse
the existing worker instead of accumulating pending flows and daemon threads.
The caller must hold _OAUTH_FLOWS_LOCK so the check can be paired atomically
with flow insertion on start paths.
"""
canonical_provider = _normalize_onboarding_oauth_provider(provider)
canonical_home = str(Path(hermes_home))
now = time.time()
for flow_id, flow in _OAUTH_FLOWS.items():
if flow.get("provider") != canonical_provider:
continue
if str(flow.get("hermes_home") or "") != canonical_home:
continue
if flow.get("status") != "pending":
continue
if float(flow.get("expires_at") or 0) <= now:
flow["status"] = "expired"
flow["updated_at"] = now
_drop_sensitive_flow_fields(flow)
continue
return flow_id, dict(flow)
return None
def _pending_oauth_flow_for(provider: str, hermes_home: Path) -> tuple[str, dict[str, Any]] | None:
"""Return an existing live onboarding OAuth flow for the provider/profile, if any."""
with _OAUTH_FLOWS_LOCK:
return _pending_oauth_flow_for_locked(provider, hermes_home)
def _clear_process_anthropic_env_values() -> None:
"""Clear Anthropic process env fallbacks under the streaming env lock."""
from api.streaming import _ENV_LOCK
with _ENV_LOCK:
for key in _ANTHROPIC_ENV_KEYS:
os.environ.pop(key, None)
def resolve_runtime_provider_with_anthropic_env_lock(resolver, *args, **kwargs):
"""Resolve runtime credentials under the Anthropic onboarding env lock.
Request paths must resolve Anthropic env fallbacks per outbound request,
not cache ANTHROPIC_TOKEN or ANTHROPIC_API_KEY across onboarding. Sharing
the process-env lock prevents a chat stream from observing one stale
Anthropic env value while onboarding has already cleared the other.
"""
from api.streaming import _ENV_LOCK
with _ENV_LOCK:
return resolver(*args, **kwargs)
2026-05-04 21:07:16 +00:00
def _normalize_onboarding_oauth_provider(provider: str) -> str:
"""Normalize Anthropic aliases (claude, claude-code) to 'anthropic'; defaults to 'openai-codex' when blank."""
provider = str(provider or "").strip().lower()
if provider in _ANTHROPIC_PROVIDER_ALIASES:
return "anthropic"
return provider or "openai-codex"
2026-05-04 21:07:16 +00:00
def _get_active_hermes_home() -> Path:
"""Return the active Hermes profile home directory, falling back to ~/.hermes when profile resolution fails."""
2026-05-04 21:07:16 +00:00
try:
from api.profiles import get_active_hermes_home
return Path(get_active_hermes_home())
except Exception as exc:
# Per Opus advisor on stage-296: log the silent fallback so a corrupt
# profile state ending up writing tokens to ~/.hermes (instead of the
# active profile) is observable in logs rather than failing silently.
logger.warning(
"Falling back to ~/.hermes for OAuth credential storage: "
"active-profile resolution failed: %s",
exc,
)
2026-05-04 21:07:16 +00:00
return Path.home() / ".hermes"
2026-05-04 21:07:16 +00:00
# ── legacy auth.json helpers ────────────────────────────────────────────────
2026-05-04 21:07:16 +00:00
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:
2026-05-04 21:07:16 +00:00
loaded = json.loads(path.read_text(encoding="utf-8"))
return loaded if isinstance(loaded, dict) else {}
except json.JSONDecodeError as exc:
2026-05-04 21:07:16 +00:00
logger.warning("Failed to parse %s: %s", path, exc)
return {}
return {}
def read_auth_json():
2026-05-04 21:07:16 +00:00
"""Public wrapper for streaming credential self-heal code."""
return _read_auth_json()
2026-05-04 21:07:16 +00:00
def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Path:
"""Atomically write auth.json with owner-only permissions.
v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415 stage-257 batch (PRs #1402 + #1415): Opus pre-release advisor caught 4 issues in stage-257: 1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace() preserves the temp file umask (0644 default), so OAuth access/refresh tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600) BEFORE rename, with try/except OSError that warns but does not abort. 2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted job_id as a path component without validation. Mirrors the rollback path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit . / .. rejection. 3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised ValueError on malformed input → confusing 500. Now try/except + clamp to (max(0, offset), max(1, min(500, limit))). 4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth even though path-resolve check would catch it downstream). PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and test_custom_provider_display_name asserted bare model IDs but #1415 changes named-custom-provider IDs to @custom:NAME:model form when active provider differs. Tests updated to use _strip_at_prefix helper to keep checking the same invariant in the new shape. 4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing tests. Full suite: 3602 passed, 0 failed.
2026-05-01 18:30:41 +00:00
2026-05-04 21:07:16 +00:00
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.
v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415 stage-257 batch (PRs #1402 + #1415): Opus pre-release advisor caught 4 issues in stage-257: 1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace() preserves the temp file umask (0644 default), so OAuth access/refresh tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600) BEFORE rename, with try/except OSError that warns but does not abort. 2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted job_id as a path component without validation. Mirrors the rollback path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit . / .. rejection. 3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised ValueError on malformed input → confusing 500. Now try/except + clamp to (max(0, offset), max(1, min(500, limit))). 4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth even though path-resolve check would catch it downstream). PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and test_custom_provider_display_name asserted bare model IDs but #1415 changes named-custom-provider IDs to @custom:NAME:model form when active provider differs. Tests updated to use _strip_at_prefix helper to keep checking the same invariant in the new shape. 4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing tests. Full suite: 3602 passed, 0 failed.
2026-05-01 18:30:41 +00:00
"""
2026-05-04 21:07:16 +00:00
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}")
v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415 stage-257 batch (PRs #1402 + #1415): Opus pre-release advisor caught 4 issues in stage-257: 1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace() preserves the temp file umask (0644 default), so OAuth access/refresh tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600) BEFORE rename, with try/except OSError that warns but does not abort. 2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted job_id as a path component without validation. Mirrors the rollback path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit . / .. rejection. 3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised ValueError on malformed input → confusing 500. Now try/except + clamp to (max(0, offset), max(1, min(500, limit))). 4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth even though path-resolve check would catch it downstream). PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and test_custom_provider_display_name asserted bare model IDs but #1415 changes named-custom-provider IDs to @custom:NAME:model form when active provider differs. Tests updated to use _strip_at_prefix helper to keep checking the same invariant in the new shape. 4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing tests. Full suite: 3602 passed, 0 failed.
2026-05-01 18:30:41 +00:00
try:
2026-05-04 21:07:16 +00:00
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
2026-05-04 21:07:16 +00:00
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string ending in Z."""
2026-05-04 21:07:16 +00:00
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
2026-05-04 21:07:16 +00:00
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
# Per Opus advisor on stage-296: also accept the legacy `source ==
# "oauth_device"` value so users with prior Codex OAuth credentials
# (written by older WebUI versions before this PR's source-key change)
# get their existing entry updated in-place rather than accumulating a
# stale duplicate pool entry.
_accept_sources = {"manual:device_code", "oauth_device"}
2026-05-04 21:07:16 +00:00
for candidate in entries:
if isinstance(candidate, dict) and candidate.get("source") in _accept_sources:
2026-05-04 21:07:16 +00:00
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:
2026-05-04 21:07:16 +00:00
from api.config import invalidate_credential_pool_cache
2026-05-04 21:07:16 +00:00
invalidate_credential_pool_cache("openai-codex")
except Exception:
logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True)
2026-05-04 21:07:16 +00:00
return path
2026-05-04 21:07:16 +00:00
# Backward-compatible wrapper used by older code/tests.
def _save_codex_credentials(token_data):
"""Backward-compatible wrapper: persist Codex OAuth tokens to the active-profile auth.json."""
2026-05-04 21:07:16 +00:00
return _persist_codex_credentials(_get_active_hermes_home(), token_data)
# ── Anthropic / Claude Code credential linking ─────────────────────────────
def _read_claude_code_credentials() -> dict[str, Any] | None:
"""Read Claude Code OAuth credentials from the host without exposing them.
Delegates to the agent adapter which knows about ~/.claude/.credentials.json
and macOS Keychain. Returns the credential dict or None.
"""
try:
from agent.anthropic_adapter import (
is_claude_code_token_valid,
read_claude_code_credentials,
)
creds = read_claude_code_credentials()
if creds and (
is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))
):
return creds
except Exception as exc:
logger.debug("Could not read Claude Code credentials: %s", exc)
return None
def _clear_anthropic_env_values(hermes_home: Path) -> None:
"""Clear Anthropic API/setup-token env values in the active profile only.
The .env write path already clears os.environ while holding the streaming
env lock. Keep a locked process-env clear here too so import/write failures
cannot leave or partially clear stale Anthropic fallbacks.
"""
try:
from api.providers import _write_env_file
_write_env_file(
Path(hermes_home) / ".env",
{key: None for key in _ANTHROPIC_ENV_KEYS},
)
except Exception as exc:
logger.warning("Failed to clear Anthropic env values: %s", exc)
_clear_process_anthropic_env_values()
def _link_anthropic_credentials(hermes_home: Path) -> None:
"""Link Hermes to use Claude Code's credential store.
Clears ANTHROPIC_TOKEN and ANTHROPIC_API_KEY from the Hermes .env so
that resolve_anthropic_token() falls through to reading Claude Code's
~/.claude/.credentials.json directly — the same thing the CLI's
``use_anthropic_claude_code_credentials()`` does.
Also writes a marker entry in auth.json credential_pool so that
``_provider_oauth_authenticated("anthropic", ...)`` can detect the
linked state without touching the actual credential files.
"""
_clear_anthropic_env_values(hermes_home)
# Write a pool marker (no secrets) so onboarding status can detect linkage.
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("anthropic", [])
if not isinstance(entries, list):
entries = []
pool["anthropic"] = entries
now = _now_iso()
entry = None
for candidate in entries:
if isinstance(candidate, dict) and candidate.get("source") == "claude_code_linked":
entry = candidate
break
if entry is None:
entry = {
"id": "anthropic-claude-code-" + uuid.uuid4().hex[:12],
"label": "Claude Code (linked)",
"auth_type": "oauth",
"priority": 0,
"source": "claude_code_linked",
"created_at": now,
}
entries.insert(0, entry)
entry.update({
"label": "Claude Code (linked)",
"auth_type": "oauth",
"priority": 0,
"source": "claude_code_linked",
"updated_at": now,
})
auth["updated_at"] = now
_write_auth_json(auth, auth_path)
try:
from api.config import invalidate_credential_pool_cache
invalidate_credential_pool_cache("anthropic")
except Exception:
logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe start payload for an Anthropic credential-linking flow, omitting server-side secrets."""
payload: dict[str, Any] = {
"ok": True,
"provider": "anthropic",
"flow_id": flow_id,
"status": flow.get("status", "pending"),
"poll_interval_seconds": flow.get("poll_interval_seconds", ANTHROPIC_CREDENTIAL_POLL_SECONDS),
}
if flow.get("status") == "pending":
payload["action_required"] = (
"Claude Code credentials were not found on this server. "
"Please run 'claude login' or 'claude setup-token' in a terminal "
"on the host, then return here — this page will detect the credentials automatically."
)
if flow.get("expires_at"):
payload["expires_at"] = flow["expires_at"]
return payload
def _anthropic_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe status payload for an Anthropic flow, replacing internal error with a safe string."""
payload: dict[str, Any] = {
"ok": True,
"provider": "anthropic",
"flow_id": flow_id,
"status": flow.get("status", "error"),
}
if flow.get("status") == "error" and flow.get("error"):
payload["error"] = ANTHROPIC_PUBLIC_LINK_ERROR
return payload
def _spawn_anthropic_credential_worker(flow_id: str) -> None:
"""Launch a daemon thread that polls for Claude Code credentials and transitions the Anthropic flow to success."""
worker = threading.Thread(
target=_run_anthropic_credential_worker, args=(flow_id,), daemon=True,
)
worker.start()
def _run_anthropic_credential_worker(flow_id: str) -> None:
"""Poll for Claude Code credential appearance until found, cancelled, or expired."""
while True:
with _OAUTH_FLOWS_LOCK:
flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
if not flow:
return
if flow.get("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 ANTHROPIC_CREDENTIAL_POLL_SECONDS)))
# Re-check status under lock (cancel may have arrived during sleep)
with _OAUTH_FLOWS_LOCK:
live = _OAUTH_FLOWS.get(flow_id)
if not live or live.get("status") != "pending":
return
try:
creds = _read_claude_code_credentials()
if creds is None:
continue
# Re-check status under lock before linking — cancel must win
with _OAUTH_FLOWS_LOCK:
current = _OAUTH_FLOWS.get(flow_id)
if not current or current.get("status") != "pending":
return
hermes_home = Path(flow["hermes_home"])
_link_anthropic_credentials(hermes_home)
with _OAUTH_FLOWS_LOCK:
current = _OAUTH_FLOWS.get(flow_id)
if not current or current.get("status") != "pending":
cancelled = bool(current and current.get("status") == "cancelled")
else:
current["status"] = "success"
current["updated_at"] = time.time()
_drop_sensitive_flow_fields(current)
cancelled = False
if cancelled:
_remove_anthropic_link_marker(hermes_home)
return
except Exception as exc:
logger.warning("Anthropic credential polling failed: %s", exc)
with _OAUTH_FLOWS_LOCK:
current = _OAUTH_FLOWS.get(flow_id)
if current and current.get("status") == "pending":
current["status"] = "error"
current["updated_at"] = time.time()
current["error"] = str(exc)
_drop_sensitive_flow_fields(current)
return
def _remove_anthropic_link_marker(hermes_home: Path) -> None:
"""Remove the secret-free Claude Code linked marker after a cancelled race."""
auth_path = Path(hermes_home) / "auth.json"
auth = _read_auth_json(auth_path)
pool = auth.get("credential_pool")
if not isinstance(pool, dict):
return
entries = pool.get("anthropic")
if not isinstance(entries, list):
return
kept = [entry for entry in entries if not (isinstance(entry, dict) and entry.get("source") == "claude_code_linked")]
if len(kept) == len(entries):
return
if kept:
pool["anthropic"] = kept
else:
pool.pop("anthropic", None)
auth["updated_at"] = _now_iso()
_write_auth_json(auth, auth_path)
try:
from api.config import invalidate_credential_pool_cache
invalidate_credential_pool_cache("anthropic")
except Exception:
logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
2026-05-04 21:07:16 +00:00
# ── Codex protocol ──────────────────────────────────────────────────────────
def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
"""POST a JSON or form-encoded payload to url and return the parsed JSON response."""
2026-05-04 21:07:16 +00:00
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]:
"""Request a new device-auth user code and device_auth_id from the Codex endpoint."""
2026-05-04 21:07:16 +00:00
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:
"""Poll the Codex device token endpoint; returns None on 403/404 (not yet authorized) or raises otherwise."""
2026-05-04 21:07:16 +00:00
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]:
"""Exchange a Codex authorization code and PKCE verifier for access/refresh tokens."""
2026-05-04 21:07:16 +00:00
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 _codex_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe start payload for a Codex device-code flow, including user_code and verification_uri."""
2026-05-04 21:07:16 +00:00
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),
}
def _codex_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Build the browser-safe status payload for a Codex flow, capping error strings at 200 characters."""
2026-05-04 21:07:16 +00:00
payload = {
"ok": True,
"provider": "openai-codex",
"flow_id": flow_id,
"status": flow.get("status", "error"),
}
2026-05-04 21:07:16 +00:00
if flow.get("status") == "error" and flow.get("error"):
payload["error"] = str(flow.get("error"))[:200]
return payload
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Dispatch to the provider-specific start payload builder based on flow['provider']."""
provider = flow.get("provider", "openai-codex")
if provider == "anthropic":
return _anthropic_public_start_payload(flow_id, flow)
return _codex_public_start_payload(flow_id, flow)
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
"""Dispatch to the provider-specific status payload builder based on flow['provider']."""
provider = flow.get("provider", "openai-codex")
if provider == "anthropic":
return _anthropic_public_status_payload(flow_id, flow)
return _codex_public_status_payload(flow_id, flow)
2026-05-04 21:07:16 +00:00
def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
"""Remove device codes, authorization codes, and token material from a flow dict in place."""
2026-05-04 21:07:16 +00:00
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:
"""Expire pending flows past their deadline and purge terminal flows older than 300 seconds from memory."""
2026-05-04 21:07:16 +00:00
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)
2026-05-04 21:07:16 +00:00
def _spawn_codex_oauth_worker(flow_id: str) -> None:
"""Launch a daemon thread that drives the Codex device-code polling and token exchange loop."""
2026-05-04 21:07:16 +00:00
worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True)
worker.start()
2026-05-04 21:07:16 +00:00
def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
"""Update a flow's status under the lock, then strip sensitive fields on terminal transitions."""
2026-05-04 21:07:16 +00:00
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:
"""Drive the Codex device-code polling loop until the user authorizes, the flow cancels, or it expires."""
2026-05-04 21:07:16 +00:00
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:
2026-05-04 21:07:16 +00:00
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)
fix(oauth): honor cancel during Codex device-token exchange (follow-up to #1652) The Codex OAuth onboarding worker introduced in #1652 had a cancel-vs-worker race: a `cancel_onboarding_oauth_flow` request that arrived while the worker was mid-network-call (between the `live = dict(...)` snapshot and the next status check) would be silently overridden: 1. User clicks Cancel → server sets flow.status = "cancelled" and drops sensitive lifecycle fields under the lock. 2. Worker is mid-`_poll_codex_authorization` / `_exchange_codex_authorization` using the local `live` snapshot it captured before the cancel. 3. Worker calls `_persist_codex_credentials(...)` — auth.json gets written. 4. Worker calls `_set_flow_status(flow_id, "success")` — overrides the cancelled status. Net effect: the user's explicit cancel is ignored, credentials are persisted, and the UI reports success. Reproduced with a behavioural harness that drove a real worker thread against patched network helpers and confirmed: pre-fix : flow status `success`, auth.json written despite cancel post-fix: flow status `cancelled`, auth.json NOT written The fix re-checks the flow status under `_OAUTH_FLOWS_LOCK` after the token exchange completes and before persisting. If the status is no longer `pending`, the worker exits without persisting credentials and without overwriting the terminal status. Regression test `test_cancel_during_token_exchange_does_not_persist_credentials` drives the worker against threading.Event-gated network stubs to reproduce the race deterministically and lock the new invariant. Trace verified against fresh hermes-agent tarball — credential_pool entry shape (`auth_type=oauth`, `source=manual:device_code`, `priority=0`, base_url) remains compatible with `agent.credential_pool.load_pool("openai-codex")` and the agent CLI's `_save_codex_tokens` legacy fallback path. Tests: - 10/10 in tests/test_issue1362_codex_oauth_onboarding.py - Full suite: 4230 passed, 57 skipped, 3 xpassed, 0 failed in 33.82s Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:49:38 +00:00
# Re-check status under lock before persisting: a cancel/expire that
# raced with the device-token + token-exchange network calls must
# win, so we don't persist credentials the user explicitly aborted.
with _OAUTH_FLOWS_LOCK:
current = _OAUTH_FLOWS.get(flow_id)
if not current or current.get("status") != "pending":
return
2026-05-04 21:07:16 +00:00
_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_anthropic_flow(hermes_home: Path) -> dict[str, Any]:
"""Start or immediately complete the Anthropic credential-linking flow."""
creds = _read_claude_code_credentials()
flow_id = uuid.uuid4().hex
if creds:
# Credentials already exist — link and return success immediately.
_link_anthropic_credentials(hermes_home)
flow = {
"provider": "anthropic",
"status": "success",
"hermes_home": str(hermes_home),
"created_at": time.time(),
"updated_at": time.time(),
}
with _OAUTH_FLOWS_LOCK:
_OAUTH_FLOWS[flow_id] = flow
return _public_start_payload(flow_id, flow)
# No credentials found — create a pending flow that polls for them.
expires_at = time.time() + ANTHROPIC_FLOW_MAX_WAIT_SECONDS
flow = {
"provider": "anthropic",
"status": "pending",
"expires_at": expires_at,
"poll_interval_seconds": ANTHROPIC_CREDENTIAL_POLL_SECONDS,
"hermes_home": str(hermes_home),
"created_at": time.time(),
"updated_at": time.time(),
}
with _OAUTH_FLOWS_LOCK:
existing = _pending_oauth_flow_for_locked("anthropic", hermes_home)
if existing is not None:
flow_id, flow = existing
return _public_start_payload(flow_id, flow)
_OAUTH_FLOWS[flow_id] = flow
_spawn_anthropic_credential_worker(flow_id)
return _public_start_payload(flow_id, flow)
2026-05-04 21:07:16 +00:00
def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
"""Start the supported onboarding OAuth flow.
Supports OpenAI Codex (device-code flow) and Anthropic/Claude Code
(credential-linking flow). Other providers are rejected.
2026-05-04 21:07:16 +00:00
"""
_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 and Anthropic/Claude OAuth are supported "
"in WebUI onboarding right now"
)
2026-05-04 21:07:16 +00:00
raise ValueError("provider is required")
# Normalize Claude aliases to canonical "anthropic"
if provider in _ANTHROPIC_PROVIDER_ALIASES:
return _start_anthropic_flow(_get_active_hermes_home())
# Codex flow
2026-05-04 21:07:16 +00:00
hermes_home = _get_active_hermes_home()
# Serialize check -> device-code request -> flow insertion -> worker spawn
# for this provider/profile. The global flow lock is still held only for
# short in-memory checks/inserts, so unrelated polling/status/cleanup paths
# are not blocked by the slow provider request.
with _oauth_start_lock("openai-codex", hermes_home):
with _OAUTH_FLOWS_LOCK:
existing = _pending_oauth_flow_for_locked("openai-codex", hermes_home)
if existing is not None:
flow_id, flow = existing
return _public_start_payload(flow_id, flow)
try:
device = _request_codex_user_code()
except Exception as exc:
raise RuntimeError(f"Failed to start Codex OAuth: {exc}") from exc
2026-05-04 21:07:16 +00:00
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")
2026-05-04 21:07:16 +00:00
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:
existing = _pending_oauth_flow_for_locked("openai-codex", hermes_home)
if existing is not None:
flow_id, flow = existing
return _public_start_payload(flow_id, flow)
_OAUTH_FLOWS[flow_id] = flow
_spawn_codex_oauth_worker(flow_id)
return _public_start_payload(flow_id, flow)
2026-05-04 21:07:16 +00:00
def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
"""Return the current browser-safe status for an in-flight OAuth flow, expiring it if past its deadline."""
2026-05-04 21:07:16 +00:00
_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]:
"""Cancel a pending OAuth flow by flow_id and return the final status payload."""
2026-05-04 21:07:16 +00:00
fid = str((body or {}).get("flow_id") or "").strip()
if not fid:
raise ValueError("flow_id is required")
requested_provider = _normalize_onboarding_oauth_provider(str((body or {}).get("provider") or ""))
if requested_provider not in {"openai-codex", "anthropic"}:
requested_provider = "openai-codex"
2026-05-04 21:07:16 +00:00
with _OAUTH_FLOWS_LOCK:
flow = _OAUTH_FLOWS.get(fid)
if not flow:
return {"ok": True, "provider": requested_provider, "flow_id": fid, "status": "cancelled"}
2026-05-04 21:07:16 +00:00
if flow.get("status") == "pending":
flow["status"] = "cancelled"
flow["updated_at"] = time.time()
_drop_sensitive_flow_fields(flow)
result = _public_status_payload(fid, dict(flow))
return result
2026-05-04 21:07:16 +00:00
# Backward-compatible names from the abandoned spike. They intentionally do not
# expose provider device secrets to callers anymore.
def start_codex_device_code():
"""Backward-compatible shim: start a Codex device-code flow via start_onboarding_oauth_flow."""
2026-05-04 21:07:16 +00:00
return start_onboarding_oauth_flow({"provider": "openai-codex"})
def poll_codex_token(device_code, interval=5):
"""Backward-compatible stub that always yields an error directing callers to the /api/onboarding/oauth/poll endpoint."""
2026-05-04 21:07:16 +00:00
yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"}