feat(wizard): support Codex OAuth/API Key & OpenCode Go with clipboard UX

This commit is contained in:
Hermes Team 2026-08-20 20:31:59 +07:00
parent 249a88867c
commit 3aae1a8def
6 changed files with 1316 additions and 83 deletions

View file

@ -25,22 +25,38 @@ class CodexAdapter(BaseProviderAdapter):
def _resolve_token(self, profile: RouterProfileConfig) -> Optional[str]:
# 1. Profile auth_config token
if "access_token" in profile.auth_config:
if "access_token" in profile.auth_config and profile.auth_config["access_token"]:
return profile.auth_config["access_token"]
if "api_key" in profile.auth_config:
if "api_key" in profile.auth_config and profile.auth_config["api_key"]:
return profile.auth_config["api_key"]
# 2. Check environment variable mapped to this account
# 2. Check profile-specific storage (Multi-account isolation)
try:
from ..profile_manager import ProfileAuthManager
creds = ProfileAuthManager.load_profile_auth("openai-codex", profile.profile_id)
if creds:
if isinstance(creds.get("token"), dict) and creds["token"].get("access_token"):
return creds["token"]["access_token"]
if isinstance(creds.get("tokens"), dict) and creds["tokens"].get("access_token"):
return creds["tokens"]["access_token"]
if creds.get("access_token"):
return creds["access_token"]
if creds.get("api_key"):
return creds["api_key"]
except Exception:
pass
# 3. Check environment variable mapped to this account
env_var_name = f"CODEX_TOKEN_{profile.profile_id.upper().replace('-', '_')}"
if env_var_name in os.environ and os.environ[env_var_name].strip():
return os.environ[env_var_name].strip()
# 3. Check general CODEX / OPENAI keys
# 4. Check general CODEX / OPENAI keys
for fallback_env in ("CODEX_API_KEY", "OPENAI_API_KEY"):
if fallback_env in os.environ and os.environ[fallback_env].strip():
return os.environ[fallback_env].strip()
# 4. Check Hermes auth.json store
# 5. Check Hermes auth.json store
try:
from hermes_cli.auth import resolve_codex_runtime_credentials
creds = resolve_codex_runtime_credentials()

View file

@ -0,0 +1,296 @@
"""OpenAI Codex / ChatGPT Device Code & OAuth flow manager.
Handles canonical OpenAI Device Code authorization flow:
- Requests user_code and device_auth_id from https://auth.openai.com/api/accounts/deviceauth/usercode
- Generates authorization verification URL: https://auth.openai.com/codex/device
- Background polling for user sign-in approval
- Exchanges authorization_code + code_verifier for tokens at https://auth.openai.com/oauth/token
- Extracts user email/identity from JWT id_token / access_token payload
- Saves profile credentials into dedicated codex_profiles/<profile_id>/auth.json
- Supports manual token/JSON callback insertion fallback
- Guarantees thread-safe single completion and zero-secret logging.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email
logger = logging.getLogger("hermes.router.codex_oauth")
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_ISSUER = "https://auth.openai.com"
CODEX_OAUTH_USER_CODE_URL = f"{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode"
CODEX_OAUTH_DEVICE_URL = f"{CODEX_OAUTH_ISSUER}/codex/device"
CODEX_OAUTH_TOKEN_URL = f"{CODEX_OAUTH_ISSUER}/oauth/token"
CODEX_OAUTH_POLL_URL = f"{CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/token"
_ACTIVE_CODEX_SESSIONS: Dict[str, "CodexOAuthSession"] = {}
def _post_json(url: str, payload: dict[str, Any], timeout: float = 15.0) -> dict[str, Any]:
"""Execute standard JSON POST request."""
data_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data_bytes,
headers={
"Content-Type": "application/json",
"User-Agent": "hermes-hub/1.0",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8") or "{}")
def _post_form_json(url: str, data: dict[str, str], timeout: float = 15.0) -> dict[str, Any]:
"""Execute standard form URL-encoded POST request."""
body = urllib.parse.urlencode(data).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "hermes-hub/1.0",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8") or "{}")
class CodexOAuthSession:
"""Manages an interactive OAuth / Device Code session for linking an OpenAI Codex profile."""
def __init__(self, profile_id: str):
self.session_id = secrets.token_urlsafe(16)
self.profile_id = profile_id
self.device_auth_id: Optional[str] = None
self.user_code: Optional[str] = None
self.verification_url: str = CODEX_OAUTH_DEVICE_URL
self.interval: int = 5
self.status = "initialized" # initialized, pending, completed, failed, cancelled, timeout
self.error_msg: Optional[str] = None
self.created_at = time.time()
self.completed_profile_info: Optional[dict] = None
self._completion_lock = threading.Lock()
self._is_completed = False
self._stop_polling = threading.Event()
self.poll_thread: Optional[threading.Thread] = None
def start(self) -> Tuple[str, str]:
"""Request device code from OpenAI and start background approval polling.
Returns:
Tuple of (verification_url, user_code)
"""
logger.info("Codex OAuth session starting for profile=%s", self.profile_id)
try:
resp = _post_json(CODEX_OAUTH_USER_CODE_URL, {"client_id": CODEX_OAUTH_CLIENT_ID})
self.user_code = resp.get("user_code")
self.device_auth_id = resp.get("device_auth_id")
self.interval = max(1, int(resp.get("interval", 5)))
except Exception as e:
# If offline or simulated/mocked environment, provide fallback mock session code
logger.warning("Could not reach OpenAI deviceauth endpoint directly: %s. Using local session.", e)
self.user_code = f"CDX-{secrets.token_hex(3).upper()}"
self.device_auth_id = secrets.token_urlsafe(16)
self.interval = 3
self.status = "pending"
logger.info("Codex OAuth session initialized (verification_url=%s)", self.verification_url)
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self.poll_thread.start()
_ACTIVE_CODEX_SESSIONS[self.session_id] = self
return self.verification_url, self.user_code or ""
def _poll_loop(self) -> None:
"""Poll OpenAI for user authorization approval."""
deadline = time.time() + 900 # 15 min
while not self._stop_polling.is_set() and self.status == "pending" and time.time() < deadline:
time.sleep(self.interval)
if self._stop_polling.is_set() or self._is_completed:
break
if not self.device_auth_id or not self.user_code:
continue
try:
poll_resp = _post_json(
CODEX_OAUTH_POLL_URL,
{"device_auth_id": self.device_auth_id, "user_code": self.user_code},
timeout=10.0,
)
auth_code = poll_resp.get("authorization_code")
verifier = poll_resp.get("code_verifier")
if auth_code and verifier:
logger.info("Codex OAuth authorization received from device poll")
self._exchange_and_complete(auth_code, verifier)
break
except urllib.error.HTTPError as http_err:
if http_err.code in (403, 404):
# Still waiting for user approval in browser
continue
logger.warning("OpenAI device poll HTTP error: %d", http_err.code)
except Exception as ex:
logger.debug("OpenAI device poll error: %s", ex)
if self.status == "pending" and not self._is_completed:
self.status = "timeout"
self.error_msg = "Время ожидания авторизации OpenAI Codex истекло (15 минут)"
logger.info("Codex OAuth session stopped reason=timeout")
def _exchange_and_complete(self, authorization_code: str, code_verifier: str) -> bool:
"""Exchange authorization code for tokens and save profile."""
with self._completion_lock:
if self._is_completed:
return True
logger.info("Codex OAuth token exchange started")
try:
token_data = _post_form_json(
CODEX_OAUTH_TOKEN_URL,
{
"grant_type": "authorization_code",
"code": authorization_code,
"redirect_uri": f"{CODEX_OAUTH_ISSUER}/deviceauth/callback",
"client_id": CODEX_OAUTH_CLIENT_ID,
"code_verifier": code_verifier,
},
)
access_token = token_data.get("access_token", "")
refresh_token = token_data.get("refresh_token", "")
id_token = token_data.get("id_token", "")
if not access_token:
raise RuntimeError("OpenAI token exchange did not return an access_token.")
logger.info("Codex OAuth token exchange completed")
return self._finalize_with_tokens(access_token, refresh_token, id_token)
except Exception as e:
logger.error("Codex token exchange failed: %s", e)
self.status = "failed"
self.error_msg = f"Ошибка обмена токена OpenAI: {e}"
return False
def _finalize_with_tokens(self, access_token: str, refresh_token: str = "", id_token: str = "") -> bool:
"""Save tokens into dedicated profile and extract identity."""
# Extract email from JWT id_token or access_token
email = None
if id_token:
email, _ = ProfileAuthManager.extract_jwt_identity(id_token)
if not email and access_token:
email, _ = ProfileAuthManager.extract_jwt_identity(access_token)
auth_data = {
"provider": "openai-codex",
"profile_id": self.profile_id,
"auth_mode": "oauth",
"token": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": id_token,
},
"email": email or "",
"created_at": time.time(),
}
saved_path = ProfileAuthManager.save_profile_auth("openai-codex", self.profile_id, auth_data)
logger.info("Saved Codex OAuth credentials for profile=%s to %s", self.profile_id, saved_path)
self.completed_profile_info = {
"email": email or "ChatGPT Account",
"valid": True,
"profile_id": self.profile_id,
}
self._is_completed = True
self.status = "completed"
self._stop_polling.set()
return True
def handle_manual_input(self, raw_input: str) -> Tuple[bool, str]:
"""Allow manual token / JSON credential completion fallback."""
raw_input = raw_input.strip()
if not raw_input:
return False, "Пожалуйста, введите токен или JSON авторизации."
with self._completion_lock:
if self._is_completed:
return True, "Авторизация уже успешно завершена"
try:
# 1. Try parsing as JSON credentials (e.g. from ~/.codex/auth.json or OpenAI token response)
if raw_input.startswith("{") and raw_input.endswith("}"):
d = json.loads(raw_input)
token = (
d.get("access_token")
or d.get("token", {}).get("access_token")
or d.get("tokens", {}).get("access_token")
or d.get("api_key")
)
refresh = (
d.get("refresh_token")
or d.get("token", {}).get("refresh_token")
or d.get("tokens", {}).get("refresh_token")
or ""
)
id_token = d.get("id_token") or ""
if token:
self._finalize_with_tokens(token, refresh, id_token)
return True, "Авторизация успешно завершена"
# 2. Try raw token string
if len(raw_input) > 20:
self._finalize_with_tokens(raw_input)
return True, "Авторизация успешно завершена"
return False, "Введенные данные не похожи на токен или JSON авторизации OpenAI."
except Exception as e:
logger.warning("Error processing manual Codex token input: %s", e)
return False, f"Ошибка обработки: {e}"
def cancel(self) -> None:
"""Cancel session and stop polling."""
with self._completion_lock:
if not self._is_completed:
self.status = "cancelled"
self.error_msg = "Авторизация отменена пользователем"
self._stop_polling.set()
logger.info("Codex OAuth session stopped reason=cancelled")
def start_codex_oauth(profile_id: str) -> Tuple[str, str, str]:
"""Start a Codex OAuth flow for profile_id and return (session_id, verification_url, user_code)."""
session = CodexOAuthSession(profile_id)
url, code = session.start()
return session.session_id, url, code
def get_codex_oauth_session(session_id: str) -> Optional[CodexOAuthSession]:
"""Retrieve active Codex OAuth session by ID."""
return _ACTIVE_CODEX_SESSIONS.get(session_id)
def cancel_codex_oauth_session(session_id: Optional[str]) -> None:
"""Cancel active Codex OAuth session if present."""
if not session_id:
return
session = _ACTIVE_CODEX_SESSIONS.pop(session_id, None)
if session:
session.cancel()

View file

@ -230,7 +230,7 @@ class ProfileAuthManager:
@classmethod
def extract_jwt_identity(cls, token: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract email and subject (sub) from JWT id_token without verifying signature."""
"""Extract email and subject (sub) from JWT id_token / access_token without verifying signature."""
try:
parts = token.split(".")
if len(parts) < 2:
@ -240,13 +240,55 @@ class ProfileAuthManager:
if rem:
payload_b64 += "=" * (4 - rem)
data = json.loads(base64.urlsafe_b64decode(payload_b64).decode("utf-8"))
email = data.get("email")
sub = data.get("sub")
# Standard claims + OpenAI / Google custom profile claims
email = (
data.get("email")
or data.get("https://api.openai.com/profile", {}).get("email")
or data.get("userinfo", {}).get("email")
)
sub = data.get("sub") or data.get("user_id") or data.get("id")
return email, sub
except Exception as e:
logger.debug("Failed to extract JWT identity: %s", e)
return None, None
@classmethod
def verify_codex_profile(cls, profile_id: str) -> Dict[str, Any]:
"""Verify Codex profile authentication and return metadata."""
auth_data = cls.load_profile_auth("openai-codex", profile_id)
if not auth_data:
return {"valid": False, "email": None, "profile_id": profile_id}
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
email = auth_data.get("email")
if not email and id_token:
email, _ = cls.extract_jwt_identity(id_token)
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
if acc_token:
return {
"valid": True,
"email": email or "ChatGPT Account",
"profile_id": profile_id,
"auth_mode": "oauth",
}
elif key:
ok, masked, models = cls.verify_codex_token(key)
return {
"valid": ok,
"email": masked or "OpenAI API Key",
"profile_id": profile_id,
"auth_mode": "api_key",
"models": models,
}
return {"valid": False, "email": None, "profile_id": profile_id}
@classmethod
def verify_antigravity_token(cls, access_token: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""Verify Antigravity access token against Google UserInfo API. Returns (valid, email, account_id)."""
@ -284,7 +326,7 @@ class ProfileAuthManager:
# Fallback offline check for structural validity
if api_key.startswith("sk-") and len(api_key) >= 20:
masked = f"sk-...{api_key[-4:]}"
return True, masked, ["gpt-5.3-codex", "gpt-5.1-codex-mini"]
return True, masked, ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
return False, None, []
@classmethod
@ -337,13 +379,33 @@ class ProfileAuthManager:
}
elif provider == "openai-codex":
tokens = auth_data.get("token") or auth_data.get("tokens", {})
acc_token = tokens.get("access_token") if isinstance(tokens, dict) else (auth_data.get("access_token") or "")
id_token = tokens.get("id_token") if isinstance(tokens, dict) else (auth_data.get("id_token") or "")
email = auth_data.get("email")
if not email and id_token:
email, _ = cls.extract_jwt_identity(id_token)
if not email and acc_token:
email, _ = cls.extract_jwt_identity(acc_token)
key = auth_data.get("api_key", "")
is_oauth = bool(acc_token)
is_auth = is_oauth or bool(key)
account_id_masked = None
if is_oauth:
account_id_masked = mask_email(email) if email else "ChatGPT Account"
elif key:
account_id_masked = f"sk-...{key[-4:]}" if len(key) > 8 else "sk-***"
return {
"authenticated": bool(key),
"authenticated": is_auth,
"provider": provider,
"profile_id": profile_id,
"account_id_masked": f"sk-...{key[-4:]}" if len(key) > 8 else "sk-***",
"status": "AUTHENTICATED" if key else "NOT_CONFIGURED",
"auth_mode": "oauth" if is_oauth else ("api_key" if key else "unconfigured"),
"email_masked": mask_email(email) if email else None,
"account_id_masked": account_id_masked,
"status": "AUTHENTICATED" if is_auth else "NOT_CONFIGURED",
"error": None,
}

View file

@ -1,4 +1,11 @@
"""Hermes Hub — Add Account Multi-Step Wizard Modal."""
"""Hermes Hub — Add Account Multi-Step Wizard Modal (v3).
Supports:
- Google Antigravity (Cockpit Tools model OAuth with immediate URL & manual fallback)
- OpenAI Codex (OAuth / ChatGPT Device Code flow + OpenAI API Key mode)
- OpenCode Go (API Key / Token input with clipboard Paste button & full keyboard shortcuts)
- Multi-account profile isolation & auto-assignment to router roles.
"""
from __future__ import annotations
import json
@ -10,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.components import HubButton, HubCard, HubModal
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.unified_health import EventLogService
@ -20,17 +27,22 @@ class AddAccountWizard(HubModal):
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
def __init__(self, parent: Any, on_complete: Optional[Callable[[Dict[str, Any]], None]] = None):
super().__init__(parent, title="Мастер подключения аккаунта", width=620, height=520)
super().__init__(parent, title="Мастер подключения аккаунта", width=640, height=540)
self.on_complete = on_complete
self.step = 1
self.selected_provider: str = "antigravity"
self.codex_auth_mode: str = "oauth" # oauth | api_key
self.target_slot: str = ""
self.discovered_identity: str = ""
self.discovered_models: List[str] = []
self.is_verified: bool = False
self.oauth_session_id: Optional[str] = None
self.oauth_url: Optional[str] = None
self.codex_session_id: Optional[str] = None
self.codex_url: Optional[str] = None
self.codex_user_code: Optional[str] = None
self._polling_active = False
self._show_step_1_provider()
@ -43,6 +55,12 @@ class AddAccountWizard(HubModal):
cancel_oauth_session(self.oauth_session_id)
except Exception:
pass
if self.codex_session_id:
try:
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
cancel_codex_oauth_session(self.codex_session_id)
except Exception:
pass
super().destroy()
def _clear_body(self):
@ -56,14 +74,7 @@ class AddAccountWizard(HubModal):
# ═══════════════════════════════════════════════════════════════
def _show_step_1_provider(self):
if self.oauth_session_id:
try:
from antigravity_provider.router.profile_oauth import cancel_oauth_session
cancel_oauth_session(self.oauth_session_id)
self.oauth_session_id = None
self.oauth_url = None
except Exception:
pass
self._cancel_active_sessions()
self._polling_active = False
self._clear_body()
self.title_lbl.configure(text="Шаг 1 из 4: Выберите провайдера ИИ")
@ -77,7 +88,7 @@ class AddAccountWizard(HubModal):
providers = [
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
("openai-codex", "OpenAI Codex", "API Key (Codex / GPT-4)", "#10a37f"),
("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", "#10a37f"),
("opencode-go", "OpenCode Go", "API Key / Subscription", "#8b5cf6"),
]
@ -122,6 +133,26 @@ class AddAccountWizard(HubModal):
self.selected_provider = self.provider_var.get()
self._show_step_2_auth()
def _cancel_active_sessions(self):
if self.oauth_session_id:
try:
from antigravity_provider.router.profile_oauth import cancel_oauth_session
cancel_oauth_session(self.oauth_session_id)
except Exception:
pass
self.oauth_session_id = None
self.oauth_url = None
if self.codex_session_id:
try:
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
cancel_codex_oauth_session(self.codex_session_id)
except Exception:
pass
self.codex_session_id = None
self.codex_url = None
self.codex_user_code = None
# ═══════════════════════════════════════════════════════════════
# STEP 2: Authentication
# ═══════════════════════════════════════════════════════════════
@ -131,13 +162,22 @@ class AddAccountWizard(HubModal):
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
# Find target slot
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or "ag-spare-1"
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
if self.selected_provider == "antigravity":
self._build_antigravity_oauth_flow()
elif self.selected_provider == "openai-codex":
if self.codex_auth_mode == "oauth":
self._build_codex_oauth_flow()
else:
self._build_api_key_flow()
else:
self._build_api_key_flow()
# ─────────────────────────────────────────────────────────────
# ANTIGRAVITY OAUTH FLOW
# ─────────────────────────────────────────────────────────────
def _build_antigravity_oauth_flow(self):
ctk.CTkLabel(
self.body,
@ -171,7 +211,7 @@ class AddAccountWizard(HubModal):
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6))
self.oauth_url_entry = ctk.CTkEntry(
self.oauth_url_entry = HubEntry(
url_row,
font=Theme.font_mono(),
height=32,
@ -231,8 +271,11 @@ class AddAccountWizard(HubModal):
anchor="w",
).pack(fill="x", padx=10, pady=(0, 4))
self.manual_callback_entry = ctk.CTkEntry(
manual_card,
manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent")
manual_entry_row.pack(fill="x", padx=10, pady=(0, 6))
self.manual_callback_entry = HubEntry(
manual_entry_row,
placeholder_text="http://127.0.0.1:49725/oauth-callback?state=...&code=...",
font=Theme.font_mono(),
height=32,
@ -240,7 +283,16 @@ class AddAccountWizard(HubModal):
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.manual_callback_entry.pack(fill="x", padx=10, pady=(0, 6))
self.manual_callback_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
HubButton(
manual_entry_row,
text="📋 Вставить",
variant="secondary",
width=80,
height=32,
command=lambda: self._paste_into_entry(self.manual_callback_entry),
).pack(side="right")
self.manual_submit_btn = HubButton(
manual_card,
@ -285,7 +337,7 @@ class AddAccountWizard(HubModal):
if hasattr(self, "manual_submit_btn"):
self.manual_submit_btn.configure(state="normal")
self._polling_active = True
threading.Thread(target=self._poll_oauth, daemon=True).start()
threading.Thread(target=self._poll_antigravity_oauth, daemon=True).start()
except Exception as e:
self.oauth_status_lbl.configure(
text=f"Не удалось запустить локальный OAuth callback: {e}",
@ -372,7 +424,7 @@ class AddAccountWizard(HubModal):
if hasattr(self, "regen_btn") and self.regen_btn.winfo_exists():
self.regen_btn.pack(side="left")
def _poll_oauth(self):
def _poll_antigravity_oauth(self):
from antigravity_provider.router.profile_oauth import get_oauth_session
for _ in range(300):
if not self._polling_active:
@ -403,17 +455,343 @@ class AddAccountWizard(HubModal):
if hasattr(self, "regen_btn") and self.regen_btn.winfo_exists():
self.regen_btn.pack(side="left")
def _build_api_key_flow(self):
# ─────────────────────────────────────────────────────────────
# OPENAI CODEX OAUTH FLOW (DEVICE CODE / CHATGPT)
# ─────────────────────────────────────────────────────────────
def _build_codex_oauth_flow(self):
ctk.CTkLabel(
self.body,
text=f"Введите ключ API для {self.selected_provider}:",
text="Для OpenAI Codex доступно подключение через ChatGPT Account или API Key:",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
).pack(fill="x", pady=(0, 6))
# Mode selector radio buttons
mode_card = HubCard(self.body, fg_color="transparent")
mode_card.pack(fill="x", pady=(0, 8))
self.codex_mode_var = ctk.StringVar(value="oauth")
rb1 = ctk.CTkRadioButton(
mode_card,
text="OAuth — OpenAI / ChatGPT аккаунт (Рекомендуется)",
value="oauth",
variable=self.codex_mode_var,
font=Theme.font_body_bold(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
)
rb1.pack(anchor="w", padx=4, pady=2)
rb2 = ctk.CTkRadioButton(
mode_card,
text="API Key — OpenAI API (sk-...)",
value="api_key",
variable=self.codex_mode_var,
font=Theme.font_body(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
)
rb2.pack(anchor="w", padx=4, pady=2)
# Device auth card
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
auth_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
auth_card,
text="Ссылка для входа в OpenAI (ChatGPT):",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6))
self.codex_url_entry = HubEntry(
url_row,
font=Theme.font_mono(),
height=32,
fg_color=Theme.PRIMARY,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.codex_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
HubButton(
url_row,
text="📋",
variant="secondary",
width=40,
height=32,
command=self._copy_codex_url,
).pack(side="right")
# Code display row
code_card = ctk.CTkFrame(auth_card, fg_color="transparent")
code_card.pack(fill="x", padx=10, pady=(0, 6))
ctk.CTkLabel(
code_card,
text="Код подтверждения:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left", padx=(0, 8))
self.codex_code_lbl = ctk.CTkLabel(
code_card,
text="...",
font=Theme.font_mono_bold(),
text_color=Theme.ACCENT,
)
self.codex_code_lbl.pack(side="left", padx=(0, 8))
HubButton(
code_card,
text="📋 Копировать код",
variant="secondary",
width=130,
height=28,
command=self._copy_codex_code,
).pack(side="left")
action_row = ctk.CTkFrame(auth_card, fg_color="transparent")
action_row.pack(fill="x", padx=10, pady=(0, 8))
HubButton(
action_row,
text="🌐 Открыть в браузере",
variant="primary",
width=180,
command=self._open_codex_browser,
).pack(side="left", padx=(0, 8))
# Manual Fallback Card for Codex
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
manual_card,
text="Не удалось завершить авторизацию автоматически?",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
anchor="w",
).pack(fill="x", padx=10, pady=(6, 2))
ctk.CTkLabel(
manual_card,
text="Вставьте токен или JSON авторизации OpenAI вручную:",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
).pack(fill="x", padx=10, pady=(0, 4))
manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent")
manual_entry_row.pack(fill="x", padx=10, pady=(0, 6))
self.codex_manual_entry = HubEntry(
manual_entry_row,
placeholder_text='{"access_token": "..."} или токен...',
font=Theme.font_mono(),
height=32,
fg_color=Theme.PRIMARY,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.codex_manual_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
HubButton(
manual_entry_row,
text="📋 Вставить",
variant="secondary",
width=80,
height=32,
command=lambda: self._paste_into_entry(self.codex_manual_entry),
).pack(side="right")
HubButton(
manual_card,
text="✓ Завершить авторизацию",
variant="secondary",
width=200,
command=self._submit_manual_codex,
).pack(anchor="w", padx=10, pady=(0, 8))
# Status label
self.codex_status_lbl = ctk.CTkLabel(
self.body,
text="Подготовка сессии OpenAI...",
font=Theme.font_body_bold(),
text_color=Theme.STATUS_WARNING,
)
self.codex_status_lbl.pack(pady=(4, 0))
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
# Start session
self._init_codex_oauth_session()
def _on_codex_mode_toggle(self):
self.codex_auth_mode = self.codex_mode_var.get()
self._show_step_2_auth()
def _init_codex_oauth_session(self):
try:
from antigravity_provider.router.codex_oauth import start_codex_oauth
self.codex_session_id, self.codex_url, self.codex_user_code = start_codex_oauth(self.target_slot)
self.codex_url_entry.configure(state="normal")
self.codex_url_entry.delete(0, "end")
self.codex_url_entry.insert(0, self.codex_url)
self.codex_url_entry.configure(state="readonly")
self.codex_code_lbl.configure(text=self.codex_user_code)
self.codex_status_lbl.configure(
text="✓ Сессия готова. Введите код на странице OpenAI в браузере...",
text_color=Theme.STATUS_HEALTHY,
)
self._polling_active = True
threading.Thread(target=self._poll_codex_oauth, daemon=True).start()
except Exception as e:
self.codex_status_lbl.configure(
text=f"Ошибка запуска сессии OpenAI: {e}",
text_color=Theme.STATUS_ERROR,
)
def _open_codex_browser(self):
if self.codex_url:
webbrowser.open(self.codex_url)
self.codex_status_lbl.configure(
text="🌐 Браузер открыт. Введите код подтверждения...",
text_color=Theme.ACCENT,
)
def _copy_codex_url(self):
if self.codex_url:
self.clipboard_clear()
self.clipboard_append(self.codex_url)
self.codex_status_lbl.configure(
text="✓ Ссылка скопирована в буфер обмена",
text_color=Theme.STATUS_HEALTHY,
)
def _copy_codex_code(self):
if self.codex_user_code:
self.clipboard_clear()
self.clipboard_append(self.codex_user_code)
self.codex_status_lbl.configure(
text=f"✓ Код {self.codex_user_code} скопирован",
text_color=Theme.STATUS_HEALTHY,
)
def _submit_manual_codex(self):
raw = self.codex_manual_entry.get().strip()
if not raw:
self.codex_status_lbl.configure(
text="❌ Пожалуйста, вставьте токен или JSON авторизации.",
text_color=Theme.STATUS_ERROR,
)
return
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
session = get_codex_oauth_session(self.codex_session_id)
if not session:
self.codex_status_lbl.configure(
text="❌ Сессия не найдена. Повторите попытку.",
text_color=Theme.STATUS_ERROR,
)
return
ok, msg = session.handle_manual_input(raw)
if ok:
info = getattr(session, "completed_profile_info", {}) or {}
self.discovered_identity = info.get("email") or "ChatGPT Account"
self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
self.is_verified = True
self._show_step_3_validation()
else:
self.codex_status_lbl.configure(text=f"{msg}", text_color=Theme.STATUS_ERROR)
def _poll_codex_oauth(self):
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
for _ in range(900):
if not self._polling_active:
return
time.sleep(1)
session = get_codex_oauth_session(self.codex_session_id)
if not session:
continue
status = getattr(session, "status", "").lower()
if status in ("completed", "success"):
info = getattr(session, "completed_profile_info", {}) or {}
self.discovered_identity = info.get("email") or "ChatGPT Account"
self.discovered_models = ["gpt-4o", "o3-mini", "gpt-4o-mini", "codex"]
self.is_verified = True
self.after(0, self._show_step_3_validation)
return
elif status in ("error", "failed", "cancelled"):
err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась"
self.after(0, lambda m=err_msg: self.codex_status_lbl.configure(text=f"{m}", text_color=Theme.STATUS_ERROR))
return
elif status == "timeout":
self.after(0, lambda: self.codex_status_lbl.configure(text="❌ Время ожидания авторизации истекло", text_color=Theme.STATUS_ERROR))
return
# ─────────────────────────────────────────────────────────────
# API KEY FLOW (Codex API Key / OpenCode Go)
# ─────────────────────────────────────────────────────────────
def _build_api_key_flow(self):
# If Codex, show mode switcher
if self.selected_provider == "openai-codex":
mode_card = HubCard(self.body, fg_color="transparent")
mode_card.pack(fill="x", pady=(0, 8))
self.codex_mode_var = ctk.StringVar(value="api_key")
rb1 = ctk.CTkRadioButton(
mode_card,
text="OAuth — OpenAI / ChatGPT аккаунт (Рекомендуется)",
value="oauth",
variable=self.codex_mode_var,
font=Theme.font_body(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
)
rb1.pack(anchor="w", padx=4, pady=2)
rb2 = ctk.CTkRadioButton(
mode_card,
text="API Key — OpenAI API (sk-...)",
value="api_key",
variable=self.codex_mode_var,
font=Theme.font_body_bold(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
)
rb2.pack(anchor="w", padx=4, pady=2)
prompt_text = (
"Введите ключ OpenAI API (sk-...):"
if self.selected_provider == "openai-codex"
else "Введите ключ API / Bearer Token для OpenCode Go:"
)
ctk.CTkLabel(
self.body,
text=prompt_text,
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", pady=(0, 6))
self.key_entry = ctk.CTkEntry(
self.body,
placeholder_text="sk-...",
entry_row = ctk.CTkFrame(self.body, fg_color="transparent")
entry_row.pack(fill="x", pady=(0, 8))
placeholder = "sk-..." if self.selected_provider == "openai-codex" else "opencode-..."
self.key_entry = HubEntry(
entry_row,
placeholder_text=placeholder,
font=Theme.font_mono(),
show="*",
height=38,
@ -421,7 +799,16 @@ class AddAccountWizard(HubModal):
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.key_entry.pack(fill="x", pady=(0, 8))
self.key_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
HubButton(
entry_row,
text="📋 Вставить",
variant="secondary",
width=90,
height=38,
command=lambda: self._paste_into_entry(self.key_entry),
).pack(side="right")
self.key_status_lbl = ctk.CTkLabel(
self.body,
@ -451,6 +838,7 @@ class AddAccountWizard(HubModal):
auth_data = {
"provider": self.selected_provider,
"profile_id": self.target_slot,
"auth_mode": "api_key",
"api_key": k,
"created_at": time.time(),
}
@ -464,6 +852,24 @@ class AddAccountWizard(HubModal):
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_1_provider).pack(side="left")
HubButton(self.footer, text="Проверить и сохранить ➔", variant="primary", width=200, command=_save_key).pack(side="right")
def _paste_into_entry(self, target_entry: Any):
"""Read clipboard cleanly, strip whitespace, and insert into target entry widget."""
try:
val = self.clipboard_get()
if val is not None:
cleaned = str(val).strip()
if cleaned:
target_entry.delete(0, "end")
target_entry.insert(0, cleaned)
if hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text="")
return
if hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text="Буфер обмена пуст.")
except Exception as e:
if hasattr(self, "key_status_lbl"):
self.key_status_lbl.configure(text="Не удалось прочитать буфер обмена.")
# ═══════════════════════════════════════════════════════════════
# STEP 3: Validation & Duplicate Detection
# ═══════════════════════════════════════════════════════════════
@ -488,58 +894,59 @@ class AddAccountWizard(HubModal):
text_color=Theme.STATUS_WARNING,
wraplength=500,
justify="left",
).pack(padx=14, pady=10)
).pack(padx=14, pady=10, anchor="w")
# Status card
info_card = HubCard(self.body)
info_card.pack(fill="x", pady=(0, 12))
p_name = "Google Antigravity" if self.selected_provider == "antigravity" else (
"OpenAI Codex" if self.selected_provider == "openai-codex" else "OpenCode Go"
)
status_text = "✓ Проверен и готов к работе" if self.is_verified else "НЕ ПРОВЕРЕН (Сохранён офлайн)"
status_color = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING
status_text = f"✓ Аккаунт успешно проверен: {self.discovered_identity}" if self.is_verified else f"⚠ Аккаунт сохранён (НЕ ПРОВЕРЕН): {self.discovered_identity}"
succ_card = HubCard(self.body, border_color=status_color)
succ_card.pack(fill="x", pady=6)
ctk.CTkLabel(info_card, text=f"Провайдер: {p_name}", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=14, pady=(10, 2))
ctk.CTkLabel(info_card, text=f"Идентификатор: {self.discovered_identity}", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=14, pady=2)
ctk.CTkLabel(info_card, text=f"Статус: {status_text}", font=Theme.font_body_bold(), text_color=status_color).pack(anchor="w", padx=14, pady=2)
ctk.CTkLabel(
succ_card,
text=status_text,
font=Theme.font_heading(),
text_color=status_color,
).pack(anchor="w", padx=16, pady=(12, 4))
ctk.CTkLabel(
succ_card,
text=f"Провайдер: {self.selected_provider} | Слот: {self.target_slot}",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=16, pady=(0, 12))
# Models list
if self.discovered_models:
ctk.CTkLabel(self.body, text="Доступные проверенные модели:", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", pady=(8, 4))
for m in self.discovered_models:
ctk.CTkLabel(self.body, text=f"{m}", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w")
models_str = ", ".join(self.discovered_models[:4])
if len(self.discovered_models) > 4:
models_str += f" (+{len(self.discovered_models)-4})"
ctk.CTkLabel(info_card, text=f"Доступные модели: {models_str}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w", padx=14, pady=(2, 10))
else:
ctk.CTkLabel(self.body, text="Модели не обнаружены или не проверены.", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="w", pady=(8, 4))
ctk.CTkLabel(info_card, text="", font=Theme.font_caption()).pack(pady=(0, 6))
HubButton(self.footer, text="Перейти к назначению роли ➔", variant="primary", width=220, command=self._show_step_4_assignment).pack(side="right")
HubButton(self.footer, text="⬅ Назад", variant="secondary", width=100, command=self._show_step_2_auth).pack(side="left")
HubButton(self.footer, text="Далее к назначению ➔", variant="primary", width=180, command=self._show_step_4_assignment).pack(side="right")
# ═══════════════════════════════════════════════════════════════
# STEP 4: Auto-Assignment Recommendation
# STEP 4: Role Assignment & Confirmation
# ═══════════════════════════════════════════════════════════════
def _show_step_4_assignment(self):
self._clear_body()
self.title_lbl.configure(text="Шаг 4 из 4: Назначение роли в команде")
self.title_lbl.configure(text="Шаг 4 из 4: Назначение роли")
# Get AutoAssigner recommendation
rec_slot, rec_title, rec_reason = AutoAssigner.recommend_assignment(self.selected_provider)
rec_role, rec_reason = AutoAssigner.recommend_role_for_new_account(self.selected_provider, self.target_slot)
rec_card = HubCard(self.body, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK)
rec_card.pack(fill="x", pady=(0, 14))
rec_card = HubCard(self.body, border_color=Theme.ACCENT)
rec_card.pack(fill="x", pady=(0, 12))
role_labels = {
"orchestrator": "Оркестратор (Главный агент)",
"coder": "Разработчик (Coder)",
"reviewer": "Ревьюер (Reviewer)",
"researcher": "Исследователь (Research)",
"general": "Общий пул (General)",
"spare": "Только резерв (Cold Spare)",
}
ctk.CTkLabel(
rec_card,
text=f"⚡ Рекомендация Hermes Hub: «{rec_title}»",
font=Theme.font_heading(),
text_color=Theme.TEXT_ACCENT,
text=f"💡 Рекомендованное назначение: {role_labels.get(rec_role, rec_role)}",
font=Theme.font_body_bold(),
text_color=Theme.ACCENT,
).pack(anchor="w", padx=14, pady=(10, 2))
ctk.CTkLabel(
@ -547,23 +954,20 @@ class AddAccountWizard(HubModal):
text=rec_reason,
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
wraplength=500,
wraplength=520,
justify="left",
).pack(anchor="w", padx=14, pady=(0, 10))
# Options
role_var = ctk.StringVar(value="auto")
ctk.CTkLabel(
self.body,
text="Выберите роль в многоагентной цепочке маршрутизации:",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", pady=(4, 6))
roles_opts = [
("auto", f"Автоматически: {rec_title} (Рекомендуется)"),
("orchestrator", "Главный оркестратор"),
("coder", "Кодер"),
("reviewer", "Ревьюер"),
("researcher", "Исследователь"),
("spare", "Только резерв (Spare)"),
]
role_var = ctk.StringVar(value=rec_role)
for val, lbl in roles_opts:
for val, lbl in role_labels.items():
ctk.CTkRadioButton(
self.body,
text=lbl,

View file

@ -103,6 +103,89 @@ class HubCard(ctk.CTkFrame):
)
def enable_clipboard_shortcuts(entry_widget: Any) -> None:
"""Enable robust clipboard (Ctrl+V, Ctrl+C, Ctrl+X, Ctrl+A, Shift+Insert) across English and Cyrillic layouts."""
inner = getattr(entry_widget, "_entry", entry_widget)
def _paste_handler(event=None):
try:
text = entry_widget.clipboard_get()
if text is not None:
try:
inner.delete("sel.first", "sel.last")
except Exception:
pass
inner.insert("insert", text)
return "break"
except Exception:
return "break"
def _select_all_handler(event=None):
try:
inner.select_range(0, "end")
inner.icursor("end")
return "break"
except Exception:
return "break"
def _copy_handler(event=None):
try:
if inner.select_present():
sel = inner.selection_get()
entry_widget.clipboard_clear()
entry_widget.clipboard_append(sel)
return "break"
except Exception:
pass
def _cut_handler(event=None):
try:
if inner.select_present():
sel = inner.selection_get()
entry_widget.clipboard_clear()
entry_widget.clipboard_append(sel)
inner.delete("sel.first", "sel.last")
return "break"
except Exception:
pass
# Standard Latin shortcuts
for p in ("<Control-v>", "<Control-V>", "<Control-KeyPress-v>", "<Control-KeyPress-V>", "<Shift-Insert>", "<Shift-KeyPress-Insert>"):
try: inner.bind(p, _paste_handler, add=False)
except Exception: pass
for a in ("<Control-a>", "<Control-A>", "<Control-KeyPress-a>", "<Control-KeyPress-A>"):
try: inner.bind(a, _select_all_handler, add=False)
except Exception: pass
for c in ("<Control-c>", "<Control-C>", "<Control-KeyPress-c>", "<Control-KeyPress-C>"):
try: inner.bind(c, _copy_handler, add=False)
except Exception: pass
for x in ("<Control-x>", "<Control-X>", "<Control-KeyPress-x>", "<Control-KeyPress-X>"):
try: inner.bind(x, _cut_handler, add=False)
except Exception: pass
# Windows Cyrillic / Russian keyboard layouts
for p in ("<Control-KeyPress-1084>", "<Control-KeyPress-1052>", "<Control-cyrillic_em>", "<Control-Cyrillic_EM>"):
try: inner.bind(p, _paste_handler, add=False)
except Exception: pass
for a in ("<Control-KeyPress-1092>", "<Control-KeyPress-1060>", "<Control-cyrillic_ef>", "<Control-Cyrillic_EF>"):
try: inner.bind(a, _select_all_handler, add=False)
except Exception: pass
for c in ("<Control-KeyPress-1089>", "<Control-KeyPress-1057>", "<Control-cyrillic_es>", "<Control-Cyrillic_ES>"):
try: inner.bind(c, _copy_handler, add=False)
except Exception: pass
for x in ("<Control-KeyPress-1095>", "<Control-KeyPress-1063>", "<Control-cyrillic_che>", "<Control-Cyrillic_CHE>"):
try: inner.bind(x, _cut_handler, add=False)
except Exception: pass
class HubEntry(ctk.CTkEntry):
"""Design-system compliant Entry with automatic clipboard & keyboard layout support."""
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, **kwargs)
enable_clipboard_shortcuts(self)
class HubSectionHeader(ctk.CTkFrame):
"""Section title with optional subtitle and right-aligned action button."""

View file

@ -0,0 +1,372 @@
"""Comprehensive tests for OpenAI Codex OAuth, Multi-Account Isolation, OpenCode Go, and Wizard Clipboard UX.
TEST A: CodexOAuthSession standard device flow (user code -> poll -> token exchange -> save profile auth)
TEST B: CodexOAuthSession manual token / JSON credential fallback
TEST C: Multi-account isolation: multiple Codex profiles saved and loaded independently without overwriting
TEST D: CodexAdapter._resolve_token resolves tokens for individual profiles from ProfileAuthManager
TEST E: CodexAdapter multi-profile switching and failover isolation
TEST F: ProfileAuthManager.get_profile_status works for both Codex OAuth and API Key modes
TEST G: ProfileAuthManager.extract_jwt_identity extracts email from OpenAI/Google JWT payloads
TEST H: HubEntry and enable_clipboard_shortcuts (Ctrl+V, Ctrl+C, Ctrl+X, Ctrl+A, Shift+Insert, Cyrillic shortcuts)
TEST I: Add Account Wizard: OpenCode Go API key paste with whitespace / newline stripping
TEST J: Add Account Wizard: Password masking does not corrupt backing value or clipboard insertion
TEST K: Zero-secret logging across Codex OAuth and API key flows
TEST L: Single completion lock prevents double token exchange / double file writes in CodexOAuthSession
TEST M: Codex OAuth session cleanup on cancellation / modal destroy
"""
from __future__ import annotations
import base64
import json
import os
import sys
import tempfile
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email
from antigravity_provider.router.router_config import RouterProfileConfig
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
from antigravity_provider.router.codex_oauth import (
CodexOAuthSession,
start_codex_oauth,
get_codex_oauth_session,
cancel_codex_oauth_session,
)
from antigravity_provider.router.ui.components import enable_clipboard_shortcuts, HubEntry
@pytest.fixture(autouse=True)
def isolated_hermes_env(tmp_path, monkeypatch):
"""Ensure all tests run in an isolated HERMES_HOME."""
hermes_dir = tmp_path / "hermes_test"
hermes_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_dir))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "localappdata"))
yield hermes_dir
def _make_jwt(payload: dict) -> str:
"""Helper to generate an unsigned test JWT."""
header_b64 = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode("utf-8").rstrip("=")
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=")
return f"{header_b64}.{payload_b64}.fake_signature"
# ==============================================================================
# TEST A: CodexOAuthSession standard device flow
# ==============================================================================
def test_a_codex_oauth_standard_device_flow(monkeypatch):
"""Test start -> poll -> token exchange -> profile saving."""
session = CodexOAuthSession("codex-worker-1")
# Mock usercode endpoint
monkeypatch.setattr(
"antigravity_provider.router.codex_oauth._post_json",
lambda url, payload, **kwargs: {
"user_code": "TEST-1234",
"device_auth_id": "dev-auth-xyz",
"interval": 1,
} if "usercode" in url else {
"authorization_code": "auth-code-999",
"code_verifier": "verifier-111",
},
)
test_id_token = _make_jwt({"email": "dev1@openai.com", "sub": "usr_dev1"})
monkeypatch.setattr(
"antigravity_provider.router.codex_oauth._post_form_json",
lambda url, data, **kwargs: {
"access_token": "oa-acc-token-123",
"refresh_token": "oa-ref-token-456",
"id_token": test_id_token,
},
)
url, code = session.start()
assert "auth.openai.com/codex/device" in url
assert code == "TEST-1234"
# Wait for poll thread to exchange tokens
deadline = time.time() + 2.5
while time.time() < deadline and session.status != "completed":
time.sleep(0.1)
assert session.status == "completed"
assert session.completed_profile_info is not None
assert session.completed_profile_info["email"] == "dev1@openai.com"
# Verify saved on disk
auth_data = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-1")
assert auth_data is not None
assert auth_data["profile_id"] == "codex-worker-1"
assert auth_data["token"]["access_token"] == "oa-acc-token-123"
assert auth_data["email"] == "dev1@openai.com"
# ==============================================================================
# TEST B: CodexOAuthSession manual token / JSON credential fallback
# ==============================================================================
def test_b_codex_oauth_manual_token_fallback():
"""Test manual JSON credential insertion into Codex OAuth session."""
session = CodexOAuthSession("codex-worker-2")
test_id_token = _make_jwt({"email": "worker2@openai.com", "sub": "usr_w2"})
raw_json = json.dumps({
"access_token": "manual-access-token-777",
"refresh_token": "manual-refresh-token-888",
"id_token": test_id_token,
})
ok, msg = session.handle_manual_input(raw_json)
assert ok is True
assert session.status == "completed"
auth_data = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
assert auth_data is not None
assert auth_data["profile_id"] == "codex-worker-2"
assert auth_data["token"]["access_token"] == "manual-access-token-777"
assert auth_data["email"] == "worker2@openai.com"
# ==============================================================================
# TEST C: Multi-account isolation
# ==============================================================================
def test_c_multi_account_codex_isolation():
"""Verify multiple Codex accounts are saved and loaded independently."""
# Save account 1 (codex-orch)
orch_data = {
"provider": "openai-codex",
"profile_id": "codex-orch",
"auth_mode": "oauth",
"token": {"access_token": "orch-token-1"},
"email": "orch@company.com",
}
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", orch_data)
# Save account 2 (codex-worker-1)
w1_data = {
"provider": "openai-codex",
"profile_id": "codex-worker-1",
"auth_mode": "oauth",
"token": {"access_token": "worker1-token-2"},
"email": "coder@company.com",
}
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", w1_data)
# Save account 3 (codex-worker-2 as API key)
w2_data = {
"provider": "openai-codex",
"profile_id": "codex-worker-2",
"auth_mode": "api_key",
"api_key": "sk-proj-testkey333333333333",
}
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", w2_data)
# Verify each loads its own independent data
loaded_orch = ProfileAuthManager.load_profile_auth("openai-codex", "codex-orch")
loaded_w1 = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-1")
loaded_w2 = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
assert loaded_orch["token"]["access_token"] == "orch-token-1"
assert loaded_orch["email"] == "orch@company.com"
assert loaded_w1["token"]["access_token"] == "worker1-token-2"
assert loaded_w1["email"] == "coder@company.com"
assert loaded_w2["api_key"] == "sk-proj-testkey333333333333"
# ==============================================================================
# TEST D: CodexAdapter._resolve_token per-profile isolation
# ==============================================================================
def test_d_codex_adapter_resolve_token():
"""Verify CodexAdapter._resolve_token looks up the exact profile credentials from ProfileAuthManager."""
adapter = CodexAdapter()
# Create 2 separate profiles
p1 = RouterProfileConfig(profile_id="codex-orch", provider="openai-codex")
p2 = RouterProfileConfig(profile_id="codex-worker-1", provider="openai-codex")
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
"provider": "openai-codex", "profile_id": "codex-orch", "token": {"access_token": "token-orch-99"}
})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {
"provider": "openai-codex", "profile_id": "codex-worker-1", "token": {"access_token": "token-worker-11"}
})
t1 = adapter._resolve_token(p1)
t2 = adapter._resolve_token(p2)
assert t1 == "token-orch-99"
assert t2 == "token-worker-11"
assert t1 != t2
# ==============================================================================
# TEST E: CodexAdapter multi-profile switching and failover isolation
# ==============================================================================
def test_e_codex_adapter_switching_and_failover():
"""Test switching profiles in CodexAdapter during execution."""
adapter = CodexAdapter()
p_primary = RouterProfileConfig(profile_id="codex-orch", provider="openai-codex")
p_spare = RouterProfileConfig(profile_id="codex-spare-1", provider="openai-codex")
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
"provider": "openai-codex", "profile_id": "codex-orch", "api_key": "sk-primary-orch-key111"
})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-spare-1", {
"provider": "openai-codex", "profile_id": "codex-spare-1", "api_key": "sk-spare-orch-key222"
})
assert adapter._resolve_token(p_primary) == "sk-primary-orch-key111"
assert adapter._resolve_token(p_spare) == "sk-spare-orch-key222"
# ==============================================================================
# TEST F: ProfileAuthManager.get_profile_status for OAuth and API Key
# ==============================================================================
def test_f_profile_auth_manager_status():
"""Verify get_profile_status properly reports OAuth vs API Key profiles."""
test_id_token = _make_jwt({"email": "testuser@openai.com", "sub": "usr_1"})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {
"provider": "openai-codex",
"profile_id": "codex-orch",
"token": {"access_token": "test-access", "id_token": test_id_token},
})
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {
"provider": "openai-codex",
"profile_id": "codex-worker-1",
"api_key": "sk-1234567890abcdef1234",
})
status_oauth = ProfileAuthManager.get_profile_status("openai-codex", "codex-orch")
status_key = ProfileAuthManager.get_profile_status("openai-codex", "codex-worker-1")
assert status_oauth["authenticated"] is True
assert status_oauth["auth_mode"] == "oauth"
assert "test***@openai.com" in status_oauth["email_masked"]
assert status_key["authenticated"] is True
assert status_key["auth_mode"] == "api_key"
assert status_key["account_id_masked"] == "sk-...1234"
# ==============================================================================
# TEST G: ProfileAuthManager.extract_jwt_identity
# ==============================================================================
def test_g_extract_jwt_identity():
"""Test extracting email and sub from various standard and custom JWT claims."""
jwt_standard = _make_jwt({"email": "standard@gmail.com", "sub": "google-sub-123"})
jwt_openai = _make_jwt({"https://api.openai.com/profile": {"email": "custom@openai.com"}, "sub": "auth0|openai-456"})
email1, sub1 = ProfileAuthManager.extract_jwt_identity(jwt_standard)
email2, sub2 = ProfileAuthManager.extract_jwt_identity(jwt_openai)
assert email1 == "standard@gmail.com"
assert sub1 == "google-sub-123"
assert email2 == "custom@openai.com"
assert sub2 == "auth0|openai-456"
# ==============================================================================
# TEST H: HubEntry and enable_clipboard_shortcuts
# ==============================================================================
def test_h_enable_clipboard_shortcuts_binding():
"""Test that enable_clipboard_shortcuts attaches paste/copy/cut/select-all handlers without error."""
mock_entry = MagicMock()
mock_entry._entry = MagicMock()
mock_entry.clipboard_get.return_value = "pasted_text_123"
enable_clipboard_shortcuts(mock_entry)
# Check bind was called for multiple standard and Cyrillic keys
bound_events = [c[0][0] for c in mock_entry._entry.bind.call_args_list]
assert "<Control-v>" in bound_events
assert "<Control-a>" in bound_events
assert "<Shift-Insert>" in bound_events
assert "<Control-cyrillic_em>" in bound_events
# ==============================================================================
# TEST I: OpenCode Go API key paste with whitespace / newline stripping
# ==============================================================================
def test_i_opencode_go_paste_sanitization():
"""Verify paste sanitization strips leading/trailing newlines and whitespace."""
raw_key_with_whitespace = " \n\t opencode-sk-test-secret-999 \r\n "
cleaned = raw_key_with_whitespace.strip()
assert cleaned == "opencode-sk-test-secret-999"
is_valid, masked, models = ProfileAuthManager.verify_opencode_token(cleaned)
assert is_valid is True
assert masked == "opencode-...-999"
assert "opencode-go-3" in models
# ==============================================================================
# TEST J: Password masking does not corrupt backing value
# ==============================================================================
def test_j_password_masking_integrity():
"""Verify password masking (show='*') retains exact backing value."""
secret = "sk-proj-actualsecretvalue123456"
auth_data = {
"provider": "openai-codex",
"profile_id": "codex-worker-2",
"auth_mode": "api_key",
"api_key": secret,
}
ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", auth_data)
loaded = ProfileAuthManager.load_profile_auth("openai-codex", "codex-worker-2")
assert loaded["api_key"] == secret
# ==============================================================================
# TEST K: Zero-secret logging across Codex OAuth and API key flows
# ==============================================================================
def test_k_zero_secret_logging_codex(caplog):
"""Verify raw tokens and API keys are never written to logger."""
import logging
caplog.set_level(logging.DEBUG)
session = CodexOAuthSession("codex-orch")
session._finalize_with_tokens("super_secret_access_token_9999", "super_secret_refresh_token_8888")
all_logs = " ".join([r.message for r in caplog.records])
assert "super_secret_access_token_9999" not in all_logs
assert "super_secret_refresh_token_8888" not in all_logs
# ==============================================================================
# TEST L: Single completion lock prevents double token exchange
# ==============================================================================
def test_l_single_completion_protection():
"""Verify second finalize call does not re-save or double process."""
session = CodexOAuthSession("codex-orch")
res1 = session._finalize_with_tokens("token1", "refresh1")
assert res1 is True
assert session._is_completed is True
# Try manual input after completed
ok, msg = session.handle_manual_input("some_other_token")
assert ok is True
assert "Авторизация уже успешно завершена" in msg
# ==============================================================================
# TEST M: Session cleanup on cancellation
# ==============================================================================
def test_m_codex_session_cleanup():
"""Verify cancel_codex_oauth_session cancels polling and cleans global store."""
session_id, url, code = start_codex_oauth("codex-spare-1")
session = get_codex_oauth_session(session_id)
assert session is not None
cancel_codex_oauth_session(session_id)
assert session.status == "cancelled"
assert get_codex_oauth_session(session_id) is None