"""Codex 认证管理 - OAuth 登录、token 管理、保存 CPA 兼容认证文件""" import base64 import hashlib import json import logging import re import secrets import time import urllib.parse from pathlib import Path from playwright.sync_api import sync_playwright import autoteam.display # noqa: F401 from autoteam import oauth_workspace as _oauth_workspace from autoteam.accounts import is_supported_plan, normalize_plan_type from autoteam.admin_state import ( get_admin_email, get_admin_session_token, get_chatgpt_account_id, get_chatgpt_workspace_name, ) from autoteam.auth_storage import AUTH_DIR, ensure_auth_dir, ensure_auth_file_permissions from autoteam.config import get_playwright_context_options, get_playwright_launch_options from autoteam.invite import ( # SPEC-2 shared/add-phone-detection §3 — OAuth 流程复用 RegisterBlocked, assert_not_blocked, ) from autoteam.playwright_lifecycle import close_playwright_objects from autoteam.signup_profile import SignupProfile, generate_signup_profile from autoteam.textio import read_text, write_text logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.parent SCREENSHOT_DIR = PROJECT_ROOT / "screenshots" _OAUTH_TRACE_LIMIT = 40 _OAUTH_TRACE_KEYWORDS = ( "/oauth/authorize", "/sign-in-with-chatgpt/codex", "choose-an-account", "consent", "organization", "email-verification", "log-in", "/auth/callback", "no_valid_organizations", ) # Codex OAuth 配置 CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_AUTH_URL = "https://auth.openai.com/oauth/authorize" CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_CALLBACK_PORT = 1455 CODEX_REDIRECT_URI = f"http://localhost:{CODEX_CALLBACK_PORT}/auth/callback" _OTP_REJECTION_FILE = AUTH_DIR / "otp_rejections.json" _OTP_REJECTION_TTL_SECONDS = 2 * 60 * 60 _OTP_REJECTION_MAX_PER_EMAIL = 100 # SPEC-2 shared/quota-classification §4.4 I5 — Codex backend 最小推理端点(用于 uninitialized_seat 二次验证) _CODEX_SMOKE_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" # quota 关键词:codex backend 返回 4xx 时若 body 含这些词同样视为 auth_invalid(可能是配额相关 API 错误) _CODEX_SMOKE_QUOTA_HINTS = ("quota", "no_quota", "rate_limit", "billing", "exceeded") # Round 11 — codex backend 拒绝 model 时的 body 关键词(4xx),用于触发 fallback model chain _CODEX_SMOKE_MODEL_NOT_SUPPORTED_HINTS = ( "not supported", "model_not_supported", "is not supported", "invalid model", "unknown model", ) # Round 11 — cheap_codex_smoke 默认 instructions(短回复 + 最小 token 消耗) _CODEX_SMOKE_DEFAULT_INSTRUCTIONS = "You are a concise assistant. Reply with one short word." def _generate_pkce(): """生成 PKCE code_verifier 和 code_challenge""" verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() return verifier, challenge def _parse_jwt_payload(token): """解析 JWT payload(不验证签名)""" parts = token.split(".") if len(parts) < 2: return {} payload = parts[1] # 补齐 base64 padding payload += "=" * (4 - len(payload) % 4) try: return json.loads(base64.urlsafe_b64decode(payload)) except Exception: return {} def _otp_rejection_email_key(email: str | None) -> str: return str(email or "").strip().lower() def _otp_rejection_hash(email: str | None, code: str | None) -> str: key = f"{_otp_rejection_email_key(email)}:{str(code or '').strip()}" return hashlib.sha256(key.encode("utf-8")).hexdigest() def _load_otp_rejection_store() -> dict: try: if not _OTP_REJECTION_FILE.exists(): return {} data = json.loads(read_text(_OTP_REJECTION_FILE)) return data if isinstance(data, dict) else {} except Exception as exc: logger.debug("[Codex] failed to load OTP rejection cache: %s", exc) return {} def _write_otp_rejection_store(data: dict) -> None: try: ensure_auth_dir() write_text(_OTP_REJECTION_FILE, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)) ensure_auth_file_permissions(_OTP_REJECTION_FILE) except Exception as exc: logger.debug("[Codex] failed to write OTP rejection cache: %s", exc) def _coerce_int(value): try: return int(value) except Exception: return None def _load_recent_otp_rejections(email: str | None, *, now: float | None = None): now = time.time() if now is None else float(now) cutoff = now - _OTP_REJECTION_TTL_SECONDS key = _otp_rejection_email_key(email) data = _load_otp_rejection_store() records = data.get(key, []) if not isinstance(records, list): return set(), set() code_hashes = set() email_ids = set() kept = [] changed = False for record in records: if not isinstance(record, dict): changed = True continue try: created_at = float(record.get("ts") or 0) except Exception: created_at = 0 if created_at and created_at < cutoff: changed = True continue code_hash = str(record.get("code_hash") or "").strip() if code_hash: code_hashes.add(code_hash) email_id = _coerce_int(record.get("email_id")) if email_id is not None: email_ids.add(email_id) kept.append(record) if changed: if kept: data[key] = kept[-_OTP_REJECTION_MAX_PER_EMAIL:] else: data.pop(key, None) _write_otp_rejection_store(data) return code_hashes, email_ids def _record_otp_rejection(email: str | None, code: str | None, email_id, *, now: float | None = None): code = str(code or "").strip() if not code: return None now = time.time() if now is None else float(now) cutoff = now - _OTP_REJECTION_TTL_SECONDS key = _otp_rejection_email_key(email) code_hash = _otp_rejection_hash(email, code) email_id_int = _coerce_int(email_id) data = _load_otp_rejection_store() records = data.get(key, []) if not isinstance(records, list): records = [] kept = [] for record in records: if not isinstance(record, dict): continue try: created_at = float(record.get("ts") or 0) except Exception: created_at = 0 if created_at and created_at < cutoff: continue if str(record.get("code_hash") or "") == code_hash: continue if email_id_int is not None and _coerce_int(record.get("email_id")) == email_id_int: continue kept.append(record) kept.append({"code_hash": code_hash, "email_id": email_id_int, "ts": now}) data[key] = kept[-_OTP_REJECTION_MAX_PER_EMAIL:] _write_otp_rejection_store(data) return code_hash def _page_excerpt(page, limit=240) -> str: try: text = page.locator("body").inner_text(timeout=1500) text = re.sub(r"\s+", " ", text).strip() return text[:limit] except Exception: return "" def _normalize_trace_text(value, limit=240) -> str: text = re.sub(r"\s+", " ", str(value or "")).strip() return text[:limit] def _should_trace_oauth_network(url: str | None) -> bool: lowered = (url or "").lower() if "auth.openai.com" not in lowered and f"localhost:{CODEX_CALLBACK_PORT}" not in lowered: return False return any(keyword in lowered for keyword in _OAUTH_TRACE_KEYWORDS) def _append_oauth_trace(trace_events: list[dict], *, kind: str, url: str, **fields) -> None: if not _should_trace_oauth_network(url): return entry = {"kind": kind, "url": url} for key, value in fields.items(): if value is None: continue if key in {"body_excerpt", "location", "failure", "resource_type"}: entry[key] = _normalize_trace_text(value, limit=320) else: entry[key] = value trace_events.append(entry) if len(trace_events) > _OAUTH_TRACE_LIMIT: del trace_events[:-_OAUTH_TRACE_LIMIT] def _oauth_trace_has_login_challenge(trace_events: list[dict]) -> bool: for entry in trace_events[-12:]: for key in ("url", "location"): value = str(entry.get(key) or "").lower() if "/api/accounts/login" in value or "auth.openai.com/log-in" in value: return True return False def _classify_oauth_failure(url: str | None, body_excerpt: str = ""): lowered_url = (url or "").lower() body = (body_excerpt or "").lower() if "add-phone" in lowered_url: return "add_phone", "需要手机号验证", False if "verify you are human" in body or "captcha" in body: return "human_verification", "命中人机验证", False if "operation timed out" in body: return "oauth_timeout", "OAuth 授权页操作超时", True if "unsupported_country_region_territory" in body or "country, region, or territory not supported" in body: return "unsupported_region", "OAuth 授权接口返回不支持当前地区/出口", True if "choose-an-account" in lowered_url: return "account_selection", "停留在账号选择页", True if "no_valid_organizations" in body or "no valid organizations" in body: return "no_valid_organizations", "OAuth 授权页未选中可用 organization", True if "unable to load site" in body or "try again later" in body or "status page" in body: return "site_unavailable", "站点暂时不可用或代理异常", True if "email-verification" in lowered_url: return "email_verification", "卡在邮箱验证码页", True if "workspace" in lowered_url: return "workspace_selection", "卡在 workspace 选择页", True if "/auth/login" in lowered_url or "/log-in" in lowered_url or "log-in-or-create-account" in lowered_url: return "login_state_lost", "登录态丢失或回到了登录页", True return "auth_code_missing", f"未获取到 auth code(停留在 {lowered_url or 'unknown'})", True def _screenshot(page, name): SCREENSHOT_DIR.mkdir(exist_ok=True) page.screenshot(path=str(SCREENSHOT_DIR / name), full_page=True) def _build_auth_url(code_challenge, state): params = { "client_id": CODEX_CLIENT_ID, "response_type": "code", "redirect_uri": CODEX_REDIRECT_URI, "scope": "openid email profile offline_access", "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", "prompt": "consent", } return f"{CODEX_AUTH_URL}?{urllib.parse.urlencode(params)}" def _is_workspace_selection_page(page) -> bool: """Compatibility wrapper around the shared OAuth workspace detector.""" return _oauth_workspace._is_workspace_selection_page(page) def _workspace_label_candidates(page): """Compatibility wrapper around shared workspace label extraction.""" return _oauth_workspace._workspace_label_candidates(page) def _select_team_workspace(page, workspace_name: str) -> bool: """Compatibility wrapper around shared Team workspace selection.""" return _oauth_workspace._select_team_workspace(page, workspace_name) def _exchange_auth_code(auth_code, code_verifier, fallback_email=None): logger.info("[Codex] 获取到 auth code,交换 token...") import requests resp = requests.post( CODEX_TOKEN_URL, data={ "grant_type": "authorization_code", "client_id": CODEX_CLIENT_ID, "code": auth_code, "redirect_uri": CODEX_REDIRECT_URI, "code_verifier": code_verifier, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if resp.status_code != 200: logger.error("[Codex] Token 交换失败: %d %s", resp.status_code, resp.text[:200]) return None token_data = resp.json() id_token = token_data.get("id_token", "") claims = _parse_jwt_payload(id_token) auth_claims = claims.get("https://api.openai.com/auth", {}) raw_plan = auth_claims.get("chatgpt_plan_type", "unknown") bundle = { "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token"), "id_token": id_token, "account_id": auth_claims.get("chatgpt_account_id", ""), "email": claims.get("email", fallback_email or ""), # SPEC-2 shared/plan-type-whitelist §2.3:plan_type 已归一化为小写; # plan_type_raw 保留 OpenAI 原始字面量便于事后排查; # plan_supported 是白名单判定结果,下游消费方应只读该字段不再自己 .lower() 比对。 "plan_type": normalize_plan_type(raw_plan), "plan_type_raw": raw_plan, "plan_supported": is_supported_plan(raw_plan), "expired": time.time() + token_data.get("expires_in", 3600), } logger.info( "[Codex] 登录成功: %s (plan: %s, supported: %s)", bundle["email"], bundle["plan_type"], bundle["plan_supported"], ) return bundle def _password_grant_access_token(email, password): """Round 11 V8 fast-path(已弃用,保留作未来 hook)— OAuth 2.0 ROPC password grant. 现状(V11 实证 2026-04-30):OAuth 2.1 已移除 ROPC,OpenAI auth.openai.com/oauth/token 后端 30 次组合(5 账号 × 2 client_id × 3 scope)全员 HTTP 400 `unknown_parameter:'username'` — 完全没部署 password grant,本 helper 永远返回 None。 完整证据见 `research/v11-password-grant-probe-report.md`。 实际使用 `fetch_nextauth_backend_access_token` 替代(NextAuth /api/auth/session.accessToken 路径,V10 jshook trace 已实证 chatgpt.com /api/auth/session 响应含 accessToken 字段 可作 Bearer 调 backend-api/*)。 保留 helper 仅作未来 OpenAI 重启 ROPC 时的 hook;现有单测继续覆盖契约语义(空参 / 200 / 401 / 网络异常 / 非 JSON / 缺字段),实现行为保持不变。 Args: email: 子号邮箱,作为 `username` 字段。 password: 注册时设的密码。 Returns: access_token 字符串(200 OK + token_type=Bearer 时);否则 None。永不抛(M-I1 风格)。 当前实测永远返回 None(OpenAI 后端 unknown_parameter:'username')。 """ if not email or not password: return None import requests try: resp = requests.post( CODEX_TOKEN_URL, data={ "grant_type": "password", "client_id": CODEX_CLIENT_ID, "username": email, "password": password, "scope": "openid profile email offline_access", }, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=15, ) except Exception as exc: # noqa: BLE001 logger.warning("[Codex] _password_grant_access_token 请求异常: %s", exc) return None if resp.status_code != 200: logger.warning( "[Codex] _password_grant_access_token 返回 %d: %s", resp.status_code, (resp.text or "")[:200], ) return None try: data = resp.json() except Exception as exc: # noqa: BLE001 logger.warning("[Codex] _password_grant_access_token 响应非 JSON: %s", exc) return None access_token = data.get("access_token") if not access_token: logger.warning( "[Codex] _password_grant_access_token 响应缺 access_token: %s", str(data)[:200], ) return None logger.info("[Codex] _password_grant_access_token 成功 (token_type=%s)", data.get("token_type")) return access_token def fetch_nextauth_backend_access_token(page): """Round 11 V12 P0.1 — 从已加载 chatgpt.com cookie 的浏览器 page 提取 NextAuth `accessToken`。 背景:V11 探活实证 OpenAI `auth.openai.com/oauth/token` 已撤 ROPC password grant (30 个 client_id/scope/audience 变体全员 HTTP 400 `unknown_parameter: 'username'`), `_password_grant_access_token` 在新号场景永远返回 None。 替代路径:V10 jshook trace 已实证 chatgpt.com `/api/auth/session` 响应含 `accessToken` 字段(2080 字符),等价于 SPA 调 `/backend-api/*` 用的 Bearer token。在 register 成功 后浏览器 context 已落 chatgpt.com NextAuth session cookie,直接 page.evaluate 调 /api/auth/session credentials:include 即可拿到。 Args: page: Playwright Page 对象,必须已加载 chatgpt.com 域(cookie 在该上下文)。 Returns: access_token 字符串(200 OK + accessToken 字段非空时);否则 None。永不抛(M-I1 风格)。 """ if page is None: return None try: result = page.evaluate( """ async () => { try { const r = await fetch('/api/auth/session', { credentials: 'include', cache: 'no-store', headers: { 'Accept': 'application/json' }, }); if (r.status !== 200) return { status: r.status, accessToken: null }; const ct = r.headers.get('content-type') || ''; if (!ct.includes('application/json')) return { status: r.status, accessToken: null, raw: 'non-json' }; const data = await r.json(); return { status: r.status, accessToken: data && data.accessToken ? data.accessToken : null }; } catch (e) { return { status: 0, accessToken: null, error: String(e) }; } } """ ) except Exception as exc: # noqa: BLE001 logger.warning("[Codex] fetch_nextauth_backend_access_token page.evaluate 异常: %s", exc) return None if not isinstance(result, dict): logger.warning("[Codex] fetch_nextauth_backend_access_token 异常返回: %s", str(result)[:200]) return None status = result.get("status") access_token = result.get("accessToken") if status != 200: logger.warning( "[Codex] fetch_nextauth_backend_access_token /api/auth/session 返回 %s (accessToken=%s)", status, "yes" if access_token else "no", ) return None if not access_token: logger.warning( "[Codex] fetch_nextauth_backend_access_token /api/auth/session 200 但 accessToken 字段缺失/空" ) return None logger.info( "[Codex] fetch_nextauth_backend_access_token 成功 (len=%d)", len(access_token), ) return access_token def _bundle_from_access_token(access_token, email, account_id): claims = _parse_jwt_payload(access_token) auth_claims = claims.get("https://api.openai.com/auth", {}) raw_plan = auth_claims.get("chatgpt_plan_type", "unknown") resolved_email = claims.get("email", email or "") resolved_account_id = account_id or auth_claims.get("chatgpt_account_id", "") return { "access_token": access_token, "refresh_token": "", "id_token": access_token, "account_id": resolved_account_id, "email": resolved_email, "plan_type": normalize_plan_type(raw_plan), "plan_type_raw": raw_plan, "plan_supported": is_supported_plan(raw_plan), "expired": time.time() + 3600, } def _bundle_from_session_token(session_token, email, account_id): return _bundle_from_access_token(session_token, email, account_id) def _oauth_result_from_bundle(bundle): return { "ok": True, "bundle": bundle, "error_type": None, "error_detail": None, "retryable": False, } def _inject_team_account_cookies(context, account_id: str | None) -> None: account_id = (account_id or "").strip() if not account_id: return try: context.add_cookies( [ { "name": "_account", "value": account_id, "domain": "chatgpt.com", "path": "/", "secure": True, "sameSite": "Lax", }, { "name": "_account", "value": account_id, "domain": "auth.openai.com", "path": "/", "secure": True, "sameSite": "Lax", }, ] ) except Exception as exc: logger.debug("[Codex-Fallback] 注入 Team account cookie 失败: %s", exc) def _is_valid_team_access_token(access_token: str | None, account_id: str | None) -> bool: if not access_token: return False claims = _parse_jwt_payload(access_token) auth_claims = claims.get("https://api.openai.com/auth", {}) plan_type = normalize_plan_type(auth_claims.get("chatgpt_plan_type", "")) token_account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() expected_account_id = str(account_id or "").strip() if expected_account_id and token_account_id and token_account_id != expected_account_id: return False return plan_type == "team" def _session_token_supports_team_quota(page, session_token, account_id): try: result = page.evaluate( """async ([teamAccountId, token]) => { try { const resp = await fetch("/backend-api/wham/usage", { credentials: "include", headers: { "Accept": "application/json", "Authorization": `Bearer ${token || ""}`, }, }); const text = await resp.text().catch(() => ""); return { status: resp.status, body: text }; } catch (e) { return { status: 0, body: String(e && e.message || e) }; } }""", [account_id, session_token], ) except Exception as exc: logger.info("[Codex-Fallback] Team quota probe exception: %s", exc) return False status = result.get("status") if isinstance(result, dict) else None if status == 200: return True logger.info( "[Codex-Fallback] Team quota probe rejected session token: status=%s body=%s", status, (result or {}).get("body", "") if isinstance(result, dict) else "", ) return False def _fetch_team_session_bundle_from_context( context, email: str, account_id: str | None, *, stage_label: str, attempts: int = 3, ) -> dict | None: """Use an already logged-in ChatGPT session as Codex auth material.""" account_id = (account_id or "").strip() page = None try: _inject_team_account_cookies(context, account_id) page = context.new_page() target_url = f"https://chatgpt.com/admin/workspace/{account_id}" if account_id else "https://chatgpt.com/" page.goto(target_url, wait_until="domcontentloaded", timeout=30000) time.sleep(2) for attempt in range(1, max(1, attempts) + 1): session = page.evaluate( """async () => { try { const resp = await fetch("/api/auth/session"); return await resp.json(); } catch (e) { return { error: String(e && e.message || e) }; } }""" ) token = session.get("accessToken") if isinstance(session, dict) else None if _is_valid_team_access_token(token, account_id): bundle = _bundle_from_access_token(token, email, account_id) logger.info( "[Codex-Fallback] %s 获取到 ChatGPT Team session token: email=%s plan=%s", stage_label, bundle.get("email") or email, bundle.get("plan_type"), ) return bundle if token and _session_token_supports_team_quota(page, token, account_id): bundle = _bundle_from_session_token(token, email, account_id) bundle["plan_type"] = "team" bundle["plan_supported"] = True logger.info( "[Codex-Fallback] %s JWT claims 未切到 Team,但 wham/usage 已验证目标 Team 可用: email=%s account=%s", stage_label, bundle.get("email") or email, account_id, ) return bundle if token: claims = _parse_jwt_payload(token) auth_claims = claims.get("https://api.openai.com/auth", {}) logger.info( "[Codex-Fallback] %s session token 不匹配 Team workspace (attempt %d/%d): account=%s plan=%s", stage_label, attempt, attempts, auth_claims.get("chatgpt_account_id"), auth_claims.get("chatgpt_plan_type"), ) if attempt < attempts: page.reload(wait_until="domcontentloaded", timeout=30000) time.sleep(1) return None except Exception as exc: logger.warning("[Codex-Fallback] %s session bundle 提取失败: %s", stage_label, exc) return None finally: if page is not None: try: page.close() except Exception: pass def fetch_personal_uuid(access_token): """Round 11 V8 — POST https://chatgpt.com/backend-api/accounts/personal idempotent getOrCreate. Master 控制实验(`research/v7c-master-replay.json`)证实该 endpoint 是 idempotent — 用 Bearer access_token 调一次,200 OK + 返回 `{id: "", structure: "personal", created: false}`, 用户在 register 阶段已经被自动创建 personal workspace。此 UUID 下游用作 OAuth `allowed_workspace_id` query 参数,绕过 default_workspace_id sticky-Team 死锁。 ⚠️ Round 11 V13 实证(2026-04-30):用 `requests` 直打该 endpoint 会被 Cloudflare 403 拦 (无 cf_clearance / 真实 chrome UA / TLS fingerprint)。生产路径必须用 `fetch_personal_uuid_via_page(page, access_token)` 在浏览器内 page.evaluate 调用,绕开 cf bot challenge。本 helper 仅保留作单测 / 离线 CTF 复现脚本用途。 Args: access_token: 子号刚完成 password login 拿到的临时 Bearer token。 Returns: UUID 字符串 / None(401 / 403 / 网络异常)。永不抛(M-I1 风格)。 """ if not access_token: return None import requests try: resp = requests.post( "https://chatgpt.com/backend-api/accounts/personal", headers={ "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "Origin": "https://chatgpt.com", }, json={}, timeout=15, ) except Exception as exc: # noqa: BLE001 — 任何 transport 异常都不抛 logger.warning("[Codex] fetch_personal_uuid 请求异常: %s", exc) return None if resp.status_code != 200: logger.warning( "[Codex] fetch_personal_uuid 返回 %d: %s", resp.status_code, (resp.text or "")[:200], ) return None try: data = resp.json() except Exception as exc: # noqa: BLE001 logger.warning("[Codex] fetch_personal_uuid 响应非 JSON: %s", exc) return None uuid = data.get("id") if not uuid: logger.warning("[Codex] fetch_personal_uuid 响应缺少 id 字段: %s", str(data)[:200]) return None logger.info( "[Codex] fetch_personal_uuid 成功: %s (created=%s)", uuid, data.get("created"), ) return uuid def fetch_personal_uuid_via_page(page, access_token=None): """Round 11 V13 P0.2 — 浏览器内调用 POST /backend-api/accounts/personal,绕过 Cloudflare。 背景:V12 探活实证 `requests` 直打 `chatgpt.com/backend-api/accounts/personal` 全员被 Cloudflare 403 拦(无 cf_clearance / 真实 chrome TLS fingerprint / 真实 UA)。但浏览器在 register 阶段已 通过 cf challenge,context 里有 cf_clearance cookie + 完整 chrome 指纹,page.evaluate 在 chatgpt.com origin 内 fetch 不会被拦。 Args: page: Playwright Page 对象,必须已加载 chatgpt.com 域(cookie + cf_clearance 在该上下文)。 access_token: 显式 Bearer token(从 `fetch_nextauth_backend_access_token` 拿到); None 时走 `credentials: 'include'` 用浏览器 NextAuth cookie session(SPA 默认行为)。 Returns: UUID 字符串 / None(page=None / 401 / 403 / 缺 id / 异常)。永不抛(M-I1 风格)。 """ if page is None: return None js = """ async (token) => { try { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; if (token) headers['Authorization'] = 'Bearer ' + token; const r = await fetch('/backend-api/accounts/personal', { method: 'POST', credentials: 'include', headers: headers, body: '{}', }); if (r.status !== 200) { let body = ''; try { body = (await r.text()).slice(0, 200); } catch (e) {} return { status: r.status, id: null, body: body }; } const ct = r.headers.get('content-type') || ''; if (!ct.includes('application/json')) { return { status: r.status, id: null, raw: 'non-json' }; } const data = await r.json(); return { status: r.status, id: data && data.id ? data.id : null, created: data ? data.created : null }; } catch (e) { return { status: 0, id: null, error: String(e) }; } } """ try: result = page.evaluate(js, access_token or "") except Exception as exc: # noqa: BLE001 logger.warning("[Codex] fetch_personal_uuid_via_page page.evaluate 异常: %s", exc) return None if not isinstance(result, dict): logger.warning( "[Codex] fetch_personal_uuid_via_page 异常返回: %s", str(result)[:200] ) return None status = result.get("status") uuid = result.get("id") if status != 200: logger.warning( "[Codex] fetch_personal_uuid_via_page /accounts/personal 返回 %s (id=%s, body=%s)", status, "yes" if uuid else "no", (result.get("body") or "")[:120], ) return None if not uuid: logger.warning( "[Codex] fetch_personal_uuid_via_page 200 但缺 id 字段: %s", str(result)[:200] ) return None logger.info( "[Codex] fetch_personal_uuid_via_page 成功: %s (created=%s)", uuid, result.get("created"), ) return uuid def is_token_pair_invalidated(auth_path): """Round 11 V7 — 双失效探测:access_token + refresh_token 是否被 server-side invalidate。 研究报告 v7-v10-personal-uuid-exploitation.md §V7 确认 OpenAI 在 user kick 时同步废 access_token(`token_invalidated` 401)+ refresh_token(`refresh_token_invalidated` 401)。 任意一个仍可用 → 视为可救活,仅 access_token 死可走 refresh_access_token 重生;两个都死才 判定 auth_invalid 触发外层清账(避免无谓重登)。 Args: auth_path: 子号 auth_file 路径(JSON)。 Returns: True 当且仅当 GET /backend-api/me 返回 401 + POST /oauth/token (refresh_token grant) 也返回 401。任何其它结果(网络异常 / 200 / 非 401 错误)→ False(保守不动状态)。 """ if not auth_path: return False p = Path(auth_path) if not p.exists(): return False try: data = json.loads(p.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 logger.warning("[Codex] is_token_pair_invalidated 读取 %s 异常: %s", auth_path, exc) return False access_token = data.get("access_token") refresh_token = data.get("refresh_token") if not access_token or not refresh_token: return False import requests try: me_resp = requests.get( "https://chatgpt.com/backend-api/me", headers={"Authorization": f"Bearer {access_token}"}, timeout=15, ) except Exception as exc: # noqa: BLE001 logger.warning("[Codex] is_token_pair_invalidated GET /me 异常: %s", exc) return False if me_resp.status_code != 401: return False try: rt_resp = requests.post( CODEX_TOKEN_URL, data={ "grant_type": "refresh_token", "client_id": CODEX_CLIENT_ID, "refresh_token": refresh_token, "scope": "openid profile email", }, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=15, ) except Exception as exc: # noqa: BLE001 logger.warning("[Codex] is_token_pair_invalidated refresh 异常: %s", exc) return False if rt_resp.status_code != 401: return False logger.warning( "[Codex] is_token_pair_invalidated 命中:access_token + refresh_token 同时被 server-side invalidate (auth_path=%s)", auth_path, ) return True def _build_auth_url_with_allowed_workspace(code_challenge, state, allowed_workspace_id): """Round 11 V8 — 在 _build_auth_url 基础上拼 `&allowed_workspace_id=` query 参数。 OAuth issuer 静默接受该参数(实测 v8-result.md:不报 unknown_parameter),让 issuer 在颁 token 时优先选 personal workspace 而非 default_workspace_id 指向的 Team。 """ base = _build_auth_url(code_challenge, state) if not allowed_workspace_id: return base sep = "&" if "?" in base else "?" return f"{base}{sep}allowed_workspace_id={urllib.parse.quote(allowed_workspace_id, safe='')}" def _write_auth_file(filepath, bundle): filepath = Path(filepath) ensure_auth_dir() filepath.parent.mkdir(exist_ok=True) auth_data = { "type": "codex", "id_token": bundle.get("id_token", ""), "access_token": bundle.get("access_token", ""), "refresh_token": bundle.get("refresh_token", ""), "account_id": bundle.get("account_id", ""), "email": bundle.get("email", ""), "expired": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(bundle.get("expired", 0))), "last_refresh": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } write_text(filepath, json.dumps(auth_data, indent=2)) ensure_auth_file_permissions(filepath) logger.info("[Codex] 认证文件已保存: %s", filepath) return str(filepath) def _inject_personal_session_cookies(context, session_token): """ 把注册阶段的 chatgpt.com __Secure-next-auth.session-token 注入到 chatgpt.com + auth.openai.com 双域,让 personal OAuth 跳过 /log-in 表单。 Round 11 四轮: - 单注入 auth.openai.com 域不够 —— NextAuth 跨域 issuer 校验严格, /oauth/authorize 不认 chatgpt.com 颁发的 token。 - 必须双域同时注入,然后先 goto chatgpt.com 让服务端 next-auth API 校验 session 并写齐配套 cookies,再 goto auth_url 进入 OAuth。 切片规则与 SessionCodexAuthFlow._inject_auth_cookies / chatgpt_api._build_session_cookies 保持一致:>3800 字节切两段 .0/.1,否则单个 cookie。 """ if not session_token: return def _build(domain): if len(session_token) > 3800: return [ { "name": "__Secure-next-auth.session-token.0", "value": session_token[:3800], "domain": domain, "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", }, { "name": "__Secure-next-auth.session-token.1", "value": session_token[3800:], "domain": domain, "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", }, ] return [ { "name": "__Secure-next-auth.session-token", "value": session_token, "domain": domain, "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", } ] cookies = _build("chatgpt.com") + _build("auth.openai.com") context.add_cookies(cookies) def _click_primary_auth_button(page, field, labels): """ 只点击当前输入框所在表单的主按钮,避免误点 Continue with Google/Apple/Microsoft。 """ label_re = re.compile(rf"^(?:{'|'.join(re.escape(label) for label in labels)})$", re.I) try: form = field.locator("xpath=ancestor::form[1]").first btn = form.get_by_role("button", name=label_re).first if btn.is_visible(timeout=2000): btn.click() return True except Exception: pass try: form = field.locator("xpath=ancestor::form[1]").first btn = form.locator('button[type="submit"], input[type="submit"]').first if btn.is_visible(timeout=2000): btn.click() return True except Exception: pass try: btn = page.get_by_role("button", name=label_re).last if btn.is_visible(timeout=2000): btn.click() return True except Exception: pass try: field.press("Enter") return True except Exception: return False def _select_existing_api_organization(page) -> bool: trigger_selectors = ( 'button:has-text("New organization")', '[role="button"]:has-text("New organization")', 'button:has-text("新组织")', '[role="button"]:has-text("新组织")', ) for selector in trigger_selectors: try: trigger = page.locator(selector).first if not trigger.is_visible(timeout=800): continue trigger.click() time.sleep(0.5) for option in page.locator('[role="option"]').all(): try: label = option.inner_text(timeout=500).strip() except Exception: continue lowered = label.lower() if not label or "new organization" in lowered or "新组织" in label: continue option.click() logger.info("[Codex] 已切换到已有 API organization: %s", label) time.sleep(0.5) return True logger.warning("[Codex] API organization 下拉中未找到可用的已有组织,保留当前选择") return False except Exception: continue return False _CHOOSE_ACCOUNT_PAGE_HINTS = ( "choose an account", "select an account", "continue as", "choose a chatgpt account", "选择一个账号", "选择账号", ) _CHOOSE_ACCOUNT_IGNORE_LABELS = { "choose an account", "select an account", "choose a chatgpt account", "continue as", "continue", "继续", "allow", "log in", "cancel", "back", "terms of use", "privacy policy", } _CHOOSE_ACCOUNT_IGNORE_SUBSTRINGS = ( "terms of use", "privacy policy", "continue with", ) def _is_choose_account_page(page) -> bool: url = (getattr(page, "url", "") or "").lower() if "choose-an-account" in url: return True try: body = page.locator("body").inner_text(timeout=1200).lower() except Exception: body = "" return any(hint in body for hint in _CHOOSE_ACCOUNT_PAGE_HINTS) def _is_choose_account_ignored_label(text: str) -> bool: lowered = str(text or "").strip().lower() if lowered in _CHOOSE_ACCOUNT_IGNORE_LABELS: return True return any(token in lowered for token in _CHOOSE_ACCOUNT_IGNORE_SUBSTRINGS) def _click_oauth_locator(loc) -> bool: try: loc.click(timeout=3000) return True except Exception: try: loc.click(force=True) return True except Exception: return False def _choose_account_label_candidates(page): if not _is_choose_account_page(page): return [] selectors = ( "button", "a", '[role="button"]', '[role="option"]', '[aria-selected="true"]', '[aria-selected="false"]', "[data-state]", "li", "label", "div", ) seen = set() candidates = [] for selector in selectors: try: for loc in page.locator(selector).all(): try: if not loc.is_visible(timeout=100): continue text = re.sub(r"\s+", " ", loc.inner_text(timeout=200)).strip() except Exception: continue lowered = text.lower() if not text or lowered in seen or len(text) > 160 or _is_choose_account_ignored_label(lowered): continue seen.add(lowered) candidates.append((text, loc)) except Exception: continue return candidates def _select_oauth_account(page, email: str | None) -> bool: preferred_email = str(email or "").strip().lower() if not preferred_email: return False local_part = preferred_email.split("@", 1)[0] if "@" in preferred_email else preferred_email for text, loc in _choose_account_label_candidates(page): lowered = text.strip().lower() if preferred_email not in lowered and (not local_part or local_part not in lowered): continue if not _click_oauth_locator(loc): continue logger.info("[Codex] 选择 OAuth 账号: %s", text) time.sleep(2) try: confirm = page.locator( 'button:has-text("Continue"), button:has-text("继续"), button:has-text("Allow")' ).first if confirm.is_visible(timeout=800): confirm.click() logger.info("[Codex] 已确认 OAuth 账号选择") time.sleep(3) except Exception: pass return True for selector in ( f'text="{preferred_email}"', f"text=/{re.escape(preferred_email)}/i", ): try: loc = page.locator(selector).first if not loc.is_visible(timeout=500): continue if not _click_oauth_locator(loc): continue logger.info("[Codex] 选择 OAuth 账号: %s", preferred_email) time.sleep(2) return True except Exception: continue return False def _select_choose_account(page, email: str | None) -> bool: return _select_oauth_account(page, email) def _oauth_page_has_terminal_error(page) -> bool: body_excerpt = _page_excerpt(page, limit=400).lower() return ( "no_valid_organizations" in body_excerpt or "an error occurred during authentication" in body_excerpt or "operation timed out" in body_excerpt or "unsupported_country_region_territory" in body_excerpt or "country, region, or territory not supported" in body_excerpt ) def _recover_oauth_timeout_page(page) -> bool: body_excerpt = _page_excerpt(page, limit=400).lower() if "operation timed out" not in body_excerpt: return False for selector in ( 'button:has-text("Try again")', '[role="button"]:has-text("Try again")', 'a:has-text("Try again")', 'button:has-text("Retry")', ): try: btn = page.locator(selector).first if not btn.is_visible(timeout=800): continue logger.info("[Codex] 命中 OAuth 超时错误页,尝试点击 Try again 继续当前流程...") btn.click() time.sleep(3) return True except Exception: continue return False def _recover_oauth_no_valid_organizations_page(page) -> bool: body_excerpt = _page_excerpt(page, limit=500).lower() if "no_valid_organizations" not in body_excerpt and "no valid organizations" not in body_excerpt: return False for selector in ( 'button:has-text("Try again")', '[role="button"]:has-text("Try again")', 'a:has-text("Try again")', 'button:has-text("Retry")', '[role="button"]:has-text("Retry")', ): try: btn = page.locator(selector).first if not btn.is_visible(timeout=800): continue logger.info("[Codex] 命中 no_valid_organizations 错误页,尝试点击 Try again 继续当前 OAuth 流程...") btn.click() time.sleep(4) return True except Exception: continue return False def _is_oauth_login_challenge_page(page, trace_events=None) -> bool: try: url = (page.url or "").lower() except Exception: url = "" if "/api/accounts/login" in url or "auth.openai.com/log-in" in url: return True return bool(trace_events and _oauth_trace_has_login_challenge(trace_events) and "/oauth" not in url) def _is_login_page_url(url: str | None) -> bool: url = (url or "").lower() return "/api/accounts/login" in url or "auth.openai.com/log-in" in url or "log-in-or-create-account" in url def _wait_for_oauth_challenge_progress(page, timeout=20) -> bool: deadline = time.time() + timeout last_url = "" while time.time() < deadline: try: url = page.url or "" last_url = url except Exception: url = "" lower_url = url.lower() if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in lower_url: return True if ( lower_url and "/api/accounts/login" not in lower_url and "auth.openai.com/log-in" not in lower_url and "email-verification" not in lower_url ): if any(marker in lower_url for marker in ("/oauth", "consent", "organization", "choose-an-account")): return True try: if _is_otp_input_visible(page, timeout=300): return False except Exception: pass time.sleep(0.5) logger.info("[Codex] OAuth login challenge progress wait timed out, last_url=%s", last_url) return False def _complete_oauth_login_challenge(page, email, password, mail_client, min_email_id, used_email_ids) -> bool: acted = False try: for attempt in range(2): email_input = page.locator('input[name="email"], input[id="email-input"], input[id="email"]').first if not email_input.is_visible(timeout=3000): break email_input.fill(email) acted = True time.sleep(0.5) _click_primary_auth_button(page, email_input, ["Continue", "继续"]) time.sleep(3) if not _is_google_redirect(page): break _screenshot(page, f"codex_login_challenge_google_email_{attempt + 1}.png") logger.warning("[Codex] OAuth login challenge email step redirected to Google; retrying") page.go_back(wait_until="domcontentloaded", timeout=30000) time.sleep(2) except Exception: pass try: for attempt in range(2): pwd_input = page.locator('input[name="password"], input[type="password"]').first if not pwd_input.is_visible(timeout=3000): break acted = True if password: pwd_input.fill(password) time.sleep(0.5) _click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"]) else: otp_btn = page.locator( 'button:has-text("一次性验证码"), button:has-text("one-time"), button:has-text("email login")' ).first if otp_btn.is_visible(timeout=3000): otp_btn.click() else: _click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"]) time.sleep(5) if not _is_google_redirect(page): break _screenshot(page, f"codex_login_challenge_google_password_{attempt + 1}.png") logger.warning("[Codex] OAuth login challenge password step redirected to Google; retrying") page.go_back(wait_until="domcontentloaded", timeout=30000) time.sleep(2) except Exception: pass try: if _is_otp_input_visible(page, timeout=3000): if not mail_client: logger.warning("[Codex] OAuth login challenge needs OTP but no mail_client is available") return acted submit_status = _resolve_email_verification( page, mail_client=mail_client, email=email, after_email_id=min_email_id, used_email_ids=used_email_ids, wait_log="[Codex] OAuth login challenge needs OTP, waiting for emailId > %d", wait_timeout=90, ) if submit_status != "no_code": acted = True _wait_for_oauth_challenge_progress(page, timeout=20) except Exception: pass if "about-you" in (page.url or ""): _complete_oauth_about_you(page) acted = True return acted def _is_google_redirect(page): url = (page.url or "").lower() if "accounts.google.com" in url: return True try: text = page.locator("body").inner_text(timeout=1000).lower() return "sign in with google" in text[:300] except Exception: return False _OTP_INPUT_SELECTORS = ( 'input[name="code"], input[inputmode="numeric"], input[autocomplete="one-time-code"], ' 'input[placeholder*="验证码"], input[placeholder*="code" i]' ) _OTP_SINGLE_INPUT_SELECTORS = 'input[maxlength="1"], input[data-input-otp], input[aria-label*="digit" i]' _OTP_INVALID_HINTS = ( "invalid code", "incorrect code", "wrong code", "expired code", "check the code and try again", "验证码无效", "验证码错误", "验证码已过期", ) def _is_otp_input_visible(page, timeout=500): try: return page.locator(_OTP_INPUT_SELECTORS).first.is_visible(timeout=timeout) except Exception: return False def _detect_otp_error(page): try: body = page.locator("body").inner_text(timeout=1500).lower().replace("\n", " ") except Exception: return None for hint in _OTP_INVALID_HINTS: if hint in body: return hint return None def _is_otp_progress_url(url: str | None) -> bool: url = (url or "").lower() if not url: return False if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url: return True return any( marker in url for marker in ( "consent", "organization", "choose-an-account", "about-you", "/oauth/oauth2/auth", "/sign-in-with-chatgpt/codex", ) ) and "email-verification" not in url def _wait_for_otp_submit_result(page, timeout=12): """ 等待验证码提交结果: - accepted: 验证码输入框已消失 / 页面已前进 - invalid: 页面明确提示验证码错误 - pending: 既没报错也没明显前进(常见于页面较慢或状态未稳定) """ deadline = time.time() + timeout while time.time() < deadline: try: if _is_otp_progress_url(page.url): return "accepted", None except Exception: pass err = _detect_otp_error(page) if err: return "invalid", err if not _is_otp_input_visible(page, timeout=250): return "accepted", None time.sleep(0.5) try: if _is_otp_progress_url(page.url): return "accepted", None except Exception: pass err = _detect_otp_error(page) if err: return "invalid", err return "pending", None def _visible_otp_slot_inputs(page, timeout=300): try: candidates = page.locator(_OTP_SINGLE_INPUT_SELECTORS).all() except Exception: return [] visible = [] for loc in candidates: try: if loc.is_visible(timeout=timeout): visible.append(loc) except Exception: continue return visible def _fill_otp_code(page, code: str) -> bool: value = str(code or "").strip() if not value: return False slot_inputs = _visible_otp_slot_inputs(page, timeout=200) if len(slot_inputs) >= min(4, len(value)): logger.info("[Codex] 检测到 %d 个单字符验证码输入框", len(slot_inputs)) for index, char in enumerate(value): if index >= len(slot_inputs): break loc = slot_inputs[index] try: loc.click(force=True) except Exception: pass try: loc.fill("") except Exception: pass try: loc.fill(char) except Exception: try: loc.type(char, delay=50) except Exception: try: page.keyboard.type(char, delay=50) except Exception: return False time.sleep(0.1) return True try: otp_input = page.locator(_OTP_INPUT_SELECTORS).first if otp_input.is_visible(timeout=2000): otp_input.fill(value) return True except Exception: pass return False def _click_otp_submit_button(page) -> bool: for selector in ( 'button[type="submit"]', 'button:has-text("Continue")', 'button:has-text("继续")', 'button:has-text("Verify")', ): try: button = page.locator(selector).first if button.is_visible(timeout=800): button.click() return True except Exception: continue return False def _poll_mail_verification_code( mail_client, email: str, *, after_email_id: int, used_email_ids: set[int], timeout: int = 120, require_sender: bool = False, ): deadline = time.time() + timeout while time.time() < deadline: for em in mail_client.search_emails_by_recipient(email, size=5): email_id = em.get("emailId", 0) if email_id <= after_email_id or email_id in used_email_ids: continue if require_sender: sender = (em.get("sendEmail") or "").lower() if "openai" not in sender and "chatgpt" not in sender: continue subject = (em.get("subject") or "").lower() if "invited" in subject or "invitation" in subject: continue code = mail_client.extract_verification_code(em) if code: return code, email_id time.sleep(3) return None, 0 def _resolve_email_verification( page, *, mail_client, email: str, after_email_id: int, used_email_ids: set[int], wait_log: str, require_sender: bool = False, wait_timeout: int = 120, submit_timeout: int = 15, ) -> str: logger.info(wait_log, after_email_id) otp, otp_email_id = _poll_mail_verification_code( mail_client, email, after_email_id=after_email_id, used_email_ids=used_email_ids, timeout=wait_timeout, require_sender=require_sender, ) if not otp: logger.warning("[Codex] 未获取到验证码") return "no_code" logger.info("[Codex] 获取到验证码: %s", otp) for submit_attempt in range(1, 3): current_url = (getattr(page, "url", "") or "").lower() if current_url and "email-verification" not in current_url: used_email_ids.add(otp_email_id) return "accepted" if not _fill_otp_code(page, otp): if current_url and "email-verification" not in current_url: used_email_ids.add(otp_email_id) return "accepted" if submit_attempt < 2: logger.warning("[Codex] 验证码输入框不可用,准备重试第 %d/2 次", submit_attempt + 1) time.sleep(2) continue used_email_ids.add(otp_email_id) logger.warning("[Codex] 验证码输入框不可用,标记并跳过邮件 %s", otp_email_id) return "input_unavailable" time.sleep(0.5) _click_otp_submit_button(page) logger.info("[Codex] 已输入验证码: %s", otp) submit_status, submit_detail = _wait_for_otp_submit_result(page, timeout=submit_timeout) if submit_status == "accepted": used_email_ids.add(otp_email_id) return "accepted" if submit_status == "invalid": used_email_ids.add(otp_email_id) detail_suffix = f",命中提示: {submit_detail}" if submit_detail else "" logger.warning( "[Codex] 验证码邮件 %s(code=%s)被页面判定无效%s,标记并跳过该邮件", otp_email_id, otp, detail_suffix, ) return "invalid" if submit_attempt < 2: logger.warning( "[Codex] 验证码邮件 %s(code=%s)提交后未确认成功,准备重试第 %d/2 次", otp_email_id, otp, submit_attempt + 1, ) time.sleep(2) else: used_email_ids.add(otp_email_id) logger.warning( "[Codex] 验证码邮件 %s(code=%s)提交后仍未确认成功,标记并跳过该邮件", otp_email_id, otp, ) return "pending" return "pending" def _typewrite_credential(page, locator, value, *, delay_ms=50, post_sleep=1.0): """逐字符 keyboard.type 填入 credential,触发 React onChange 事件链. Round 11 五轮 — 阶段 2 fresh re-login 必备: OpenAI auth /log-in 页 React 表单对 Playwright fill() 不友好 —— fill 一次性 setValue,不触发 input 事件 → React internal state 不更新 → Continue 按钮变灰禁用。 keyboard.type 逐字符模拟真用户击键,触发 onChange/onInput 完整事件链。 注:注册流的 /create-account/password 页用 fill() 是 OK 的(不同 React form impl); 本 helper 仅用于 fresh login (/log-in) 阶段。 """ try: locator.click() time.sleep(0.3) # 清空可能预填的内容 try: locator.press("Control+A") locator.press("Delete") except Exception: pass time.sleep(0.2) page.keyboard.type(value, delay=delay_ms) time.sleep(post_sleep) return True except Exception as exc: logger.warning("[Codex] _typewrite_credential 失败: %s", exc) return False def _perform_fresh_relogin_in_context(context, email, password, mail_client, *, used_email_ids): """阶段 2 — fresh chatgpt.com login. Round 11 五轮 Option A: 阶段 1(silent step-0 + cookie 注入)拿到 plan_type=team 或 bundle=None 时, 说明 chatgpt.com session_token 内嵌的 user identity 已被锁死在原 Team workspace, NextAuth refresh 不能切。唯一兜底:清空 OAuth context 所有 cookies,做一次完整的 chatgpt.com 登录(email + password),拿到 Personal-bound 全新 session,再走 OAuth。 流程: 1. context.clear_cookies() — 清空 stale 的 chatgpt.com / auth.openai.com 双域 session 2. goto chatgpt.com/auth/login,过 Cloudflare 3. 用 _typewrite_credential(keyboard.type)填 email + password — 不用 fill() 避免灰按钮 4. 处理 OTP(若 mail_client 可用) 5. 等 chatgpt.com 登录完成 Returns: bool — True 表示 fresh login 成功(context 现持有新 session),False 表示失败 """ logger.info("[Codex] 阶段 2 fresh re-login 开始: 清空 context cookies → 重新登录 chatgpt.com") try: context.clear_cookies() except Exception as exc: logger.warning("[Codex] 清空 context cookies 失败: %s", exc) # 重新刷邮箱 ID snapshot,fresh login 阶段不复用阶段 1 的旧 snapshot fresh_email_id_before = 0 if mail_client: try: _pre = mail_client.search_emails_by_recipient(email, size=1) if _pre: fresh_email_id_before = _pre[0].get("emailId", 0) except Exception: pass page = context.new_page() try: page.goto("https://chatgpt.com/auth/login", wait_until="domcontentloaded", timeout=60000) time.sleep(5) # 过 Cloudflare(若有) for _i in range(12): try: if "verify you are human" not in page.content()[:2000].lower(): break except Exception: break logger.info("[Codex] fresh re-login 等待 Cloudflare...(%ds)", _i * 5) time.sleep(5) # 点击 "登录" / "Log in" 按钮(若 chatgpt.com 首页落在欢迎页) try: login_btn = page.locator( 'button:has-text("登录"), button:has-text("Log in"), a:has-text("Log in"), a:has-text("登录")' ).first if login_btn.is_visible(timeout=3000): login_btn.click() time.sleep(3) except Exception: pass _screenshot(page, "codex_relogin_01_login_page.png") # === Email 步骤 === try: ei = page.locator( 'input[name="email"], input[id="email-input"], input[id="email"], input[type="email"]' ).first if ei.is_visible(timeout=8000): logger.info("[Codex] fresh re-login: 用 keyboard.type 填入 email...") if not _typewrite_credential(page, ei, email): logger.warning("[Codex] fresh re-login: keyboard.type email 失败") return False _click_primary_auth_button(page, ei, ["Continue", "继续"]) time.sleep(3) _screenshot(page, "codex_relogin_02_after_email.png") else: logger.warning("[Codex] fresh re-login: email input 不可见") except Exception as exc: logger.warning("[Codex] fresh re-login email 步骤异常: %s", exc) # === Password 步骤(关键:/log-in 页) === try: pi = page.locator('input[name="password"], input[type="password"]').first if pi.is_visible(timeout=8000): if password: logger.info("[Codex] fresh re-login: 用 keyboard.type 填入 password...") if not _typewrite_credential(page, pi, password): logger.warning("[Codex] fresh re-login: keyboard.type password 失败") return False _click_primary_auth_button(page, pi, ["Continue", "继续", "Log in"]) time.sleep(5) else: # 无密码 → 一次性验证码登录 otp_btn = page.locator( 'button:has-text("一次性验证码"), button:has-text("one-time"), button:has-text("email login")' ).first if otp_btn.is_visible(timeout=3000): logger.info("[Codex] fresh re-login: 无密码,点击一次性验证码登录") otp_btn.click() time.sleep(3) _screenshot(page, "codex_relogin_03_after_password.png") except Exception as exc: logger.warning("[Codex] fresh re-login password 步骤异常: %s", exc) # === 可能 OTP === try: if _is_otp_input_visible(page, timeout=5000) and mail_client: _resolve_email_verification( page, mail_client=mail_client, email=email, after_email_id=fresh_email_id_before, used_email_ids=used_email_ids, wait_log="[Codex] fresh re-login: 需要 OTP,等待 emailId > %d 的新邮件...", require_sender=True, ) time.sleep(5) _screenshot(page, "codex_relogin_04_after_otp.png") elif _is_otp_input_visible(page, timeout=500): logger.warning("[Codex] fresh re-login: 需要 OTP 但无 mail_client") except Exception: pass # === 等 chatgpt.com 登录完成 === # 成功条件:URL 不再含 /auth/login,且页面可正常渲染 for _i in range(15): cur = page.url or "" if "auth/login" not in cur and "/log-in" not in cur: # Round 14 — 全新号在此处常停在 about-you("How old are you?" Full name/Age/ # Finish creating account)。URL 已不含 /log-in 会被误判为"登录完成",但账号其实 # 没建完:不填资料 + 点 Finish,后续 OAuth /authorize 会因账号未完成而回到登录页, # consent 永远拿不到 auth code。必须在此把 about-you 填完点 Finish creating account。 if "about-you" in cur.lower(): logger.info("[Codex] fresh re-login: 命中 about-you,填写姓名/年龄并完成建号...") _complete_oauth_about_you(page) cur = page.url or "" logger.info("[Codex] fresh re-login: 登录完成,当前 URL: %s", cur[:120]) _screenshot(page, "codex_relogin_05_logged_in.png") return True time.sleep(2) # 还停在 login 页 — 失败 logger.warning("[Codex] fresh re-login: 等待登录完成超时,当前 URL: %s", page.url[:120]) _screenshot(page, "codex_relogin_05_timeout.png") return False except Exception as exc: logger.warning("[Codex] fresh re-login 异常: %s", exc) return False finally: close_playwright_objects(page=page, logger=logger, label="codex-fresh-relogin") def _wait_for_oauth_about_you_exit(page, *, timeout: float = 12.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: if "about-you" not in (page.url or "").lower(): return True time.sleep(0.5) return "about-you" not in (page.url or "").lower() def _click_oauth_about_you_submit(page) -> None: for selector in ( 'button:has-text("Finish creating account")', 'button:has-text("完成帐户创建")', 'button:has-text("完成账户创建")', 'button:has-text("Create account")', 'button:has-text("Continue")', 'button:has-text("继续")', 'button[type="submit"]', ): try: button = page.locator(selector).first if button.is_visible(timeout=1000): button.click() return except Exception: continue page.keyboard.press("Enter") def _complete_oauth_about_you(page, signup_profile: SignupProfile | None = None) -> bool: """Complete OAuth about-you with the same identity snapshot used at registration.""" signup_profile = signup_profile or generate_signup_profile() logger.info("[Codex] 检测到 about-you 页面,填写个人信息...") try: birthday_orders = signup_profile.positional_birthday_orders() for attempt, values in enumerate(birthday_orders, 1): if "about-you" not in (page.url or "").lower(): return True name_input = page.locator('input[name="name"]').first if name_input.is_visible(timeout=3000): name_input.fill(signup_profile.full_name) logger.info("[Codex] 已填入随机姓名: %s", signup_profile.full_name) spinbuttons = page.locator('[role="spinbutton"]').all() if len(spinbuttons) < 3: age_input = page.locator( 'input[name="age"], input[placeholder*="年龄"], input[placeholder*="Age"]' ).first try: if age_input.is_visible(timeout=3000): age_input.fill(signup_profile.age_text) logger.info("[Codex] 已填入随机年龄: %s", signup_profile.age_text) except Exception: logger.warning("[Codex] 未找到年龄/生日输入框") time.sleep(0.5) _click_oauth_about_you_submit(page) time.sleep(2) assert_not_blocked(page, "oauth_about_you_submit") _screenshot(page, "codex_03d_after_aboutyou.png") done = _wait_for_oauth_about_you_exit(page) logger.info("[Codex] about-you 完成=%s,当前 URL: %s", done, page.url) return done try: for label_sel in ("text=生日日期", "text=Date of birth"): try: page.locator(label_sel).first.click(timeout=1000) time.sleep(0.3) break except Exception: continue except Exception: pass for sb, val in zip(spinbuttons[:3], values): sb.click(force=True) time.sleep(0.2) try: page.keyboard.press("ControlOrMeta+A") time.sleep(0.1) except Exception: pass page.keyboard.type(val, delay=80) time.sleep(0.3) logger.info( "[Codex] 已填入随机生日: %s (spinbutton, attempt=%d, values=%s)", signup_profile.birthday_text, attempt, values, ) time.sleep(0.5) _click_oauth_about_you_submit(page) time.sleep(2) assert_not_blocked(page, "oauth_about_you_submit") _screenshot(page, "codex_03d_after_aboutyou.png") if _wait_for_oauth_about_you_exit(page): logger.info("[Codex] about-you 完成,当前 URL: %s", page.url) return True logger.warning( "[Codex] about-you 第 %d 次提交后仍停留在 profile 页,尝试下一生日顺序", attempt, ) logger.warning("[Codex] about-you 多顺序尝试后仍未完成,当前 URL: %s", page.url) return False except RegisterBlocked: raise except Exception as exc: logger.error("[Codex] about-you 处理失败: %s", exc) return False def login_codex_via_browser( email, password, mail_client=None, *, return_result=False, use_personal=False, chatgpt_session_token=None, prefetched_personal_uuid=None, pre_signed_in_cookies: list | None = None, signup_profile: SignupProfile | None = None, playwright_proxy_url: str | None = None, ): """ 通过 Playwright 自动完成 Codex OAuth 登录。 mail_client: CloudMailClient 实例,用于自动读取登录验证码。 use_personal: 若为 True,则走"个人账号"流程 —— 不注入 Team _account cookie, workspace 选择时跳过 Team 直接用 Personal。用于已退出 Team 的子账号生成 free plan 的 rt/at。 chatgpt_session_token: 注册阶段从 chatgpt.com 抽出的 __Secure-next-auth.session-token, 在 use_personal=True 时注入 auth.openai.com 跳过 /log-in 表单。 沿用 SessionCodexAuthFlow._inject_auth_cookies 的注入模式(主号专用扩展给子号)。 prefetched_personal_uuid: Round 11 V8 — accounts.json 持久化的 personal_workspace_id; 非空时直接拼到 OAuth `auth_url` 的 allowed_workspace_id 参数, 省一次 silent step-0 内的 POST /accounts/personal。 pre_signed_in_cookies: 直接注册后传入的已登录 chatgpt.com/auth.openai.com cookies。 提供时优先尝试 ChatGPT session fallback,避免新号二次 OAuth 登录挑战。 signup_profile: 注册 about-you 使用过的身份快照;OAuth about-you 必须复用它。 返回 auth bundle: {access_token, refresh_token, id_token, account_id, email, plan_type, personal_workspace_id} return_result=True 时返回 {ok, bundle, error_type, error_detail, retryable}。 Round 11 五轮 Option A — 两阶段 personal OAuth: 阶段 1(快路径):有 chatgpt_session_token → silent step-0 双域注入 + NextAuth refresh, 直接 goto auth_url。注册→未踢出场景大部分用得上。 拿到 plan_type=free 直接返回。 阶段 2(fresh re-login fallback):阶段 1 拿到 plan != free 或 bundle=None, 说明 session_token 内嵌 user identity 锁死原 Team。清空 OAuth context 所有 cookies,做一次完整 chatgpt.com 登录(keyboard.type 逐字符, 绕过 React 灰按钮),拿 Personal-bound 全新 session,再走 OAuth → plan=free。 """ code_verifier, code_challenge = _generate_pkce() state = secrets.token_urlsafe(16) _used_email_ids: set[int] = set() # 记录已尝试过的邮件,避免重复提交同一封验证码邮件 # personal 模式下不引导到 Team workspace chatgpt_account_id = "" if use_personal else get_chatgpt_account_id() auth_url = _build_auth_url(code_challenge, state) logger.info( "[Codex] 开始 OAuth 登录: %s (use_personal=%s, session_token=%s)", email, use_personal, "yes" if chatgpt_session_token else "no", ) auth_code = None # Round 11 V8 — personal UUID 在 silent step-0 / stage2 re-login 完成后通过 # POST /backend-api/accounts/personal getOrCreate 拿到,之后用 allowed_workspace_id # 注入 OAuth /authorize URL,绕过 default_workspace_id sticky-Team 死锁。 # 注入既可来自调用方 prefetched_personal_uuid(accounts.json 持久化字段), # 也可来自 silent step-0 现场 fetch。 personal_uuid = prefetched_personal_uuid # Round 11 V8 fast-path(verifier #2 P0)+ V12 P0.1 deprecation — # `_password_grant_access_token` 已被 V11 探活实证不可用:OpenAI auth.openai.com/oauth/token # 30 个 client_id/scope/audience 组合全员 HTTP 400 `unknown_parameter: 'username'`, # ROPC password grant 已撤(OAuth 2.1 demand 移除)。保留此 fast-path 仅作为未来 # OpenAI 重启 ROPC 的 hook,实际效果在新号场景永远进 logger.warning 分支。 # 真正落 personal_uuid 的路径切到 silent step-0 内的 fetch_nextauth_backend_access_token # → fetch_personal_uuid Bearer 链(V12 P0.1)。 if use_personal and not personal_uuid and email and password: try: tmp_access = _password_grant_access_token(email, password) if tmp_access: fetched_uuid = fetch_personal_uuid(tmp_access) if fetched_uuid: personal_uuid = fetched_uuid logger.info( "[Codex] fast-path Bearer 拿到 personal_workspace_id=%s", personal_uuid, ) else: logger.warning( "[Codex] fast-path password grant 成功但 fetch_personal_uuid 返回 None,等 silent step-0 NextAuth fallback" ) else: logger.warning( "[Codex] fast-path password grant 失败(V11 已知 ROPC 不可用),等 silent step-0 NextAuth fallback" ) except Exception as exc: # noqa: BLE001 — fast-path 失败不应阻断 OAuth 主流程 logger.warning("[Codex] fast-path 异常,等 silent step-0 NextAuth fallback: %s", exc) with sync_playwright() as p: try: launch_kwargs = get_playwright_launch_options(proxy_url=playwright_proxy_url) except TypeError as exc: if "proxy_url" not in str(exc): raise launch_kwargs = get_playwright_launch_options() browser = p.chromium.launch(**launch_kwargs) context = browser.new_context(**get_playwright_context_options()) if pre_signed_in_cookies: try: context.add_cookies(pre_signed_in_cookies) logger.info( "[Codex] 已注入 %d 个 session cookie,优先尝试 ChatGPT session fallback", len(pre_signed_in_cookies), ) except Exception as exc: logger.warning("[Codex] 注入 session cookie 失败,回退到完整登录: %s", exc) pre_signed_in_cookies = None if pre_signed_in_cookies and not use_personal: session_fallback_bundle = _fetch_team_session_bundle_from_context( context, email, chatgpt_account_id, stage_label="pre-oauth", ) if session_fallback_bundle: close_playwright_objects(context=context, browser=browser, logger=logger, label="codex-session-fallback") if return_result: return _oauth_result_from_bundle(session_fallback_bundle) return session_fallback_bundle # Round 11 四轮 — Personal 模式 session_token 注入(silent step-0): # 实测刚踢出 Team 的新号在 OAuth /log-in 页 fill email 后 Continue 按钮变灰禁用, # login flow 永远卡在 email 步骤,bundle=None。根因疑似 Playwright fill() 的 # input 事件被 OpenAI auth /log-in React 表单的 anti-bot 检测识别。 # 单纯把 session_token 灌到 auth.openai.com 不够 —— NextAuth 对加密 token # 跨域 issuer 校验严格,/oauth/authorize 不认 chatgpt.com 颁发的 token。 # 必须做"silent step-0":把 token 灌到 chatgpt.com 域,先 goto chatgpt.com # 让服务端 _next-auth API 校验 session 并写一套配套 cookies(oai-did / # __cflb / cf_clearance / _puid 等),然后再 goto auth_url。OpenAI auth # backend 看到来自 chatgpt.com 的有效会话引荐 → 直接跳过 /log-in 进 consent。 # 同时 auth.openai.com 域也注入一份(冗余兜底),双域同步 SessionCodexAuthFlow。 if use_personal and chatgpt_session_token: _inject_personal_session_cookies(context, chatgpt_session_token) logger.info( "[Codex] personal 模式注入 __Secure-next-auth.session-token (len=%d) 到 chatgpt.com + auth.openai.com", len(chatgpt_session_token), ) # === Step 0: 先登录 ChatGPT 并切换到 Team workspace === # Team 模式:登录前注入 _account cookie 引导登录进入 Team workspace。 # # Round 11 三轮 — Personal 模式也需要 step-0: # 历史注释说"personal 模式跳过 step-0 因为 chatgpt_account_id="" 无 cookie 可注入"。 # 但实测发现刚踢出 Team 的新号在 OpenAI auth backend 端 oai-oauth-session.workspaces=[] # (server-side 状态),直接走 auth_url 时 /oauth/authorize → /log-in → 永远循环, # 即便 consent loop 点 10 次 Continue,URL 仍卡在 /log-in 没有 auth_code。 # # 根因:OpenAI 只在用户在 chatgpt.com 实际登录后才在 OAuth session 端 populate workspaces。 # 新号注册流走 chatgpt.com 是另一个 browser context;OAuth 这个新 context 第一次访问 # auth.openai.com,session 端没有任何 workspace 关联 → /oauth/authorize 拒绝颁 token。 # # 修复:personal 模式也走 step-0,先 chatgpt.com 登录建立 Personal workspace 上下文, # auth.openai.com session 端 workspaces[] 被 populate 后,auth_url 即可正常 consent。 # 区别:不注入 _account cookie(没有 Team workspace_id),让 chatgpt.com 自动用 Personal。 # 在登录开始前记录当前最新邮件 ID,后续只接受比这个更新的 _email_id_before_login = 0 if mail_client: try: _pre = mail_client.search_emails_by_recipient(email, size=1) if _pre: _email_id_before_login = _pre[0].get("emailId", 0) except Exception: pass if use_personal: if chatgpt_session_token: # Round 11 四轮 — silent step-0:cookie 已注入双域,只需 goto chatgpt.com # 让服务端 next-auth API 校验 session 并写齐配套 cookies(oai-did / _puid / # __cflb 等),OpenAI auth backend 看到来自 chatgpt.com 的有效会话引荐 → # /oauth/authorize 不再走 /log-in 表单,直接 consent。 # # 进一步:踢出 Team 后,session_token 内 user.workspace 字段还指向 Team, # OAuth 仍拿到 plan_type=team。call /api/auth/session 强制 NextAuth 刷新 # session,把 user.workspace 切到 Personal。然后再走 OAuth 才能拿 plan=free。 logger.info("[Codex] personal 模式 silent step-0: cookie 注入后访问 chatgpt.com 验证 session...") _silent_page = context.new_page() try: _silent_page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=60000) time.sleep(5) # 过 Cloudflare(若有) for _i in range(8): html_lower = _silent_page.content()[:2000].lower() if "verify you are human" not in html_lower and "challenge" not in (_silent_page.url or "").lower(): break logger.info("[Codex] silent step-0 等待 Cloudflare... (%ds)", _i * 5) time.sleep(5) # 强制 NextAuth session refresh:踢出 Team 后必须刷新 user.workspace # 才能让 OAuth 拿 plan=free 而非缓存的 plan=team。 try: refresh_resp = _silent_page.evaluate( """ async () => { const r = await fetch('/api/auth/session?update', { credentials: 'include', cache: 'no-store' }); const ct = r.headers.get('content-type') || ''; if (!ct.includes('application/json')) return { ok: r.ok, status: r.status, raw: 'non-json' }; const data = await r.json(); return { ok: r.ok, status: r.status, hasUser: !!data?.user, plan: data?.user?.plan ?? null }; } """ ) logger.info("[Codex] silent step-0 NextAuth session refresh 结果: %s", refresh_resp) except Exception as refresh_exc: logger.warning("[Codex] silent step-0 NextAuth refresh 异常(忽略): %s", refresh_exc) # 再调一次 backend-api/accounts/check,触发 server-side workspace 重新判定 try: accounts_resp = _silent_page.evaluate( """ async () => { const r = await fetch('/backend-api/accounts/check', { credentials: 'include', cache: 'no-store' }); return { status: r.status }; } """ ) logger.info("[Codex] silent step-0 accounts/check 结果: %s", accounts_resp) except Exception as ck_exc: logger.debug("[Codex] silent step-0 accounts/check 异常(忽略): %s", ck_exc) # Round 11 V13 P0.2 — 浏览器内 fetch_personal_uuid_via_page 绕 Cloudflare。 # 已有 prefetched_personal_uuid 则跳过重复 fetch。否则: # primary: /api/auth/session 提 accessToken → page.evaluate Bearer # fetch_personal_uuid_via_page(避开 cf bot 拦截) # fallback: 原 in-browser POST /accounts/personal cookie 路径(新号场景已知 401) if not personal_uuid: try: nextauth_token = fetch_nextauth_backend_access_token(_silent_page) if nextauth_token: fetched_uuid = fetch_personal_uuid_via_page( _silent_page, nextauth_token ) if fetched_uuid: personal_uuid = fetched_uuid logger.info( "[Codex] silent step-0 NextAuth Bearer 路径(浏览器内)拿到 personal_workspace_id=%s", personal_uuid, ) else: logger.warning( "[Codex] silent step-0 NextAuth accessToken 拿到但 fetch_personal_uuid_via_page 返回 None,降级 cookie fallback" ) except Exception as nx_exc: logger.warning( "[Codex] silent step-0 NextAuth route 异常,降级 cookie fallback: %s", nx_exc, ) if not personal_uuid: try: personal_resp = _silent_page.evaluate( """ async () => { const r = await fetch('/backend-api/accounts/personal', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: '{}', cache: 'no-store', }); let data = null; try { data = await r.json(); } catch (_) {} return { status: r.status, id: data && data.id, created: data && data.created }; } """ ) logger.info( "[Codex] silent step-0 cookie fallback /accounts/personal 结果: %s", personal_resp, ) if isinstance(personal_resp, dict) and personal_resp.get("status") == 200: fetched_uuid = personal_resp.get("id") if fetched_uuid: personal_uuid = fetched_uuid logger.info( "[Codex] silent step-0 cookie fallback 拿到 personal_workspace_id=%s (created=%s)", personal_uuid, personal_resp.get("created"), ) except Exception as pu_exc: logger.warning( "[Codex] silent step-0 cookie fallback /accounts/personal fetch 异常(继续走 OAuth): %s", pu_exc, ) cur = _silent_page.url or "" logger.info("[Codex] silent step-0 完成 URL: %s", cur[:120]) _screenshot(_silent_page, "codex_00b_silent_session_validate.png") except Exception as exc: logger.warning("[Codex] silent step-0 异常(继续走 OAuth): %s", exc) finally: close_playwright_objects(page=_silent_page, logger=logger, label="codex-silent-step0") else: logger.info("[Codex] personal 模式: 无 session_token,跳过 step-0,直接走 auth_url") else: if chatgpt_account_id: context.add_cookies( [ { "name": "_account", "value": chatgpt_account_id, "domain": "chatgpt.com", "path": "/", "secure": True, "sameSite": "Lax", }, { "name": "_account", "value": chatgpt_account_id, "domain": "auth.openai.com", "path": "/", "secure": True, "sameSite": "Lax", }, ] ) logger.debug("[Codex] 登录前已注入 _account cookie = %s", chatgpt_account_id) logger.info("[Codex] 先登录 ChatGPT 选择 Team workspace...") _page = context.new_page() _page.goto("https://chatgpt.com/auth/login", wait_until="domcontentloaded", timeout=60000) time.sleep(5) # Cloudflare for _i in range(12): if "verify you are human" not in _page.content()[:2000].lower(): break time.sleep(5) # 点击登录 try: _page.locator('button:has-text("登录"), button:has-text("Log in")').first.click() time.sleep(3) except Exception: pass # 输入邮箱(避免误点 Google/Microsoft 第三方登录按钮) try: ei = _page.locator('input[name="email"], input[id="email-input"], input[id="email"]').first if ei.is_visible(timeout=5000): ei.fill(email) time.sleep(0.5) _click_primary_auth_button(_page, ei, ["Continue", "继续"]) time.sleep(3) except Exception: pass # 输入密码 / 点击一次性验证码登录 try: pi = _page.locator('input[type="password"]').first if pi.is_visible(timeout=5000): if password: pi.fill(password) time.sleep(0.5) _click_primary_auth_button(_page, pi, ["Continue", "继续", "Log in"]) else: # 没有密码,点击"使用一次性验证码登录" otp_btn = _page.locator( 'button:has-text("一次性验证码"), button:has-text("one-time"), button:has-text("email login")' ).first if otp_btn.is_visible(timeout=3000): logger.info("[Codex] 无密码,点击一次性验证码登录") otp_btn.click() else: # fallback: 提交空密码让页面报错,然后找验证码按钮 _click_primary_auth_button(_page, pi, ["Continue", "继续", "Log in"]) time.sleep(8) except Exception: pass # 可能需要邮箱验证码 try: if mail_client and _is_otp_input_visible(_page, timeout=5000): _resolve_email_verification( _page, mail_client=mail_client, email=email, after_email_id=_email_id_before_login, used_email_ids=_used_email_ids, wait_log="[Codex] ChatGPT 登录需要验证码,等待 emailId > %d 的新邮件...", ) time.sleep(5) except Exception: pass _screenshot(_page, "codex_00_chatgpt_login.png") logger.info("[Codex] ChatGPT 登录后 URL: %s", _page.url) # 如果是 workspace 选择页面,Team 模式选配置的 workspace if "workspace" in _page.url: workspace_name = get_chatgpt_workspace_name() logger.info("[Codex] 检测到 workspace 选择页面...") try: ws_btn = _page.locator(f'text="{workspace_name}"').first if workspace_name and ws_btn.is_visible(timeout=3000): logger.info("[Codex] 选择 workspace: %s", workspace_name) ws_btn.click() time.sleep(5) else: # fallback: 选第二个选项(第一个通常是"个人") options = _page.locator('a, button, [role="button"]').all() for opt in options: try: text = opt.inner_text(timeout=1000).strip() if ( text and "个人" not in text and "Personal" not in text and text not in ("ChatGPT", "") ): logger.info("[Codex] 选择 workspace: %s", text) opt.click() time.sleep(5) break except Exception: continue except Exception: pass _screenshot(_page, "codex_00_after_workspace.png") logger.info("[Codex] 选择 workspace 后 URL: %s", _page.url) # _account cookie 已在登录前注入 # 关闭 ChatGPT 页面但保留 context close_playwright_objects(page=_page, logger=logger, label="codex-workspace-page") # 通过监听请求来捕获 OAuth callback redirect def on_request(request): nonlocal auth_code url = request.url if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url: parsed = urllib.parse.urlparse(url) qs = urllib.parse.parse_qs(parsed.query) auth_code = qs.get("code", [None])[0] if auth_code: logger.info("[Codex] 捕获到 auth code!") # 也监听 response/framenavigated 来捕获 redirect URL def on_response(response): nonlocal auth_code url = response.url if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url and not auth_code: parsed = urllib.parse.urlparse(url) qs = urllib.parse.parse_qs(parsed.query) auth_code = qs.get("code", [None])[0] if auth_code: logger.info("[Codex] 从 response 捕获到 auth code!") # Round 11 V8 — 拿到 personal UUID 后,把 OAuth /authorize URL 拼上 allowed_workspace_id, # 让 issuer 在颁 token 时优先选 personal workspace 而非 default_workspace_id 指向的 Team。 if use_personal and personal_uuid: auth_url = _build_auth_url_with_allowed_workspace(code_challenge, state, personal_uuid) logger.info( "[Codex] OAuth /authorize 注入 allowed_workspace_id=%s", personal_uuid, ) page = context.new_page() page.on("request", on_request) page.on("response", on_response) page.goto(auth_url, wait_until="domcontentloaded", timeout=60000) time.sleep(3) _screenshot(page, "codex_01_auth_page.png") # 输入邮箱(注意避免点到 Google/Microsoft/Apple 第三方登录按钮) try: for attempt in range(2): email_input = page.locator('input[name="email"], input[id="email-input"], input[id="email"]').first if not email_input.is_visible(timeout=5000): break email_input.fill(email) time.sleep(0.5) _click_primary_auth_button(page, email_input, ["Continue", "继续"]) time.sleep(3) if not _is_google_redirect(page): break _screenshot(page, f"codex_02_google_redirect_attempt{attempt + 1}.png") logger.warning("[Codex] 邮箱步骤误跳转到 Google 登录,返回重试... (attempt %d)", attempt + 1) page.go_back(wait_until="domcontentloaded", timeout=30000) time.sleep(2) _screenshot(page, "codex_02_after_email.png") except Exception: _screenshot(page, "codex_02_no_email.png") # 输入密码 try: for attempt in range(2): pwd_input = page.locator('input[name="password"], input[type="password"]').first if not pwd_input.is_visible(timeout=5000): break pwd_input.fill(password) time.sleep(0.5) _click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"]) time.sleep(5) if not _is_google_redirect(page): break _screenshot(page, f"codex_03_google_redirect_attempt{attempt + 1}.png") logger.warning("[Codex] 密码步骤误跳转到 Google 登录,返回重试... (attempt %d)", attempt + 1) page.go_back(wait_until="domcontentloaded", timeout=30000) time.sleep(2) _screenshot(page, "codex_03_after_password.png") except Exception: _screenshot(page, "codex_03_no_password.png") # 可能需要邮箱登录验证码 _screenshot(page, "codex_03b_check_otp.png") code_input = None try: code_input = page.locator( 'input[name="code"], input[placeholder*="验证码"], input[placeholder*="code" i]' ).first if not code_input.is_visible(timeout=5000): code_input = None except Exception: code_input = None if code_input and mail_client: logger.info("[Codex] 需要登录验证码,等待 emailId > %d 的新邮件...", _email_id_before_login) start_t = time.time() otp_code = None otp_email_id = 0 while time.time() - start_t < 120: emails = mail_client.search_emails_by_recipient(email, size=5) for em in emails: email_id = em.get("emailId", 0) if email_id <= _email_id_before_login or email_id in _used_email_ids: continue subj = em.get("subject", "").lower() if "invited" in subj or "invitation" in subj: continue otp_code = mail_client.extract_verification_code(em) if otp_code: otp_email_id = email_id break if otp_code: break time.sleep(3) if otp_code: _used_email_ids.add(otp_email_id) logger.info("[Codex] 获取到验证码: %s", otp_code) code_input.fill(otp_code) time.sleep(0.5) page.locator( 'button:has-text("Continue"), button:has-text("继续"), button[type="submit"]' ).first.click() time.sleep(5) _screenshot(page, "codex_03c_after_otp.png") else: logger.warning("[Codex] 未获取到验证码") elif code_input: logger.warning("[Codex] 需要验证码但无 mail_client,无法自动获取") # SPEC-2 shared/add-phone-detection §4 (位点 C-P1):about-you 入口前先探针。 # 注册流程提交 OTP 后 OpenAI 经常把账号引到 add-phone,等切到 about-you 再发现就太晚。 assert_not_blocked(page, "oauth_about_you") # 处理 about-you 页面(可能出现在 OAuth 流程中) if "about-you" in page.url: if not _complete_oauth_about_you(page, signup_profile=signup_profile): logger.warning("[Codex] about-you 未完成,放弃本次 OAuth,交由上层重试/分类处理") close_playwright_objects(page, context, browser, logger=logger, label="codex-oauth") return None # Round 11 三轮 — Personal 模式: pre-consent workspace_select. # 历史: # Round 8 加 explicit workspace_select 是因为"default 不会自动 unset"研究结论, # 想强制把 default 切到 Personal 再 consent。 # Round 11 二轮把 workspace_select 前置到 consent loop 之前,绕过"consent loop 1-2 # 步抓到 auth_code → workspace_select 永远不被调用"的 bug。 # # Round 11 三轮发现:刚踢出 Team 的新号在 OpenAI auth backend 端 # `oai-oauth-session.workspaces=[]`(server-side 状态),/workspace UI 显示 # "Workspaces not found in client auth session" 错误。force_select_personal_via_ui # 把浏览器 goto /workspace,落在错误页 → consent loop 找不到 consent button → # 永远 bundle=None,5 次外层重试全失败。 # # 修复:把 pre-consent workspace_select 改成"尽力而为"—— # 1. 如果 workspaces[] 非空,正常 POST /api/accounts/workspace/select 切 personal # 2. 如果 workspaces[] 空(刚踢出场景),不再 goto /workspace UI(肯定错),而是直接 # 跳过 → 让 consent loop 在 auth_url 上自然运行。OAuth backend 用 default # workspace 颁 token,如果 plan!=free,外层 5 次重试 + bundle plan_type 校验 # 会拦下来,等后端最终一致性同步(回归 Round 4/e760be9 8s sleep 行为) # 3. 任何情况 finally 强制 goto auth_url,确保 consent loop 入口正确 if use_personal: try: from autoteam.oauth_workspace import ( ensure_personal_workspace_selected as _pre_consent_ws_select, ) # 用当前页面状态(login + about-you 完成,session cookie 已建立)调 workspace/select. # auth_url 作为 fallback 的 base 用,正常成功不会用到. pre_ws_ok, pre_ws_fail, pre_ws_ev = _pre_consent_ws_select( page, consent_url=auth_url, skip_ui_fallback_on_empty=True, # Round 11 三轮 — 空 workspaces[] 不再 goto /workspace UI ) if pre_ws_ok: logger.info( "[Codex] Personal mode pre-consent workspace_select 成功 — " "后续 consent 应颁 plan=free token" ) else: logger.warning( "[Codex] Personal mode pre-consent workspace_select 失败 " "fail_category=%s evidence=%s,继续走 consent loop(由外层重试兜底)", pre_ws_fail, json.dumps(pre_ws_ev, ensure_ascii=False)[:300], ) _screenshot(page, "codex_03e_pre_consent_workspace_select.png") except Exception as exc: logger.warning( "[Codex] Personal mode pre-consent workspace_select 异常: %s," "继续 consent loop", exc, ) # Round 11 三轮 — 仅在浏览器停留在 /workspace 错误页时导航回 auth_url。 # /workspace 路径出错(force_select_personal_via_ui 走该 URL 但 server 返 # "Workspaces not found in client auth session"),consent loop 找不到按钮永远 bundle=None。 # 但若浏览器在 /log-in / /password / about-you 等正常 OAuth 流程页,goto(auth_url) 会 # **重置** login 表单状态(email/password 输入清空),consent loop 再也跑不通。 # 所以只针对已知的 /workspace 错误页恢复,其他保持 page 当前状态让流程自然跑完。 try: current_url = (page.url or "") if "/workspace" in current_url: logger.info( "[Codex] pre-consent 后浏览器停在 /workspace 错误页,导航回 auth_url 恢复 consent flow (current_url=%s)", current_url[:120], ) page.goto(auth_url, wait_until="domcontentloaded", timeout=15000) time.sleep(2) except Exception as exc: logger.warning("[Codex] 导航回 auth_url 失败: %s,consent loop 仍尝试", exc) # 处理多个授权/同意页面(可能有多步) for step in range(10): if auth_code: break # SPEC-2 shared/add-phone-detection §4 (位点 C-P2):consent 循环每轮开头探针。 # 不在循环里拦,会被当作"workspace 没选好"反复重试,30s callback 等待白白耗费。 assert_not_blocked(page, f"oauth_consent_{step}") _screenshot(page, f"codex_04_step{step + 1}_before.png") try: if _is_choose_account_page(page): _screenshot(page, f"codex_04_choose_account_{step + 1}_before.png") logger.info("[Codex] 检测到账号选择页 (step %d),尝试选择: %s", step + 1, email) selected = _select_oauth_account(page, email) _screenshot(page, f"codex_04_choose_account_{step + 1}_after.png") if selected and not _is_choose_account_page(page): continue if not selected: logger.warning("[Codex] 无法自动选择 OAuth 账号: %s (step %d)", email, step + 1) except Exception: pass # 在任何页面中,如果有 workspace/组织选择,先选 Team(personal 模式下选个人) try: # Round 11 — 与 cnitlrt/AutoTeam upstream codex_auth.py:772-815 对齐: # consent loop 每个 step 起始先用 upstream-style 健壮检测,避免页面变 workspace # 选择页时被当成 "consent button 不可见" → break → 30s callback 等待白白消耗。 # Team 模式优先用 upstream helper;personal 模式留给 ensure_personal_workspace_selected # 在 consent loop 之后兜底(L916+)。 if not use_personal: from autoteam.oauth_workspace import ( _is_workspace_selection_page as _ws_is_selection_page, ) from autoteam.oauth_workspace import ( _select_team_workspace as _ws_select_team, ) workspace_name_upstream = get_chatgpt_workspace_name() if _ws_is_selection_page(page): _screenshot(page, f"codex_04_workspace_{step + 1}_before.png") logger.info( "[Codex] 检测到工作空间选择页 (step %d, upstream),尝试选择: %s", step + 1, workspace_name_upstream, ) upstream_selected = _ws_select_team(page, workspace_name_upstream) _screenshot(page, f"codex_04_workspace_{step + 1}_after.png") if upstream_selected: # 选完 workspace 后点"继续"按钮提交 try: cont_btn = page.locator( 'button:has-text("继续"), button:has-text("Continue")' ).first if cont_btn.is_visible(timeout=3000): cont_btn.click() time.sleep(3) logger.info("[Codex] 已点击继续 (step %d, upstream)", step + 1) except Exception: pass continue else: logger.warning( "[Codex] upstream-style 无法选择 workspace '%s' (step %d),回退现有 JS/locator 路径", workspace_name_upstream, step + 1, ) page_text = page.inner_text("body")[:1000] # personal 模式:检测到工作空间选择页时,直接选 Personal if use_personal and ( "选择一个工作空间" in page_text or "Select a workspace" in page_text or "选择工作空间" in page_text ): _screenshot(page, f"codex_04_personal_ws_{step + 1}_before.png") logger.info("[Codex] 检测到工作空间选择页 (step %d, personal 模式)", step + 1) personal_selected = False try: personal_btn = page.locator("text=/个人|Personal/").first if personal_btn.is_visible(timeout=2000): personal_btn.click(force=True) time.sleep(1) personal_selected = True logger.info("[Codex] 已选择 Personal workspace (step %d)", step + 1) except Exception as e: logger.warning("[Codex] 选择 Personal 失败: %s", e) _screenshot(page, f"codex_04_personal_ws_{step + 1}_after.png") if personal_selected: try: cont_btn = page.locator('button:has-text("继续"), button:has-text("Continue")').first if cont_btn.is_visible(timeout=3000): cont_btn.click() time.sleep(3) except Exception: pass continue # 选择 Team workspace(用配置的名称精确匹配)— upstream-style 检测失败后的 JS/locator 兜底 workspace_name = "" if use_personal else get_chatgpt_workspace_name() # 检测"选择一个工作空间"页面,点击 Team workspace if workspace_name and ( "选择一个工作空间" in page_text or "Select a workspace" in page_text or "选择工作空间" in page_text ): selected = False _screenshot(page, f"codex_04_workspace_{step + 1}_before.png") logger.info("[Codex] 检测到工作空间选择页 (step %d),尝试选择: %s", step + 1, workspace_name) # 用 JS 直接点击包含 workspace 名称的元素(最可靠) try: clicked = page.evaluate( """(name) => { const els = document.querySelectorAll('*'); for (const el of els) { const text = (el.textContent || '').trim(); if (text === name && !text.includes('个人') && !text.includes('Personal')) { // 找到最近的可点击父元素 let target = el; while (target && target.tagName !== 'BODY') { const tag = target.tagName.toLowerCase(); if (['button', 'a', 'li', 'label'].includes(tag) || target.getAttribute('role') || target.onclick || target.classList.length > 0) { target.click(); return true; } target = target.parentElement; } el.click(); return true; } } return false; }""", workspace_name, ) if clicked: time.sleep(1) selected = True logger.info("[Codex] 已选择 workspace (JS): %s (step %d)", workspace_name, step + 1) except Exception as e: logger.warning("[Codex] JS 选择 workspace 失败: %s", e) if not selected: # fallback: Playwright 选择器 try: ws_el = page.locator(f"text={workspace_name}").first if ws_el.is_visible(timeout=2000): ws_el.click(force=True) time.sleep(1) selected = True logger.info( "[Codex] 已选择 workspace (force click): %s (step %d)", workspace_name, step + 1 ) except Exception: pass _screenshot(page, f"codex_04_workspace_{step + 1}_after.png") if selected: # 选完 workspace 后点"继续"按钮提交 try: cont_btn = page.locator('button:has-text("继续"), button:has-text("Continue")').first if cont_btn.is_visible(timeout=3000): cont_btn.click() time.sleep(3) logger.info("[Codex] 已点击继续 (step %d)", step + 1) except Exception: pass continue else: logger.warning("[Codex] 无法选择 workspace '%s' (step %d)", workspace_name, step + 1) elif workspace_name: # 非工作空间选择页,但可能有 workspace 文本(如 organization 页) try: ws_btn = page.locator(f'text="{workspace_name}"').first if ws_btn.is_visible(timeout=1000): ws_btn.click() time.sleep(1) logger.info("[Codex] 已选择 workspace: %s (step %d)", workspace_name, step + 1) except Exception: pass # Organization 页面的下拉选择 — 与 upstream codex_auth.py:798-813 对齐 if "organization" in page.url: dropdown = page.locator("[aria-expanded], [aria-haspopup]").first if dropdown.is_visible(timeout=2000): dropdown.click() time.sleep(1) options = page.locator('[role="option"]').all() for opt in options: text = opt.inner_text(timeout=1000).strip() if text and "新组织" not in text and "New" not in text: opt.click() logger.info("[Codex] 选择已有组织: %s", text) break else: if options: options[0].click() time.sleep(1) except Exception: pass # 处理密码页面(可能在 consent 流程中出现) try: pwd_field = page.locator('input[name="password"], input[type="password"]').first if pwd_field.is_visible(timeout=2000): if password: logger.info("[Codex] 需要重新输入密码 (step %d)...", step + 1) pwd_field.fill(password) time.sleep(0.5) _click_primary_auth_button(page, pwd_field, ["Continue", "继续", "Log in"]) else: # 没密码,点"使用一次性验证码登录" otp_btn = page.locator( 'button:has-text("一次性验证码"), button:has-text("one-time"), button:has-text("email login")' ).first if otp_btn.is_visible(timeout=3000): logger.info("[Codex] 无密码,点击一次性验证码登录 (step %d)", step + 1) otp_btn.click() else: _click_primary_auth_button(page, pwd_field, ["Continue", "继续", "Log in"]) time.sleep(5) _screenshot(page, f"codex_04_password_{step + 1}.png") continue except Exception: pass # 处理邮箱验证码页面(可能在 consent 流程中出现) try: if _is_otp_input_visible(page, timeout=2000) and mail_client: submit_status = _resolve_email_verification( page, mail_client=mail_client, email=email, after_email_id=_email_id_before_login, used_email_ids=_used_email_ids, wait_log=f"[Codex] 需要邮箱验证码 (step {step + 1}),等待 emailId > %d 的新邮件...", require_sender=True, ) if submit_status == "accepted": logger.info("[Codex] 验证码页已退出,继续后续授权流程") continue except Exception: pass try: consent_btn = page.locator( 'button:has-text("继续"), button:has-text("Continue"), button:has-text("Allow")' ).first if consent_btn.is_visible(timeout=5000): logger.info("[Codex] 点击同意/继续按钮 (step %d)...", step + 1) consent_btn.click() time.sleep(5) _screenshot(page, f"codex_04_consent_{step + 1}.png") else: break except Exception: break # Round 11 二轮 — pre-consent workspace_select 已前置(line 632+);此处作为兜底: # consent loop 自然结束(auth_code 未抓到,可能 workspace_select 仍未触发后端 default 切换) # 时再调一次 workspace_select。 # Round 8 原始动机:personal OAuth 需要在 callback 之前**主动**选 personal workspace, # 否则 issuer 按 default_workspace_id(sticky 指向 Team)颁 token,拿到 plan_type=team。 # 三层兜底:HTTP /api/accounts/workspace/select(主路径)→ Playwright UI fallback → # 失败则返回 fail_category 由外层 5 次重试承担。 # Team 路径(use_personal=False)完全跳过 — 默认 default_workspace_id 已指向 Team。 if use_personal and not auth_code: try: from autoteam.oauth_workspace import ensure_personal_workspace_selected consent_url_for_select = page.url or "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" ws_ok, ws_fail_category, ws_evidence = ensure_personal_workspace_selected( page, consent_url=consent_url_for_select, skip_ui_fallback_on_empty=True, # Round 11 三轮 — 同 pre-consent ) if ws_ok: logger.info("[Codex] personal workspace 选择成功,继续等 callback") else: logger.warning( "[Codex] personal workspace 选择失败 fail_category=%s evidence=%s", ws_fail_category, json.dumps(ws_evidence, ensure_ascii=False)[:300], ) _screenshot(page, "codex_04b_after_workspace_select.png") except Exception as exc: logger.warning("[Codex] ensure_personal_workspace_selected 异常: %s", exc) # SPEC-2 shared/add-phone-detection §4 (位点 C-P3):等 callback 前探针。 # add-phone 阻塞 = "callback 永远不来"的根因,30s 等待白白浪费。 assert_not_blocked(page, "oauth_callback_wait") # 等待 redirect callback 获取 auth code for _ in range(30): if auth_code: break # 也从当前 URL 尝试提取(CPA 可能接收了回调) try: cur = page.url if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in cur: parsed = urllib.parse.urlparse(cur) qs = urllib.parse.parse_qs(parsed.query) auth_code = qs.get("code", [None])[0] if auth_code: logger.info("[Codex] 从 URL 捕获到 auth code!") break except Exception: pass time.sleep(1) if not auth_code: _screenshot(page, "codex_05_no_callback.png") logger.warning("[Codex] 未获取到 auth code,当前 URL: %s", page.url) # SPEC-2 §3.4.5 (位点 C-P4) + Round 6 PRD-5 FR-P1.1:personal 拒收 bundle 之前的最后一道关卡。 # 防御性 — 通常 C-P1~C-P3 已拦下,但 add-phone 可能在 callback 阶段后晚到一两秒; # 此处在统一 cleanup 之前做最后一次探针,确保 page 仍可读。命中即抛 RegisterBlocked, # 上游 5 个调用方按 add-phone-detection.md §5.2 矩阵分类处置(personal/team/reinvite/api 各异)。 try: assert_not_blocked(page, "oauth_personal_check") except Exception: # assert_not_blocked 命中 add-phone 会抛 RegisterBlocked — 必须传播给上层 # 但要保证 browser 资源被释放 close_playwright_objects(page, context, browser, logger=logger, label="codex-oauth") raise # Round 11 五轮 Option A — 阶段 1 完成,先尝试 exchange + plan 校验 # 校验在 with sync_playwright() 块内做,失败时可以直接 fallback 到阶段 2 # (使用同一 context,清空 cookies 后重新登录)而不需要重新启动 Playwright。 stage1_bundle = None if auth_code: stage1_bundle = _exchange_auth_code(auth_code, code_verifier, fallback_email=email) if stage1_bundle: stage1_plan = (stage1_bundle.get("plan_type") or "").lower() logger.info( "[Codex] 阶段 1 OAuth 完成: plan_type=%s account_id=%s", stage1_plan or "unknown", stage1_bundle.get("account_id"), ) # 阶段 1 直接成功的判断:Team 路径不校验 plan,有 bundle 即可; # personal 路径仅 plan == "free" 算阶段 1 真成功。 stage1_ok = bool(stage1_bundle) and ( (not use_personal) or (stage1_bundle.get("plan_type") or "").lower() == "free" ) # 阶段 2 触发条件 — 仅 personal 模式 + 阶段 1 失败(bundle=None 或 plan != free) # 触发后:context.clear_cookies() + fresh chatgpt.com login + 重新走 OAuth if (not stage1_ok) and use_personal: stage1_plan = ( (stage1_bundle.get("plan_type") or "").lower() if stage1_bundle else "none" ) logger.warning( "[Codex] 阶段 1 拿到 plan=%s(期望 free)bundle=%s,触发阶段 2 fresh re-login fallback", stage1_plan, "yes" if stage1_bundle else "no", ) # === 阶段 2 入口 === # 1) 清空 cookies + fresh chatgpt.com login(用 keyboard.type 绕过灰按钮) relogin_ok = _perform_fresh_relogin_in_context( context, email, password, mail_client, used_email_ids=_used_email_ids, ) if not relogin_ok: logger.warning("[Codex] 阶段 2 fresh re-login 未成功,放弃") close_playwright_objects(page, context, browser, logger=logger, label="codex-oauth") return None # 2) 重新生成 PKCE + state(原 auth_code 已 used,新 OAuth 必须用新 code_verifier) stage2_code_verifier, stage2_code_challenge = _generate_pkce() stage2_state = secrets.token_urlsafe(16) # Round 11 V8 — fresh re-login 已清空 cookies,personal_uuid 之前在 silent step-0 # 拿到的值仍可复用(personal workspace UUID 是稳定标识,不会因 re-login 重置)。 # 若 stage 1 没拿到(prefetched_personal_uuid 也是 None),这里再尝试一次: # V13 primary: NextAuth /api/auth/session accessToken → page.evaluate Bearer # fetch_personal_uuid_via_page(浏览器内,避 cf 拦截) # fallback: 原 in-browser POST /accounts/personal cookie 路径 if use_personal and not personal_uuid: fetch_page = None try: fetch_page = context.new_page() fetch_page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=30000) time.sleep(5) # Round 14 — 全新 OAuth 建号(无 chatgpt_session_token)关键修复: # fresh re-login 只在 auth.openai.com 端登录,chatgpt.com 端 session 尚未建立, # 且首访常有 Cloudflare 挑战。直接 fetch /accounts/personal 会 401/403 → # personal_uuid=None → consent skipped_empty_workspaces 永远拿不到 token。 # 仿照 silent step-0:先过 cf,再强制 NextAuth session refresh + accounts/check, # 让 server 端用 auth.openai.com 会话 SSO 建立 chatgpt.com session 并 populate # personal workspace,随后 getOrCreate 才会 200。 for _cf_i in range(8): try: _html_lower = fetch_page.content()[:2000].lower() except Exception: _html_lower = "" if "verify you are human" not in _html_lower and "challenge" not in (fetch_page.url or "").lower(): break logger.info("[Codex] 阶段 2 等待 chatgpt.com Cloudflare... (%ds)", _cf_i * 5) time.sleep(5) for _warm_path in ("/api/auth/session?update", "/backend-api/accounts/check"): try: fetch_page.evaluate( "async (p) => { try { await fetch(p, {credentials:'include', cache:'no-store'}); } catch(e){} }", _warm_path, ) except Exception: pass time.sleep(1) nextauth_token = fetch_nextauth_backend_access_token(fetch_page) if nextauth_token: fetched_uuid = fetch_personal_uuid_via_page( fetch_page, nextauth_token ) if fetched_uuid: personal_uuid = fetched_uuid logger.info( "[Codex] 阶段 2 NextAuth Bearer 路径(浏览器内)拿到 personal_workspace_id=%s", personal_uuid, ) if not personal_uuid: fetched_uuid = fetch_page.evaluate( """ async () => { try { const resp = await fetch('/backend-api/accounts/personal', { method: 'POST', credentials: 'include', headers: {'Accept': 'application/json'}, }); if (!resp.ok) return null; const j = await resp.json(); return j && j.id ? j.id : null; } catch (e) { return null; } } """ ) if fetched_uuid: personal_uuid = fetched_uuid logger.info( "[Codex] 阶段 2 cookie fallback 拿到 personal_workspace_id=%s", personal_uuid, ) except Exception as exc: logger.warning("[Codex] 阶段 2 fetch personal uuid 失败: %s", exc) finally: if fetch_page is not None: close_playwright_objects(page=fetch_page, logger=logger, label="codex-stage2-fetch") if use_personal and personal_uuid: stage2_auth_url = _build_auth_url_with_allowed_workspace( stage2_code_challenge, stage2_state, personal_uuid ) logger.info( "[Codex] 阶段 2 OAuth /authorize 注入 allowed_workspace_id=%s", personal_uuid, ) else: stage2_auth_url = _build_auth_url(stage2_code_challenge, stage2_state) # 3) 重新走 OAuth — 在同一 context 内开新 page 监听 callback stage2_auth_code = None def _on_request_stage2(request): nonlocal stage2_auth_code url = request.url if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url: parsed = urllib.parse.urlparse(url) qs = urllib.parse.parse_qs(parsed.query) stage2_auth_code = qs.get("code", [None])[0] if stage2_auth_code: logger.info("[Codex] 阶段 2 捕获到 auth code!") def _on_response_stage2(response): nonlocal stage2_auth_code url = response.url if ( f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url and not stage2_auth_code ): parsed = urllib.parse.urlparse(url) qs = urllib.parse.parse_qs(parsed.query) stage2_auth_code = qs.get("code", [None])[0] if stage2_auth_code: logger.info("[Codex] 阶段 2 从 response 捕获到 auth code!") stage2_page = context.new_page() stage2_page.on("request", _on_request_stage2) stage2_page.on("response", _on_response_stage2) try: stage2_page.goto(stage2_auth_url, wait_until="domcontentloaded", timeout=60000) time.sleep(3) _screenshot(stage2_page, "codex_relogin_06_oauth_start.png") # 因为 context 现在持有 fresh Personal-bound session, # /oauth/authorize 通常直接进 consent。 # 简化的 consent loop(沿用现有 page 的 consent 处理逻辑) for s2_step in range(10): if stage2_auth_code: break # add-phone 探针 try: assert_not_blocked(stage2_page, f"oauth_consent_stage2_{s2_step}") except Exception: close_playwright_objects(stage2_page, context, browser, logger=logger, label="codex-oauth-stage2") raise _screenshot(stage2_page, f"codex_relogin_07_consent_step{s2_step + 1}.png") # Round 14 — 账号选择页("Welcome back / Choose an account to continue to Codex")。 # 新建号完成 about-you 后,OAuth /authorize 在此页列出账号,必须点中自己的账号卡片 # 才能继续 consent → 拿 auth code。stage-2 简化 loop 原先只找 Continue/Allow 按钮, # 在此页空点 10 次死循环(codex_relogin_08_no_callback 实证)。复用主流程 helper。 try: if _is_choose_account_page(stage2_page): logger.info("[Codex] 阶段 2 检测到账号选择页 (step %d),选择账号: %s", s2_step + 1, email) if _select_oauth_account(stage2_page, email): time.sleep(3) continue logger.warning("[Codex] 阶段 2 账号选择页未能选中 %s", email) except Exception as exc: logger.warning("[Codex] 阶段 2 账号选择异常: %s", exc) # workspace 选择页(personal 路径) try: page_text = stage2_page.inner_text("body")[:1000] if ( "选择一个工作空间" in page_text or "Select a workspace" in page_text or "选择工作空间" in page_text ): logger.info("[Codex] 阶段 2 检测到 workspace 选择页 (step %d)", s2_step + 1) try: personal_btn = stage2_page.locator("text=/个人|Personal/").first if personal_btn.is_visible(timeout=2000): personal_btn.click(force=True) time.sleep(1) cont_btn = stage2_page.locator( 'button:has-text("继续"), button:has-text("Continue")' ).first if cont_btn.is_visible(timeout=3000): cont_btn.click() time.sleep(3) continue except Exception as exc: logger.warning("[Codex] 阶段 2 选 Personal 失败: %s", exc) except Exception: pass # 同意/继续按钮 try: consent_btn = stage2_page.locator( 'button:has-text("继续"), button:has-text("Continue"), button:has-text("Allow")' ).first if consent_btn.is_visible(timeout=5000): logger.info("[Codex] 阶段 2 点击同意按钮 (step %d)", s2_step + 1) consent_btn.click() time.sleep(5) else: break except Exception: break # 等 callback for _ in range(30): if stage2_auth_code: break try: cur2 = stage2_page.url if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in cur2: parsed = urllib.parse.urlparse(cur2) qs = urllib.parse.parse_qs(parsed.query) stage2_auth_code = qs.get("code", [None])[0] if stage2_auth_code: logger.info("[Codex] 阶段 2 从 URL 捕获到 auth code!") break except Exception: pass time.sleep(1) if not stage2_auth_code: _screenshot(stage2_page, "codex_relogin_08_no_callback.png") logger.warning( "[Codex] 阶段 2 未获取到 auth code,当前 URL: %s", (stage2_page.url or "")[:120], ) finally: close_playwright_objects(page=stage2_page, logger=logger, label="codex-stage2-page") close_playwright_objects(page, context, browser, logger=logger, label="codex-oauth") if not stage2_auth_code: logger.warning("[Codex] 阶段 2 OAuth 失败,放弃") return None stage2_bundle = _exchange_auth_code( stage2_auth_code, stage2_code_verifier, fallback_email=email ) if not stage2_bundle: logger.warning("[Codex] 阶段 2 _exchange_auth_code 失败") return None stage2_plan = (stage2_bundle.get("plan_type") or "").lower() logger.info( "[Codex] 阶段 2 OAuth 完成: plan_type=%s account_id=%s", stage2_plan or "unknown", stage2_bundle.get("account_id"), ) if stage2_plan != "free": logger.warning( "[Codex] 阶段 2 仍拿到 plan_type=%s(期望 free),返回 None 由外层 5 次重试兜底", stage2_plan or "unknown", ) return None if personal_uuid: stage2_bundle["personal_workspace_id"] = personal_uuid if return_result: return _oauth_result_from_bundle(stage2_bundle) return stage2_bundle # 阶段 1 通过(非 personal 或 plan == free) — 走原退出路径 close_playwright_objects(page, context, browser, logger=logger, label="codex-oauth") if not auth_code: logger.error("[Codex] OAuth 登录失败: 未获取到 authorization code") if return_result: return { "ok": False, "bundle": None, "error_type": "auth_code_missing", "error_detail": "未获取到 authorization code", "retryable": True, } return None if not stage1_bundle: if return_result: return { "ok": False, "bundle": None, "error_type": "token_exchange_failed", "error_detail": "Token 交换失败", "retryable": True, } return None # Personal 模式强校验 plan_type:当子号还挂在 Team workspace(OpenAI 后端 kick 同步延迟 / # default workspace 为 Team)时,auth.openai.com 会默认选 Team 颁发 token,拿到 plan_type=team # 的 bundle —— 这个 token 绑在 Team account_id 上,一旦子号离开 Team 就作废(refresh 401), # 本地却标成 PERSONAL,用户看到的是"可用免费号"但 Codex 跑不动。必须在这里拒收。 # # Round 11 hotfix:本函数单次拒收返回 None,由 manager._run_post_register_oauth 外层 5 次重试承担 # (W-I9 spec — workspace/select 主路径成功但 callback 拿到 plan!=free 时,需进入外层重试触发 # 后端最终一致性,而非立即 fail-fast)。日志级别从 ERROR 调整为 WARNING,因为这不是真正的错误 # 而是预期的 retry 触发器。 # # Round 11 五轮:阶段 1 plan != free 已在 with 块内被 stage2 fallback 拦截, # 走到这里说明阶段 1 就是 plan == free(或非 personal 路径,无校验)。 if use_personal: plan = (stage1_bundle.get("plan_type") or "").lower() if plan != "free": logger.warning( "[Codex] personal 模式拿到 plan_type=%s(期望 free),account_id=%s。" "本次拒收(返回 None),由外层 5 次重试触发后端最终一致性同步。", plan or "unknown", stage1_bundle.get("account_id"), ) if return_result: return { "ok": False, "bundle": None, "error_type": "non_free_plan", "error_detail": f"personal OAuth plan={plan or 'unknown'}", "retryable": True, } return None if personal_uuid: stage1_bundle["personal_workspace_id"] = personal_uuid if return_result: return _oauth_result_from_bundle(stage1_bundle) return stage1_bundle def login_codex_via_session(): """使用管理员 session 复用统一流程完成主号 Codex OAuth 登录。 Round 10 重构(2026-04-28):删除 1003-1177 行的遗留 inline 实现,改为 thin wrapper 委托给 SessionCodexAuthFlow(对齐 upstream cnitlrt/AutoTeam:1017-1043)。 遗留实现的 chatgpt.com/auth/login 重试 fallback 实测无效(只刷新 chatgpt.com 域, 不影响 auth.openai.com session),且漏了"落 email-input 页时自动填 admin email"这步, 导致主号 OAuth 必失败。 SessionCodexAuthFlow 内 _advance → _auto_fill_email 会在 email-input 页自动填 admin email 并继续 consent 流程,这是 upstream 已经实证的解法。 """ logger.info("[Codex] 开始使用 session 登录主号 Codex...") flow = SessionCodexAuthFlow( email=get_admin_email(), session_token=get_admin_session_token(), account_id=get_chatgpt_account_id(), workspace_name=get_chatgpt_workspace_name(), password="", password_callback=None, auth_file_callback=lambda _bundle: "", ) try: result = flow.start() step = result.get("step") detail = result.get("detail") logger.info("[Codex] 主号 session OAuth 初始结果: step=%s detail=%s", step, detail) if step != "completed": logger.warning("[Codex] 主号 session OAuth 未直接完成: step=%s detail=%s", step, detail) return None info = flow.complete() return info.get("bundle") finally: flow.stop() class SessionCodexAuthFlow: EMAIL_SELECTORS = [ 'input[name="email"]', 'input[id="email-input"]', 'input[id="email"]', 'input[type="email"]', 'input[placeholder*="email" i]', 'input[placeholder*="邮箱"]', 'input[autocomplete="email"]', 'input[autocomplete="username"]', ] PASSWORD_SELECTORS = [ 'input[name="password"]', 'input[type="password"]', ] CODE_SELECTORS = [ 'input[name="code"]', 'input[placeholder*="验证码"]', 'input[placeholder*="code" i]', 'input[inputmode="numeric"]', 'input[autocomplete="one-time-code"]', ] OTP_OPTION_SELECTORS = [ 'button:has-text("一次性验证码")', 'button:has-text("邮箱验证码")', 'button:has-text("Email login")', 'button:has-text("email login")', 'button:has-text("one-time")', 'button:has-text("One-time")', 'button:has-text("email code")', 'button:has-text("Email code")', 'a:has-text("一次性验证码")', 'a:has-text("邮箱验证码")', 'a:has-text("Email login")', 'a:has-text("one-time")', ] def __init__( self, *, email, session_token, account_id, workspace_name="", password="", password_callback=None, auth_file_callback=None, ): self.email = email or "" self.password = password or "" self.workspace_name = workspace_name or "" self.account_id = account_id or "" self.session_token = session_token or "" self.password_callback = password_callback self.auth_file_callback = auth_file_callback or save_auth_file self.code_verifier, code_challenge = _generate_pkce() self.state = secrets.token_urlsafe(16) self.auth_url = _build_auth_url(code_challenge, self.state) self.auth_code = None self.chatgpt = None self.page = None def _visible_locator(self, selectors, timeout_ms=5000): if not self.page: return None selector = ", ".join(selectors) deadline = time.time() + timeout_ms / 1000 while time.time() < deadline: frames = [self.page.main_frame] frames.extend(frame for frame in self.page.frames if frame != self.page.main_frame) for frame in frames: try: locator = frame.locator(selector).first if locator.is_visible(timeout=250): return locator except Exception: pass time.sleep(0.2) return None def _detect_step(self): if self.auth_code: return "completed", None cur = self.page.url if self.page else "" if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in cur: parsed = urllib.parse.urlparse(cur) qs = urllib.parse.parse_qs(parsed.query) self.auth_code = qs.get("code", [None])[0] if self.auth_code: return "completed", None if self._visible_locator(self.CODE_SELECTORS, timeout_ms=800): return "code_required", None if self._visible_locator(self.PASSWORD_SELECTORS, timeout_ms=800): return "password_required", None if self._visible_locator(self.EMAIL_SELECTORS, timeout_ms=800): return "email_required", None return "unknown", cur def _attach_callback_listeners(self): def on_request(request): if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in request.url: parsed = urllib.parse.urlparse(request.url) qs = urllib.parse.parse_qs(parsed.query) self.auth_code = qs.get("code", [None])[0] def on_response(response): if self.auth_code: return if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in response.url: parsed = urllib.parse.urlparse(response.url) qs = urllib.parse.parse_qs(parsed.query) self.auth_code = qs.get("code", [None])[0] self.page.on("request", on_request) self.page.on("response", on_response) def _inject_auth_cookies(self): cookies = [] if len(self.session_token) > 3800: cookies.extend( [ { "name": "__Secure-next-auth.session-token.0", "value": self.session_token[:3800], "domain": "auth.openai.com", "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", }, { "name": "__Secure-next-auth.session-token.1", "value": self.session_token[3800:], "domain": "auth.openai.com", "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", }, ] ) else: cookies.append( { "name": "__Secure-next-auth.session-token", "value": self.session_token, "domain": "auth.openai.com", "path": "/", "httpOnly": True, "secure": True, "sameSite": "Lax", } ) if self.account_id: cookies.append( { "name": "_account", "value": self.account_id, "domain": "auth.openai.com", "path": "/", "secure": True, "sameSite": "Lax", } ) cookies.append( { "name": "oai-did", "value": self.chatgpt.oai_device_id, "domain": "auth.openai.com", "path": "/", "secure": True, "sameSite": "Lax", } ) self.chatgpt.context.add_cookies(cookies) def _click_workspace_or_consent(self): acted = False try: if "workspace" in self.page.url and self.workspace_name: ws_btn = self.page.locator(f'text="{self.workspace_name}"').first if ws_btn.is_visible(timeout=1000): ws_btn.click() logger.info("[Codex] 主号选择 workspace: %s", self.workspace_name) time.sleep(2) acted = True except Exception: pass try: consent_btn = self.page.locator( 'button:has-text("继续"), button:has-text("Continue"), button:has-text("Allow")' ).first if consent_btn.is_visible(timeout=1000): consent_btn.click() logger.info("[Codex] 主号点击继续/授权") time.sleep(3) acted = True except Exception: pass return acted def _auto_fill_email(self): email_input = self._visible_locator(self.EMAIL_SELECTORS, timeout_ms=1000) if not email_input or not self.email: return False email_input.fill(self.email) time.sleep(0.5) _click_primary_auth_button(self.page, email_input, ["Continue", "继续", "Log in"]) time.sleep(3) return True def _auto_fill_password(self): password_input = self._visible_locator(self.PASSWORD_SELECTORS, timeout_ms=1000) if not password_input or not self.password: return False password_input.fill(self.password) time.sleep(0.5) _click_primary_auth_button(self.page, password_input, ["Continue", "继续", "Log in"]) time.sleep(5) return True def _switch_password_to_otp(self): otp_entry = self._visible_locator(self.OTP_OPTION_SELECTORS, timeout_ms=1500) if not otp_entry: return False try: otp_entry.click() except Exception: try: otp_entry.click(force=True) except Exception: return False logger.info("[Codex] 主号流程检测到密码页,自动切换到一次性验证码登录") time.sleep(3) return True def _advance(self, attempts=12): for _ in range(attempts): step, detail = self._detect_step() if step == "completed": return {"step": "completed", "detail": detail} if step == "code_required": return {"step": "code_required", "detail": detail} if step == "password_required": if self._switch_password_to_otp(): continue if self._auto_fill_password(): continue return { "step": "unsupported_password", "detail": "主号 Codex 当前停留在密码页,且未找到一次性验证码入口", } if step == "email_required": if self._auto_fill_email(): continue return {"step": "email_required", "detail": detail} if self._click_workspace_or_consent(): continue time.sleep(1) final_step, detail = self._detect_step() return {"step": final_step, "detail": detail} def start(self): if not self.session_token: raise RuntimeError("缺少登录 session") if not self.email: raise RuntimeError("缺少登录邮箱") from autoteam.chatgpt_api import ChatGPTTeamAPI self.chatgpt = ChatGPTTeamAPI() try: self.chatgpt.start_with_session( self.session_token, self.account_id, self.workspace_name, require_browser=True, ) self.page = self.chatgpt.context.new_page() self._attach_callback_listeners() self._inject_auth_cookies() self.page.goto(self.auth_url, wait_until="domcontentloaded", timeout=60000) time.sleep(3) return self._advance() except Exception: self.stop() raise def submit_password(self, password): self.password = password if self.password_callback: self.password_callback(password) password_input = self._visible_locator(self.PASSWORD_SELECTORS, timeout_ms=5000) if not password_input: raise RuntimeError("当前 Codex 登录不是密码输入步骤") password_input.fill(password) time.sleep(0.5) _click_primary_auth_button(self.page, password_input, ["Continue", "继续", "Log in"]) time.sleep(5) return self._advance() def submit_code(self, code): code_input = self._visible_locator(self.CODE_SELECTORS, timeout_ms=5000) if not code_input: raise RuntimeError("当前 Codex 登录不是验证码输入步骤") code_input.fill(code) time.sleep(0.5) _click_primary_auth_button(self.page, code_input, ["Continue", "继续", "Verify"]) time.sleep(5) return self._advance() def complete(self): if not self.auth_code: raise RuntimeError("未获取到 Codex authorization code") bundle = _exchange_auth_code(self.auth_code, self.code_verifier, fallback_email=self.email) if not bundle: raise RuntimeError("Codex token 交换失败") filepath = self.auth_file_callback(bundle) return { "email": bundle.get("email"), "auth_file": filepath, "plan_type": bundle.get("plan_type"), "bundle": bundle, } def stop(self): if self.chatgpt: self.chatgpt.stop() self.chatgpt = None self.page = None class MainCodexLoginFlow(SessionCodexAuthFlow): def __init__(self): from autoteam.admin_state import get_admin_password super().__init__( email=get_admin_email(), session_token=get_admin_session_token(), account_id=get_chatgpt_account_id(), workspace_name=get_chatgpt_workspace_name(), password=get_admin_password(), password_callback=None, auth_file_callback=save_main_auth_file, ) def complete(self): info = super().complete() return { "email": info.get("email"), "auth_file": info.get("auth_file"), "plan_type": info.get("plan_type"), } class MainCodexSyncFlow(MainCodexLoginFlow): def complete(self): info = super().complete() from autoteam.sync_targets import sync_main_codex_to_configured_targets as sync_main_codex_to_cpa sync_main_codex_to_cpa(info["auth_file"]) return { "email": info.get("email"), "auth_file": info.get("auth_file"), "plan_type": info.get("plan_type"), } def login_main_codex(): """主号 Codex 登录:使用已保存的管理员 session。""" return login_codex_via_session() def save_auth_file(bundle): """保存 CPA 兼容的认证文件。同一邮箱只保留一个文件,优先 team。""" ensure_auth_dir() email = bundle["email"] plan_type = bundle.get("plan_type", "unknown") account_id = bundle.get("account_id", "") hash_id = hashlib.md5(account_id.encode()).hexdigest()[:8] # 清理同一邮箱的旧文件(避免 free/team 并存) for old in AUTH_DIR.glob(f"codex-{email}-*.json"): old.unlink() logger.info("[Codex] 清理旧文件: %s", old.name) filename = f"codex-{email}-{plan_type}-{hash_id}.json" filepath = AUTH_DIR / filename return _write_auth_file(filepath, bundle) def save_main_auth_file(bundle): """保存主号 Codex 认证文件,不进入账号池。""" account_id = bundle.get("account_id") or hashlib.md5(bundle.get("email", "main").encode()).hexdigest()[:8] for old in AUTH_DIR.glob("codex-main-*.json"): old.unlink() logger.info("[Codex] 清理旧主号文件: %s", old.name) filepath = AUTH_DIR / f"codex-main-{account_id}.json" return _write_auth_file(filepath, bundle) def get_saved_main_auth_file(): """获取本地已保存的主号 Codex 认证文件路径。""" candidates = [] for path in AUTH_DIR.glob("codex-main-*.json"): if not path.is_file(): continue try: stat = path.stat() except Exception: continue candidates.append((stat.st_mtime, path.name, path)) if not candidates: return "" candidates.sort(reverse=True) return str(candidates[0][2].resolve()) def refresh_main_auth_file(): """基于已保存的管理员登录态,刷新并保存主号 Codex 认证文件。""" bundle = login_codex_via_session() if not bundle: raise RuntimeError("无法基于管理员登录态生成主号 Codex 认证文件") auth_file = save_main_auth_file(bundle) return { "email": bundle.get("email"), "auth_file": auth_file, "plan_type": bundle.get("plan_type"), } def quota_result_quota_info(info): """从 check_codex_quota 返回值中提取额度快照。""" if not isinstance(info, dict): return None quota_info = info.get("quota_info") if isinstance(quota_info, dict): return quota_info if "primary_pct" in info or "weekly_pct" in info: return info return None def quota_result_resets_at(info): """从 check_codex_quota 返回值中提取恢复时间。""" if isinstance(info, dict): value = info.get("resets_at") else: value = info try: return int(value or 0) except Exception: return 0 def get_quota_exhausted_info(quota_info, *, limit_reached=False): """根据额度快照判断是否已耗尽,并返回耗尽详情。 SPEC-2 shared/quota-classification §4.2:no_quota 优先级**高于** exhausted。 `primary_total == 0` / `reset_at == 0 + used_pct == 0` 表示 workspace 未分配配额, 返回 window="no_quota" 形态,resets_at 给 24h 占位(不应被用作重试依据)。 """ if not isinstance(quota_info, dict): return None primary_pct = int(quota_info.get("primary_pct", 0) or 0) weekly_pct = int(quota_info.get("weekly_pct", 0) or 0) primary_reset = int(quota_info.get("primary_resets_at", 0) or 0) weekly_reset = int(quota_info.get("weekly_resets_at", 0) or 0) primary_total = quota_info.get("primary_total") primary_remaining = quota_info.get("primary_remaining") # SPEC-2 shared/quota-classification I4 — no_quota 短路必须先于 exhausted 判定 no_quota_signals = [] if primary_total == 0: no_quota_signals.append("primary_total==0") elif primary_total is None and primary_pct == 0 and primary_reset == 0 and not limit_reached: # rate_limit 字段缺失 / primary_window 缺失走这一支(上游 quota_info 取值都是 0) no_quota_signals.append("rate_limit_empty") if primary_remaining == 0 and (primary_total == 0 or primary_total is None) and primary_pct == 0: no_quota_signals.append("remaining==0+total==0") if no_quota_signals and not limit_reached and primary_pct < 100 and weekly_pct < 100: return { "window": "no_quota", "resets_at": int(time.time() + 86400), # 24h 占位 "quota_info": quota_info, "limit_reached": False, "no_quota_signals": no_quota_signals, } # SPEC-2 shared/quota-classification §4.4 I5 (Round 6 PRD-5 FR-P0) — uninitialized_seat 形态。 # OpenAI fresh seat 懒初始化:wham 给了占位 reset_at>0 但 total/remaining=null,且 pct=0。 # 此形态在 wham 层无法与"真无配额"区分,**不**直接判 no_quota,而是返回 window= # "uninitialized_seat" + needs_codex_smoke=True,要求上游调 cheap_codex_smoke 二次验证。 if ( primary_total is None and primary_remaining is None and primary_pct == 0 and weekly_pct == 0 and primary_reset > 0 and not limit_reached ): return { "window": "uninitialized_seat", "resets_at": int(time.time() + 86400), "quota_info": quota_info, "limit_reached": False, "needs_codex_smoke": True, "no_quota_signals": ["workspace_uninitialized"], } primary_exhausted = primary_pct >= 100 weekly_exhausted = weekly_pct >= 100 if not (limit_reached or primary_exhausted or weekly_exhausted): return None reset_candidates = [] if primary_exhausted and primary_reset: reset_candidates.append(primary_reset) if weekly_exhausted and weekly_reset: reset_candidates.append(weekly_reset) if not reset_candidates: if primary_reset: reset_candidates.append(primary_reset) if weekly_reset: reset_candidates.append(weekly_reset) resets_at = max(reset_candidates) if reset_candidates else int(time.time() + 18000) if primary_exhausted and weekly_exhausted: window = "combined" elif weekly_exhausted: window = "weekly" elif primary_exhausted: window = "primary" else: window = "limit" return { "window": window, "resets_at": resets_at, "quota_info": quota_info, "limit_reached": bool(limit_reached), } # Round 7 FR-D6 — manager 24h 去重 cheap_codex_smoke。 # 同一 account_id 在 24h 内重复调用 cheap_codex_smoke 时,直接返回上次落盘的结果, # 不再走网络。R2 风险缓解(fresh seat 命中率 > 5% 时 smoke 调用密度爆炸)。 _CODEX_SMOKE_DEDUP_SECONDS = 86400 def _read_codex_smoke_cache(account_id): """从 accounts.json 读 last_codex_smoke_at + last_smoke_result。 匹配规则:account_id 优先按 workspace_account_id 比对,其次 email 比对。 返回 (epoch_at, result_str) 或 None(无 account_id / 无记录 / 字段缺失)。 """ if not account_id: return None try: from autoteam.accounts import load_accounts accounts = load_accounts() except Exception: return None target = str(account_id) for acc in accounts: if acc.get("workspace_account_id") == target or acc.get("email") == target: ts = acc.get("last_codex_smoke_at") res = acc.get("last_smoke_result") if ts and res: try: return (float(ts), str(res)) except (TypeError, ValueError): return None return None def _write_codex_smoke_cache(account_id, result): """落盘 last_codex_smoke_at + last_smoke_result,用于 24h 去重。 匹配规则同 _read_codex_smoke_cache。result 取值 alive/auth_invalid/uncertain。 任何异常静默吞,cache 失败不阻塞主流程。 """ if not account_id or not result: return try: from autoteam.accounts import load_accounts, update_account except Exception: return target = str(account_id) try: accounts = load_accounts() except Exception: return for acc in accounts: if acc.get("workspace_account_id") == target or acc.get("email") == target: try: update_account( acc["email"], last_codex_smoke_at=time.time(), last_smoke_result=str(result), ) except Exception as exc: logger.debug("[Codex smoke] cache 落盘失败(忽略): %s", exc) return def cheap_codex_smoke( access_token, account_id=None, *, model="gpt-5.5", fallback_models=None, instructions=None, max_output_tokens=64, timeout=15.0, force=False, ): """SPEC-2 shared/quota-classification §4.4 — uninitialized_seat 二次验证。 Round 11 升级(v2 — codex backend payload schema 改版): - 默认 model 升 gpt-5.5(team-only,主路径) - fallback_models 默认 ["gpt-5.4"](通用模型,team + free 都能用),主 model 撞 model_not_supported 时自动 fallback - payload 严格按最新 codex backend schema: * instructions 必填(默认 "You are a concise assistant. Reply with one short word.") * input 改为 list 格式 [{type:message, role:user, content:[{type:input_text, text:"ping"}]}] * 新增 store: false(必填) * 删除 max_output_tokens(后端不再支持,函数签名保留供未来扩展) PRD-Round11 Q4:gpt-5.5 是 team-only 模型(只 team 号能用),gpt-5.4 是通用模型 (free + team 都能用)。所以 team 号默认走 gpt-5.5,model_not_supported 时 fallback 到 gpt-5.4(free 号在主 model 立即 fallback)。 Round 7 FR-D6:24h 去重 cache。account_id 在 24h 内已有 cache 时直接返回,不走网络; 传 force=True 可绕过 cache(用于强制刷新场景)。 返回 (result, detail): ("alive", {model, response_text, raw_event, tokens?}) — HTTP 200 + response.completed → 真活号 detail.model 反映最终成功用的 model(可能是 fallback) ("alive", None) (cache hit only) — 24h cache 命中 ("auth_invalid", reason_str) — HTTP 401/403/429 / 4xx 含 quota 关键词 → token/seat 真失效 ("uncertain", reason_str) — HTTP 5xx / network / timeout / 解析异常 / 主+fallback 全 model_not_supported cache 命中时 detail 为 "cache_hit_<原 result>" 向后兼容:max_output_tokens 参数保留但**不再传给后端**(spike 实测后端拒收); instructions=None 时用模块级默认。 """ if not access_token: return "auth_invalid", "empty_access_token" if not account_id: try: account_id = get_chatgpt_account_id() except Exception: account_id = None # Round 7 FR-D6 — 24h 去重 cache 命中直接返回(force=True 时绕过) if not force and account_id: cached = _read_codex_smoke_cache(account_id) if cached: cached_at, cached_result = cached if (time.time() - cached_at) < _CODEX_SMOKE_DEDUP_SECONDS: logger.debug( "[Codex smoke] 24h cache 命中 account_id=%s result=%s age=%.0fs", account_id, cached_result, time.time() - cached_at, ) return cached_result, f"cache_hit_{cached_result}" # 构造 model 尝试链:主 model + fallback chain(去重保序) if fallback_models is None: fallback_models = ["gpt-5.4"] elif not isinstance(fallback_models, (list, tuple)): fallback_models = [fallback_models] model_chain: list[str] = [] seen: set[str] = set() for m in [model] + list(fallback_models): if not m: continue if m in seen: continue seen.add(m) model_chain.append(m) last_result: tuple[str, object] = ("uncertain", "no_models_attempted") for idx, candidate in enumerate(model_chain): result, detail = _cheap_codex_smoke_network( access_token, account_id, model=candidate, instructions=instructions, max_output_tokens=max_output_tokens, timeout=timeout, ) # alive / auth_invalid → 直接返回(终态) if result == "alive" or result == "auth_invalid": _write_codex_smoke_cache(account_id, result) return result, detail # uncertain 路径下:仅当 detail 是 model_not_supported 才继续尝试 fallback last_result = (result, detail) is_model_not_supported = ( isinstance(detail, str) and detail.startswith("model_not_supported") ) if not is_model_not_supported: break # 非 model 问题(网络 / 5xx / 解析)— 不重试 # else:模型不被支持,继续下一个 fallback if idx + 1 < len(model_chain): logger.info( "[Codex smoke] model=%s 不被支持,fallback → %s", candidate, model_chain[idx + 1], ) result, detail = last_result _write_codex_smoke_cache(account_id, result) return result, detail def _cheap_codex_smoke_network( access_token, account_id, *, model="gpt-5.5", instructions=None, max_output_tokens=64, # 保留参数供未来扩展;后端拒收此字段,不再写入 payload timeout=15.0, ): """实际走网络的 cheap_codex_smoke 内部函数(Round 7 FR-D6 拆出 + Round 11 v2 schema 升级)。 与 cheap_codex_smoke 不同点:不查也不写 cache,直接调 codex backend。 Round 11:读完整 SSE 帧累积 output_text,见 response.completed 时返回 dict 含 response_text。 Round 11 v2:payload schema 严格按 codex backend 最新版(spike 实测验证): - instructions 必填(默认 _CODEX_SMOKE_DEFAULT_INSTRUCTIONS) - input 是 list 格式 [{type:message, role:user, content:[{type:input_text, text}]}] - 必含 store: false - **不再**写 max_output_tokens(后端拒收"Unsupported parameter") 返回值语义: alive 路径 detail = dict {"model", "response_text", "raw_event", "tokens"} auth_invalid 路径 detail = str(http_[_quota_hint]) uncertain 路径 detail = str - "model_not_supported_":4xx 且 body 含 not supported 关键字 → 上层触发 fallback - "http_" / "no_response_created_frame" / "stream:..." / "network:..." 等 """ import requests headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "Accept": "text/event-stream", } if account_id: headers["Chatgpt-Account-Id"] = account_id payload = { "model": model, "instructions": instructions if instructions else _CODEX_SMOKE_DEFAULT_INSTRUCTIONS, "input": [ { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "ping"}], } ], "stream": True, "store": False, "reasoning": {"effort": "none"}, } try: resp = requests.post( _CODEX_SMOKE_ENDPOINT, headers=headers, json=payload, stream=True, timeout=timeout, ) except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.SSLError, ) as exc: logger.warning("[Codex smoke] 网络异常(uncertain): %s", exc) return "uncertain", f"network:{type(exc).__name__}" except Exception as exc: logger.warning("[Codex smoke] 未知异常(uncertain): %s", exc) return "uncertain", f"exception:{type(exc).__name__}" try: status_code = resp.status_code if status_code in (401, 403, 429): try: resp.close() except Exception: pass return "auth_invalid", f"http_{status_code}" if 500 <= status_code < 600: try: resp.close() except Exception: pass return "uncertain", f"http_{status_code}" if status_code != 200: # 4xx 非 401/403/429: # 1) body 含 model_not_supported 关键词 → uncertain + sentinel,触发上层 fallback # 2) body 含 quota 关键词 → auth_invalid # 3) 其他 → uncertain + http_ try: body_preview = (resp.text or "").lower()[:1500] except Exception: body_preview = "" try: resp.close() except Exception: pass if any(hint in body_preview for hint in _CODEX_SMOKE_MODEL_NOT_SUPPORTED_HINTS): return "uncertain", f"model_not_supported_{status_code}" if any(hint in body_preview for hint in _CODEX_SMOKE_QUOTA_HINTS): return "auth_invalid", f"http_{status_code}_quota_hint" return "uncertain", f"http_{status_code}" # HTTP 200 — 读完整 SSE 帧累积 output_text(Round 11) response_text_parts = [] seen_created = False completed_event = False output_tokens = None frames_read = 0 try: for line in resp.iter_lines(decode_unicode=True): if not line: continue frames_read += 1 # SSE 行通常为 "data: {...}" 或 "event: response.created" 等 # 先剥离 "data: " 前缀,失败按原文匹配 payload_text = line[6:].strip() if line.startswith("data: ") else line.strip() if "response.created" in line: seen_created = True if "response.completed" in line: # 见 completed 帧结束读流 completed_event = True # 尝试解析 token 数(usage.output_tokens) try: ev_obj = json.loads(payload_text) usage = (ev_obj.get("response") or {}).get("usage") or {} output_tokens = usage.get("output_tokens") except Exception: pass break # 累积 output_text.delta 帧文本 if "response.output_text.delta" in line or "output_text.delta" in line: try: ev_obj = json.loads(payload_text) delta_str = ev_obj.get("delta") if isinstance(delta_str, str) and delta_str: response_text_parts.append(delta_str) except Exception: pass # 安全兜底:30 帧仍未见 completed,跳出当作 alive(已 reach model) if frames_read >= 30: break except Exception as exc: logger.debug("[Codex smoke] iter_lines 异常(uncertain): %s", exc) return "uncertain", f"stream:{type(exc).__name__}" finally: try: resp.close() except Exception: pass # 8 行内仍没拿到 response.created 视为 uncertain if not seen_created and frames_read < 8: return "uncertain", "no_response_created_frame" if not seen_created: return "uncertain", "no_response_created_frame" response_text = "".join(response_text_parts) raw_event = "response.completed" if completed_event else "no_completed_within_30_frames" detail = { "model": model, "response_text": response_text, "raw_event": raw_event, } if output_tokens is not None: detail["tokens"] = output_tokens return "alive", detail finally: try: resp.close() except Exception: pass def check_codex_quota(access_token, account_id=None): """ 通过 /backend-api/wham/usage 查询 Codex 额度状态,不消耗额度。 返回: ("ok", quota_info) — HTTP 200 + 成功解析,额度未触发上限 ("exhausted", info) — HTTP 200 + quota 用尽(get_quota_exhausted_info 命中) ("auth_error", None) — **仅** HTTP 401/403,token/seat 真失效 ("network_error", None) — DNS/timeout/SSL/连接异常 / 5xx / 429 / json 解析失败 / 其他临时错误 auth_error 与 network_error 必须严格区分:auth_error 会触发"标记 AUTH_INVALID/重登"等 破坏性流程,网络抖动绝不能落入该分支(否则一次网络故障可能批量误删账号)。 quota_info = {"primary_pct": int, "primary_resets_at": int, "weekly_pct": int, "weekly_resets_at": int} """ import requests if not account_id: account_id = get_chatgpt_account_id() headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } if account_id: headers["Chatgpt-Account-Id"] = account_id try: resp = requests.get( "https://chatgpt.com/backend-api/wham/usage", headers=headers, timeout=30, ) except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.SSLError, ) as e: logger.warning("[Codex] 网络异常(归类 network_error): %s", e) return "network_error", None except requests.exceptions.RequestException as e: # 其他 requests 异常(ChunkedEncodingError 等)同样归为 network_error,而不是 auth_error logger.warning("[Codex] requests 异常(归类 network_error): %s", e) return "network_error", None except Exception as e: # 兜底:未知异常宁可归 network_error,避免因为一次网络抖动批量误标 AUTH_INVALID logger.warning("[Codex] 未知异常(归类 network_error,保守处理): %s", e) return "network_error", None if resp.status_code in (401, 403): return "auth_error", None # 429 限流 / 5xx 服务端错误 → 临时性故障,归为 network_error,不动账号 status if resp.status_code == 429 or 500 <= resp.status_code < 600: logger.warning("[Codex] wham/usage 临时错误 %d(归类 network_error): %s", resp.status_code, resp.text[:200]) return "network_error", None if resp.status_code != 200: # 4xx(非 401/403/429) 也归为 network_error:可能是 OpenAI 接口在调整, # 不能因为一次接口变更把全部账号误判 token 失效 logger.warning("[Codex] wham/usage 非预期状态 %d(归类 network_error): %s", resp.status_code, resp.text[:200]) return "network_error", None try: data = resp.json() except Exception as e: logger.warning("[Codex] wham/usage 响应 JSON 解析失败(归类 network_error): %s", e) return "network_error", None rate_limit = data.get("rate_limit") or {} primary = rate_limit.get("primary_window") or {} secondary = rate_limit.get("secondary_window") or {} # SPEC-2 shared/quota-classification §2.2 — 扩 primary_total / primary_remaining, # 让 get_quota_exhausted_info 能区分 no_quota(workspace 未分配)与 exhausted(已用完) primary_total_raw = primary.get("limit", primary.get("total")) primary_remaining_raw = primary.get("remaining") quota_info = { "primary_pct": primary.get("used_percent", 0), "primary_resets_at": primary.get("reset_at", 0), "primary_total": primary_total_raw if isinstance(primary_total_raw, (int, float)) else None, "primary_remaining": primary_remaining_raw if isinstance(primary_remaining_raw, (int, float)) else None, "weekly_pct": secondary.get("used_percent", 0), "weekly_resets_at": secondary.get("reset_at", 0), # Round 7 P2.5:把 wham/usage 的原始 rate_limit + primary_window 子树注入 quota_info, # 让 manager 在 record_failure(no_quota_assigned) 时能附 raw_rate_limit 用于事后排查。 "raw_rate_limit": rate_limit, "primary_window": primary, } # SPEC-2 shared/quota-classification §4.2 — no_quota 单独分支:rate_limit 字段 # 完全缺失 / primary_window 缺失也归 no_quota(空载也是无配额信号) rate_limit_missing = (not rate_limit) or (not primary) if rate_limit_missing: return "no_quota", { "window": "no_quota", "resets_at": int(time.time() + 86400), "quota_info": quota_info, "limit_reached": False, "no_quota_signals": ["rate_limit_or_primary_missing"], "raw_rate_limit": rate_limit, } exhausted_info = get_quota_exhausted_info(quota_info, limit_reached=bool(rate_limit.get("limit_reached"))) if exhausted_info: # window="no_quota" 是 get_quota_exhausted_info 内部短路出的形态;独立分类返回 if exhausted_info.get("window") == "no_quota": return "no_quota", exhausted_info # window="uninitialized_seat"(I5)— Round 6 PRD-5 FR-P0:必须用 cheap_codex_smoke 二次验证 if exhausted_info.get("window") == "uninitialized_seat": smoke_result, smoke_detail = cheap_codex_smoke(access_token, account_id=account_id) exhausted_info["last_smoke_result"] = smoke_result if smoke_detail is not None: exhausted_info["last_smoke_detail"] = smoke_detail if smoke_result == "alive": # fresh seat 真活,token 有效 → 维持 ok,但带 smoke_verified 标记 quota_info_verified = dict(quota_info) quota_info_verified["smoke_verified"] = True quota_info_verified["last_smoke_result"] = "alive" return "ok", quota_info_verified if smoke_result == "auth_invalid": # codex backend 401/403/quota → 当作真 auth_error,触发 reconcile 重登 return "auth_error", None # uncertain (5xx/network/timeout) → 保持原状态等下轮,归 network_error return "network_error", None return "exhausted", exhausted_info return "ok", quota_info def refresh_access_token(refresh_token): """刷新 access token""" import requests resp = requests.post( CODEX_TOKEN_URL, data={ "grant_type": "refresh_token", "client_id": CODEX_CLIENT_ID, "refresh_token": refresh_token, "scope": "openid profile email", }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if resp.status_code != 200: logger.error("[Codex] Token 刷新失败: %d", resp.status_code) return None data = resp.json() return { "access_token": data.get("access_token"), "refresh_token": data.get("refresh_token", refresh_token), "id_token": data.get("id_token", ""), "expires_in": data.get("expires_in", 3600), }