From c4759d5f8573a1b01551bd56ccb8c761b2e8e694 Mon Sep 17 00:00:00 2001 From: cnitlrt Date: Sun, 17 May 2026 08:42:07 +0800 Subject: [PATCH] fix: clean up failed team joins on OAuth errors --- src/autoteam/manager.py | 55 +++++++-- tests/unit/test_manager_auth_repair.py | 39 ++++++ tests/unit/test_manager_reinvite.py | 23 ++++ tests/unit/test_signup_flow_profiles.py | 150 +++++++++++++++++++++++- 4 files changed, 259 insertions(+), 8 deletions(-) diff --git a/src/autoteam/manager.py b/src/autoteam/manager.py index 9335f7ec..92ba05bc 100644 --- a/src/autoteam/manager.py +++ b/src/autoteam/manager.py @@ -386,13 +386,14 @@ def _record_auth_repair_failure( error_detail: str | None = None, *, chatgpt_api=None, + release_team_seat: bool = False, ) -> dict: now = time.time() acc = find_account(load_accounts(), email) or {"email": email} error_type = error_type or "login_failed" error_detail = error_detail or _auth_repair_error_label(error_type) retry_delays = _auth_repair_retry_delays() - release_team_seat = False + should_release_team_seat = bool(release_team_seat) if error_type == "add_phone" and _auth_repair_retry_add_phone_enabled(): prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0 @@ -409,7 +410,7 @@ def _record_auth_repair_failure( "auth_retry_after": None, "auth_retry_paused": True, } - release_team_seat = True + should_release_team_seat = True else: state = { "auth_retry_count": next_count, @@ -452,7 +453,7 @@ def _record_auth_repair_failure( release_attempted = False remove_status = None seat_released = False - if release_team_seat and is_team_member: + if should_release_team_seat and is_team_member: release_attempted = True remove_status = _release_auth_repair_team_seat(email, chatgpt_api=chatgpt_api) seat_released = remove_status in ("removed", "already_absent") @@ -1349,7 +1350,12 @@ def _complete_registration(email, password, invite_link, mail_client): logger.info("[注册] 账号就绪: %s", email) return email else: - result = _record_auth_repair_failure(email, login_result.get("error_type"), login_result.get("error_detail")) + result = _record_auth_repair_failure( + email, + login_result.get("error_type"), + login_result.get("error_detail"), + release_team_seat=True, + ) extra = _auth_repair_result_suffix(result) logger.warning( "[注册] 账号已加入 Team 但 Codex 登录失败,标记为 %s: %s(%s%s)", @@ -1358,7 +1364,7 @@ def _complete_registration(email, password, invite_link, mail_client): _auth_repair_error_label(result.get("auth_last_error")), extra, ) - return email + return None def _check_pending_invites(chatgpt_api, mail_client): @@ -1671,6 +1677,8 @@ def _detect_direct_register_step(page): url = (page.url or "").lower() if _is_google_redirect(page): return "google" + if "/api/auth/error" in url or url.endswith("/auth/error"): + return "error" if "email-verification" in url: return "code" @@ -1716,6 +1724,8 @@ def _wait_for_direct_register_step(page, allowed_steps, timeout=15): deadline = time.time() + timeout while time.time() < deadline: step = _detect_direct_register_step(page) + if step == "error": + return step if step in allowed_steps: return step time.sleep(0.5) @@ -1981,6 +1991,14 @@ def _register_direct_once( logger.warning("[直接注册] 邮箱步骤仍停留在 Google 登录页") browser.close() return False + if current_step == "error": + logger.warning("[直接注册] 邮箱步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False + if current_step == "unknown": + logger.warning("[直接注册] 邮箱步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False if current_step == "email": logger.warning("[直接注册] 邮箱步骤未推进 | URL: %s | body=%s", page.url, _page_excerpt(page)) browser.close() @@ -1994,6 +2012,14 @@ def _register_direct_once( ) logger.info("[直接注册] 密码页检测状态: %s | URL: %s", password_step, page.url) _safe_invite_screenshot(page, "direct_03b_before_password.png") + if password_step == "error": + logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False + if password_step == "unknown": + logger.warning("[直接注册] 无法识别密码/验证码步骤 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False try: for attempt in range(2): @@ -2042,6 +2068,14 @@ def _register_direct_once( logger.warning("[直接注册] 密码步骤仍停留在 Google 登录页") browser.close() return False + if current_step == "error": + logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False + if current_step == "unknown": + logger.warning("[直接注册] 密码步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page)) + browser.close() + return False if current_step == "email": logger.warning("[直接注册] 提交密码前流程回退到邮箱页 | URL: %s | body=%s", page.url, _page_excerpt(page)) browser.close() @@ -2179,7 +2213,12 @@ def create_account_direct(mail_client): logger.info("[直接注册] 账号就绪: %s", email) return email else: - result = _record_auth_repair_failure(email, login_result.get("error_type"), login_result.get("error_detail")) + result = _record_auth_repair_failure( + email, + login_result.get("error_type"), + login_result.get("error_detail"), + release_team_seat=True, + ) extra = _auth_repair_result_suffix(result) logger.warning( "[直接注册] 账号已加入 Team 但 Codex 登录失败,标记为 %s: %s(%s%s)", @@ -2188,7 +2227,7 @@ def create_account_direct(mail_client): _auth_repair_error_label(result.get("auth_last_error")), extra, ) - return email + return None def create_new_account(chatgpt_api, mail_client): @@ -2233,6 +2272,7 @@ def reinvite_account(chatgpt_api, mail_client, acc): login_result.get("error_type"), login_result.get("error_detail"), chatgpt_api=chatgpt_api, + release_team_seat=True, ) extra = _auth_repair_result_suffix(result) logger.warning( @@ -2252,6 +2292,7 @@ def reinvite_account(chatgpt_api, mail_client, acc): "non_team_plan", f"登录后 plan={plan_type or 'unknown'}", chatgpt_api=chatgpt_api, + release_team_seat=True, ) logger.warning("[轮转] 旧账号保持状态为 %s: %s", result.get("status"), email) return False diff --git a/tests/unit/test_manager_auth_repair.py b/tests/unit/test_manager_auth_repair.py index f3b9d519..9081230a 100644 --- a/tests/unit/test_manager_auth_repair.py +++ b/tests/unit/test_manager_auth_repair.py @@ -164,6 +164,45 @@ def test_record_auth_repair_failure_releases_team_seat_after_add_phone_retries_e ] +def test_record_auth_repair_failure_can_force_release_team_seat_for_rejoin_failures(monkeypatch): + updates = [] + monkeypatch.setattr( + manager, + "load_accounts", + lambda: [{"email": "user@example.com", "status": "standby", "auth_retry_count": 0}], + ) + monkeypatch.setattr(manager, "update_account", lambda email, **kwargs: updates.append((email, kwargs))) + monkeypatch.setattr(manager.time, "time", lambda: 1_700_000_000) + monkeypatch.setattr(manager, "_auth_repair_retry_delays", lambda: (600, 1200, 1800)) + monkeypatch.setattr(manager, "_is_email_in_team", lambda _email: True) + monkeypatch.setattr(manager, "_release_auth_repair_team_seat", lambda *_args, **_kwargs: "removed") + + state = manager._record_auth_repair_failure( + "user@example.com", + "auth_code_missing", + "未获取到 auth code", + release_team_seat=True, + ) + + assert state["auth_retry_count"] == 1 + assert state["status"] == "standby" + assert state["seat_released"] is True + assert updates == [ + ( + "user@example.com", + { + "auth_retry_count": 1, + "auth_last_error": "auth_code_missing", + "auth_last_error_detail": "未获取到 auth code", + "auth_last_failed_at": 1_700_000_000, + "auth_retry_after": 1_700_000_600, + "auth_retry_paused": False, + }, + ), + ("user@example.com", {"status": "standby"}), + ] + + def test_login_codex_with_result_retries_retryable_failures_within_same_round(monkeypatch): attempts = {"count": 0} diff --git a/tests/unit/test_manager_reinvite.py b/tests/unit/test_manager_reinvite.py index 61862308..b127ec13 100644 --- a/tests/unit/test_manager_reinvite.py +++ b/tests/unit/test_manager_reinvite.py @@ -93,3 +93,26 @@ def test_reinvite_account_marks_auth_pending_when_oauth_login_fails_but_team_sea ) assert result is False + + +def test_reinvite_account_requests_team_seat_release_when_oauth_fails_after_rejoin(monkeypatch): + captured = {} + + monkeypatch.setattr(manager, "login_codex_via_browser", lambda *args, **kwargs: None) + + def fake_record(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return {"status": accounts.STATUS_STANDBY, "auth_last_error": "auth_code_missing", "seat_released": True} + + monkeypatch.setattr(manager, "_record_auth_repair_failure", fake_record) + + result = manager.reinvite_account( + types.SimpleNamespace(browser=False), + None, + {"email": "tmp-user@example.com", "password": ""}, + ) + + assert result is False + assert captured["args"][:3] == ("tmp-user@example.com", "login_failed", "登录失败") + assert captured["kwargs"]["release_team_seat"] is True diff --git a/tests/unit/test_signup_flow_profiles.py b/tests/unit/test_signup_flow_profiles.py index e78fe2fd..1c61a32b 100644 --- a/tests/unit/test_signup_flow_profiles.py +++ b/tests/unit/test_signup_flow_profiles.py @@ -47,8 +47,9 @@ def click(self, timeout=0, force=False): class _FakeLocatorGroup: - def __init__(self, items=None): + def __init__(self, items=None, text=None): self._items = list(items or []) + self._text = text @property def first(self): @@ -65,6 +66,11 @@ def nth(self, index): def click(self, timeout=0, force=False): return self.first.click(timeout=timeout, force=force) + def inner_text(self, timeout=0): + if self._text is None: + raise AssertionError("unexpected inner_text call") + return self._text + class _FakeKeyboard: def __init__(self, page): @@ -156,6 +162,38 @@ def __exit__(self, exc_type, exc, tb): return False +class _DirectFlowPage: + def __init__(self): + self.url = "https://chatgpt.com/auth/login" + self.active_element = None + self.keyboard = _FakeKeyboard(self) + self.email_input = _FakeElement(self, visible=True, editable=True) + self.submit_button = _FakeElement(self, visible=True, editable=False) + self._body = "Something went wrong" + + def goto(self, url, wait_until=None, timeout=None): + self.url = url + + def content(self): + return "" + + def locator(self, selector): + if selector == "body": + return _FakeLocatorGroup(text=self._body) + if selector == manager._DIRECT_EMAIL_SELECTORS: + return _FakeLocatorGroup([self.email_input]) + if selector in { + manager._DIRECT_PASSWORD_SELECTORS, + manager._DIRECT_CODE_SELECTORS, + 'input[name="name"], [role="spinbutton"]', + '[role="spinbutton"]', + }: + return _FakeLocatorGroup([]) + if "button" in selector: + return _FakeLocatorGroup([self.submit_button]) + return _FakeLocatorGroup([]) + + def test_fill_about_you_birthday_by_meta_uses_profile_values(monkeypatch): monkeypatch.setattr(manager.time, "sleep", lambda *_args, **_kwargs: None) profile = SignupProfile("Ethan Carter", 1988, 7, 14, 37) @@ -187,6 +225,36 @@ def test_complete_direct_about_you_age_branch_uses_profile_values(monkeypatch): assert page.submit_button.clicked is True +def test_detect_direct_register_step_recognizes_auth_error_page(): + page = _DirectFlowPage() + page.url = "https://chatgpt.com/api/auth/error" + + assert manager._detect_direct_register_step(page) == "error" + + +def test_register_direct_once_fails_fast_when_email_step_hits_auth_error(monkeypatch): + page = _DirectFlowPage() + + monkeypatch.setattr(manager.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(manager, "_safe_invite_screenshot", lambda *args, **kwargs: None) + monkeypatch.setattr(manager, "_page_excerpt", lambda *_args, **_kwargs: "Something went wrong") + monkeypatch.setattr( + manager, + "_click_primary_auth_button", + lambda page, field, labels: setattr(page, "url", "https://chatgpt.com/api/auth/error") or True, + ) + monkeypatch.setattr(playwright_sync_api, "sync_playwright", lambda: _FakePlaywright(page)) + + result = manager._register_direct_once( + object(), + "user@example.com", + "pw", + signup_profile=SignupProfile("Liam Parker", 1991, 9, 8, 34), + ) + + assert result is False + + def test_create_account_direct_reuses_one_profile_across_retries_and_oauth(monkeypatch): profile = SignupProfile("Liam Parker", 1991, 9, 8, 34) register_calls = [] @@ -227,6 +295,50 @@ def fake_login(email, password, *, mail_client=None, max_attempts=3, signup_prof assert login_calls == [profile] +def test_create_account_direct_releases_team_seat_and_returns_none_when_oauth_fails(monkeypatch): + recorded = {} + + class _FakeMailClient: + provider_name = "cloudmail" + + def create_temp_email(self): + return 123, "user@example.com" + + def delete_account(self, account_id): + raise AssertionError(f"unexpected delete_account({account_id})") + + monkeypatch.setattr(manager.time, "sleep", lambda *_args, **_kwargs: None) + monkeypatch.setattr(manager, "generate_signup_profile", lambda: SignupProfile("Liam Parker", 1991, 9, 8, 34)) + monkeypatch.setattr(manager, "_register_direct_once", lambda *args, **kwargs: True) + monkeypatch.setattr( + manager, "add_account", lambda *args, **kwargs: recorded.setdefault("added", []).append(args[0]) + ) + monkeypatch.setattr( + manager, + "_login_codex_with_result", + lambda *args, **kwargs: { + "ok": False, + "bundle": None, + "error_type": "auth_code_missing", + "error_detail": "未获取到 auth code", + }, + ) + + def fake_record(*args, **kwargs): + recorded["record_args"] = args + recorded["record_kwargs"] = kwargs + return {"status": "standby", "auth_last_error": "auth_code_missing", "seat_released": True} + + monkeypatch.setattr(manager, "_record_auth_repair_failure", fake_record) + + result = manager.create_account_direct(_FakeMailClient()) + + assert result is None + assert recorded["added"] == ["user@example.com"] + assert recorded["record_args"][:3] == ("user@example.com", "auth_code_missing", "未获取到 auth code") + assert recorded["record_kwargs"]["release_team_seat"] is True + + def test_complete_registration_reuses_one_profile_for_invite_and_oauth(monkeypatch): profile = SignupProfile("Owen Reed", 1989, 2, 10, 37) fake_page = _FakePage(url="https://chatgpt.com") @@ -261,6 +373,42 @@ def test_complete_registration_reuses_one_profile_for_invite_and_oauth(monkeypat assert captured["oauth_profile"] is profile +def test_complete_registration_releases_team_seat_and_returns_none_when_oauth_fails(monkeypatch): + fake_page = _FakePage(url="https://chatgpt.com") + recorded = {} + + monkeypatch.setattr(manager, "generate_signup_profile", lambda: SignupProfile("Owen Reed", 1989, 2, 10, 37)) + monkeypatch.setattr( + invite, + "register_with_invite", + lambda page, invite_link, email, mail_client, password=None, signup_profile=None: (True, password), + ) + monkeypatch.setattr( + manager, + "_login_codex_with_result", + lambda *args, **kwargs: { + "ok": False, + "bundle": None, + "error_type": "choose_account_selection", + "error_detail": "卡在账号选择页", + }, + ) + + def fake_record(*args, **kwargs): + recorded["record_args"] = args + recorded["record_kwargs"] = kwargs + return {"status": "standby", "auth_last_error": "choose_account_selection", "seat_released": True} + + monkeypatch.setattr(manager, "_record_auth_repair_failure", fake_record) + monkeypatch.setattr(playwright_sync_api, "sync_playwright", lambda: _FakePlaywright(fake_page)) + + result = manager._complete_registration("user@example.com", "pw", "https://invite", object()) + + assert result is None + assert recorded["record_args"][:3] == ("user@example.com", "choose_account_selection", "卡在账号选择页") + assert recorded["record_kwargs"]["release_team_seat"] is True + + def test_complete_invite_about_you_uses_profile_values(monkeypatch): monkeypatch.setattr(invite.time, "sleep", lambda *_args, **_kwargs: None) monkeypatch.setattr(invite, "screenshot", lambda *args, **kwargs: None)