AutoTeam-F / src /autoteam /codex_auth.py
Looknicemm's picture
fix(oauth): handle account-chooser page in stage-2 consent loop (click own account to continue to Codex) - final piece for fresh oauth account token
829ffd2 verified
Raw
History Blame Contribute Delete
192 kB
"""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: "<personal_uuid>", 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=<personal_uuid>` 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_<code>[_quota_hint])
uncertain 路径 detail = str
- "model_not_supported_<code>":4xx 且 body 含 not supported 关键字 → 上层触发 fallback
- "http_<code>" / "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_<code>
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),
}