ZRainbow commited on
Commit
e49e827
·
1 Parent(s): efad4b5

fix: absorb autoteam-1 auth and CPA quota parity

Browse files
.trellis/spec/backend/account-disable-cpa-sync.md ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Account Disable and CPA Sync Contract
2
+
3
+ ## Scenario: local account disable controls
4
+
5
+ ### 1. Scope / Trigger
6
+
7
+ - Trigger: an operator needs to keep an account record and auth file locally while excluding that account from automated checks, rotation, reuse, and CPA publication.
8
+ - Applies to: `src/autoteam/accounts.py`, `src/autoteam/api.py`, `src/autoteam/cpa_sync.py`, and Dashboard account operations.
9
+
10
+ ### 2. Signatures
11
+
12
+ - Account row field: `disabled: bool`.
13
+ - Helper: `is_account_disabled(acc: dict | None) -> bool`.
14
+ - API:
15
+ - `POST /api/accounts/{email}/disable`
16
+ - `POST /api/accounts/{email}/enable`
17
+ - `POST /api/accounts/bulk/disable` with body `{ "emails": string[] }`
18
+ - `POST /api/accounts/bulk/enable` with body `{ "emails": string[] }`
19
+ - Status response account fields:
20
+ - `status`: display status, `disabled` for disabled non-main accounts.
21
+ - `raw_status`: persisted lifecycle status such as `active`, `standby`, or `personal`.
22
+
23
+ ### 3. Contracts
24
+
25
+ - `load_accounts()` and `save_accounts()` must normalize legacy rows so missing `disabled` becomes `false`.
26
+ - Main account rows must not be disabled or enabled through these endpoints.
27
+ - Disabled non-main accounts remain visible in `/api/status`, but quota probing must skip them.
28
+ - Disabled rows must be excluded from `get_active_accounts()` and `get_standby_accounts()`.
29
+ - Auto-check and rotation candidate selection must treat disabled rows as unavailable.
30
+ - `sync_to_cpa()` must not upload disabled accounts.
31
+ - A disabled account's local auth file is still protected from accidental CPA remote deletion by the existing same-file delete guard.
32
+
33
+ ### 4. Validation & Error Matrix
34
+
35
+ | Condition | Required behavior |
36
+ | --- | --- |
37
+ | Empty or invalid email | `400` with a business message |
38
+ | Main account email | `400`, never mutate `disabled` |
39
+ | Unknown email | `404` for single toggle; `missing_emails` entry for bulk |
40
+ | Already target state | No mutation; include in `unchanged_emails` for bulk |
41
+ | Mixed bulk payload | Deduplicate, skip main, report missing, mutate valid rows |
42
+ | Disabled row in status | Return `status="disabled"` and preserve `raw_status` |
43
+
44
+ ### 5. Good/Base/Bad Cases
45
+
46
+ - Good: disabling an `active` account keeps the row and auth file, hides it from quota checks, and surfaces `raw_status="active"` plus `status="disabled"`.
47
+ - Base: enabling a disabled standby account restores `status="standby"` in the display status.
48
+ - Bad: deleting or moving auth files when an account is merely disabled.
49
+
50
+ ### 6. Tests Required
51
+
52
+ - `tests/unit/test_accounts.py`
53
+ - Normalizes missing `disabled`.
54
+ - Excludes disabled active/standby rows from active/reuse helpers.
55
+ - `tests/unit/test_api_status.py`
56
+ - Single disable/enable.
57
+ - Bulk disable/enable with missing, unchanged, duplicate, and main-account rows.
58
+ - `/api/status` skips disabled quota checks and counts disabled rows.
59
+ - `tests/unit/test_cpa_sync.py`
60
+ - Disabled account is skipped for CPA upload.
61
+ - Matching remote auth file is retained by delete guard.
62
+
63
+ ### 7. Wrong vs Correct
64
+
65
+ #### Wrong
66
+
67
+ ```python
68
+ active = [a for a in load_accounts() if a["status"] == STATUS_ACTIVE]
69
+ ```
70
+
71
+ This lets a locally disabled account re-enter auto-check, rotation, or CPA sync.
72
+
73
+ #### Correct
74
+
75
+ ```python
76
+ active = [
77
+ a for a in load_accounts()
78
+ if a["status"] == STATUS_ACTIVE and not is_account_disabled(a)
79
+ ]
80
+ ```
81
+
82
+ ## Scenario: CPA list failure handling
83
+
84
+ ### 1. Scope / Trigger
85
+
86
+ - Trigger: CPA may be unreachable, return a 5xx page, return non-JSON, or return a malformed JSON shape.
87
+ - Applies to `list_cpa_files()` and all call sites that depend on CPA truth.
88
+
89
+ ### 2. Signatures
90
+
91
+ - `list_cpa_files() -> list[dict]`.
92
+ - Raises `RuntimeError` on request, HTTP, JSON, or schema failure.
93
+
94
+ ### 3. Contracts
95
+
96
+ - Do not convert CPA failure into `[]`.
97
+ - Do not treat remote failure as "remote has no files".
98
+ - Callers performing sync or cleanup should fail loudly or surface the error rather than deleting based on false emptiness.
99
+
100
+ ### 4. Validation & Error Matrix
101
+
102
+ | Condition | Required behavior |
103
+ | --- | --- |
104
+ | `requests.get` raises | `RuntimeError("[CPA] auth-files list request failed: ...")` |
105
+ | HTTP status is not `200` | `RuntimeError("[CPA] auth-files list failed: HTTP ...")` |
106
+ | Body is non-JSON | `RuntimeError("[CPA] auth-files list returned non-JSON response")` |
107
+ | JSON `files` is missing or not a list | `RuntimeError("[CPA] auth-files list response missing files list")` |
108
+
109
+ ### 5. Good/Base/Bad Cases
110
+
111
+ - Good: CPA returns `{"files": [...]}` and the sync proceeds.
112
+ - Base: CPA has zero files and explicitly returns `{"files": []}`.
113
+ - Bad: CPA returns a `503` HTML page and the app interprets that as an empty CPA file set.
114
+
115
+ ### 6. Tests Required
116
+
117
+ - `tests/unit/test_cpa_sync.py::test_list_cpa_files_raises_on_non_200`.
118
+ - `tests/unit/test_cpa_sync.py::test_list_cpa_files_raises_on_non_json`.
119
+
120
+ ### 7. Wrong vs Correct
121
+
122
+ #### Wrong
123
+
124
+ ```python
125
+ if resp.status_code != 200:
126
+ return []
127
+ ```
128
+
129
+ #### Correct
130
+
131
+ ```python
132
+ if resp.status_code != 200:
133
+ raise RuntimeError(f"[CPA] auth-files list failed: HTTP {resp.status_code}")
134
+ ```
135
+
136
+ ## Scenario: Managed-account deletion and Team state parsing
137
+
138
+ ### 1. Scope / Trigger
139
+
140
+ - Trigger: deleting a locally managed account, rendering `/api/team/members`, or reconciling local rows against ChatGPT Team member/invite responses.
141
+ - Applies to `src/autoteam/account_ops.py`, `src/autoteam/api.py`, and any caller that consumes `fetch_team_state()`.
142
+ - Goal: tolerate known ChatGPT Team API response-shape drift without deleting protected local credentials or mutating the wrong remote target.
143
+
144
+ ### 2. Signatures
145
+
146
+ - `fetch_team_state(chatgpt_api) -> tuple[list, list]`.
147
+ - `extract_team_members(payload) -> list`.
148
+ - `extract_team_invites(payload) -> list`.
149
+ - `team_member_email(member) -> str`.
150
+ - `team_member_user_id(member) -> str | None`.
151
+ - `team_member_role(member) -> str | None`.
152
+ - `team_invite_email(invite) -> str`.
153
+ - `delete_team_invite(chatgpt_api, account_id: str, invite: dict | None = None, *, invite_id=None, email: str | None = None) -> dict`.
154
+ - `delete_managed_account(email, *, remove_remote=True, remove_cloudmail=True, sync_cpa_after=True, chatgpt_api=None, mail_client=None, remote_state=None) -> dict`.
155
+ - `GET /api/team/members` response rows must contain `email`, `role`, `user_id`, `is_local`, and `type`.
156
+
157
+ ### 3. Contracts
158
+
159
+ - Team member responses may be a list or a dict containing `items`, `users`, `members`, or `account_users`.
160
+ - Team invite responses may be a list or a dict containing `items`, `invites`, or `account_invites`.
161
+ - Member identity fields may live at the top level, under `user`, or under `account_user`.
162
+ - Invite email fields may live at the top level, under `user`, or under `account_user`.
163
+ - Team API `401` / `403` and HTML/non-JSON responses must raise a readable `RuntimeError` that tells the operator to redo administrator login.
164
+ - Invite cancellation must first try `DELETE /backend-api/accounts/{account_id}/invites/{invite_id}` when an id exists, then fall back to collection delete with `{"email_address": email}`.
165
+ - Deleting an account must call `delete_account_from_configured_targets(..., include_disabled=True)` so CPA and Sub2API cleanup use the shared configured-target router.
166
+ - Local auth paths may be stored as absolute paths, `/app/...` container paths, `data/auths/...`, or `auths/...`; deletion must resolve known candidates without string-only assumptions.
167
+ - Non-main local credential seats with an auth file and no mail binding are protected from destructive deletion; the cleanup result must expose `protected_local_credential=true`.
168
+ - Mail-provider deletion must prefer `mail_account_id` and fall back to legacy `cloudmail_account_id`.
169
+
170
+ ### 4. Validation & Error Matrix
171
+
172
+ | Condition | Required behavior |
173
+ | --- | --- |
174
+ | Team users/invites return known list-wrapper shapes | Return extracted records without dropping nested identity fields |
175
+ | Team users endpoint returns HTML/login/challenge page | Raise `RuntimeError` containing `接口返回了非 JSON 内容` and redo-login guidance |
176
+ | Team users endpoint returns `401` or `403` | Raise `RuntimeError` containing redo-login guidance |
177
+ | Invite-id delete returns non-success and email is known | Retry collection delete with `email_address` |
178
+ | Account auth file uses a container `/app/...` path | Resolve the corresponding project-local candidate before deletion |
179
+ | Account has local auth and no mail binding | Return cleanup with `protected_local_credential=true`; do not delete local row, auth file, CPA, or Sub2API |
180
+ | Account has `mail_account_id` | Delete that provider account id, even when `cloudmail_account_id` is missing |
181
+ | Configured target cleanup returns CPA/Sub2API deletions | Surface them as `cleanup["cpa_files"]` and `cleanup["sub2api_accounts"]` |
182
+
183
+ ### 5. Good/Base/Bad Cases
184
+
185
+ - Good: `/api/team/members` receives `{"items": [{"user": {"email": "...", "id": "...", "account_role": "admin"}}]}` and returns normalized row fields for the frontend.
186
+ - Good: deleting a provider-backed account removes the Team member/invite, local auth file, local row, configured remote targets, and provider mailbox id once.
187
+ - Base: a personal or auth-invalid row still skips Team remote fetch, but local and configured-target cleanup remains available.
188
+ - Bad: assuming `member["email"]` and `member["user_id"]` are always top-level fields; this silently hides nested Team rows from cleanup and UI.
189
+ - Bad: calling CPA-only cleanup directly from `delete_managed_account()`; this bypasses Sub2API and configured-target availability rules.
190
+ - Bad: deleting a manually imported credential row simply because it has an auth file but no provider mailbox binding.
191
+
192
+ ### 6. Tests Required
193
+
194
+ - `tests/unit/test_account_ops.py`
195
+ - `fetch_team_state()` parses member/invite wrapper variants and nested identity fields.
196
+ - Team API HTML/auth failures raise readable redo-login errors.
197
+ - `delete_team_invite()` falls back from invite-id delete to collection delete.
198
+ - `delete_managed_account()` uses `mail_account_id`, configured-target deletion, and local credential protection.
199
+ - `/api/team/members` renders normalized nested Team rows and still stops `ChatGPTTeamAPI`.
200
+ - Regression suites:
201
+ - `tests/unit/test_round6_patches.py`
202
+ - `tests/unit/test_spec2_lifecycle.py`
203
+ - `tests/unit/test_cpa_sync.py`
204
+ - `tests/unit/test_sub2api_sync.py`
205
+
206
+ ### 7. Wrong vs Correct
207
+
208
+ #### Wrong
209
+
210
+ ```python
211
+ email = (member.get("email") or "").lower()
212
+ user_id = member.get("user_id") or member.get("id")
213
+ ```
214
+
215
+ This misses nested `user` / `account_user` shapes and can leave real Team seats undeleted.
216
+
217
+ #### Correct
218
+
219
+ ```python
220
+ email = team_member_email(member)
221
+ user_id = team_member_user_id(member)
222
+ ```
223
+
224
+ Use the shared shape helpers wherever Team state is parsed, including cleanup and `/api/team/members`.
225
+
226
+ ## Scenario: active CPA publish preflight
227
+
228
+ ### 1. Scope / Trigger
229
+
230
+ - Trigger: `sync_to_cpa()` is about to publish a local `STATUS_ACTIVE` auth file or decide whether an existing CPA copy should be deleted.
231
+ - Applies to `src/autoteam/cpa_sync.py` and the unit tests that cover active credential publish branches.
232
+ - Goal: make publish/delete decisions from live quota truth, not from the local status string alone.
233
+
234
+ ### 2. Signatures
235
+
236
+ - `_active_auth_publish_decision(acc: dict, path: Path) -> str`
237
+ - `sync_to_cpa() -> dict`
238
+ - `check_codex_quota(access_token, timeout=8) -> tuple[str, dict | None]`
239
+
240
+ ### 3. Contracts
241
+
242
+ - `quota_status == "ok"` means the active credential may be published.
243
+ - `quota_status == "network_error"` means the remote copy stays in place and the local row waits for a later round.
244
+ - Any other non-`ok` quota status means the existing remote copy should follow the delete path used for stale active files.
245
+ - A successful live `ok` check may refresh the local auth bundle's `proxy_url` before upload.
246
+ - `sync_to_cpa()` should surface active-publish counters so operators can see why a file was uploaded, kept, or deleted.
247
+
248
+ ### 4. Validation & Error Matrix
249
+
250
+ | Condition | Required behavior |
251
+ | --- | --- |
252
+ | Active quota check returns `ok` | Upload the local auth file and refresh proxy metadata when available |
253
+ | Active quota check returns `network_error` | Keep the remote copy and do not delete or upload that file |
254
+ | Active quota check returns `exhausted` or another terminal failure | Delete the remote copy through the existing delete path |
255
+ | Active quota probe raises | Log the failure and keep the remote copy for the next round |
256
+
257
+ ### 5. Good/Base/Bad Cases
258
+
259
+ - Good: a live `ok` credential is republished with fresh proxy metadata.
260
+ - Base: a transient network error leaves the remote CPA copy untouched.
261
+ - Bad: deleting or uploading active CPA files purely because the local row still says `active`.
262
+
263
+ ### 6. Tests Required
264
+
265
+ - `tests/unit/test_cpa_sync.py::test_sync_to_cpa_skips_exhausted_active_credential_before_upload`
266
+ - `tests/unit/test_cpa_sync.py::test_sync_to_cpa_keeps_remote_on_active_quota_network_error`
267
+ - `tests/unit/test_cpa_sync.py::test_sync_to_cpa_refreshes_proxy_url_before_upload`
268
+
269
+ ### 7. Wrong vs Correct
270
+
271
+ #### Wrong
272
+
273
+ ```python
274
+ if acc.get("status") == STATUS_ACTIVE:
275
+ upload_to_cpa(path)
276
+ ```
277
+
278
+ #### Correct
279
+
280
+ ```python
281
+ quota_status, _info = check_codex_quota(access_token, timeout=8)
282
+ if quota_status == "ok":
283
+ upload_to_cpa(path)
284
+ elif quota_status == "network_error":
285
+ keep_remote_copy()
286
+ else:
287
+ delete_from_cpa(path.name)
288
+ ```
289
+
290
+ Live quota must decide the publish branch before the remote copy is touched.
.trellis/spec/backend/index.md CHANGED
@@ -21,7 +21,8 @@ This directory contains guidelines for backend development. Fill in each file wi
21
  | [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill |
22
  | [Runtime and Docker Hardening](./runtime-docker-hardening.md) | Docker resource bounds, Playwright cleanup, resource probes, transport safety, and IPv6 proxy isolation | Active |
23
  | [Free Registration Hardening](./free-registration-hardening.md) | Fill-personal safety boundaries, Team-seat preflight, and SignupProfile consistency | Active |
24
- | [Account Disable and CPA Sync Contract](./account-disable-cpa-sync.md) | Disabled-account automation exclusion and explicit CPA list failure handling | Active |
 
25
 
26
  ---
27
 
 
21
  | [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill |
22
  | [Runtime and Docker Hardening](./runtime-docker-hardening.md) | Docker resource bounds, Playwright cleanup, resource probes, transport safety, and IPv6 proxy isolation | Active |
23
  | [Free Registration Hardening](./free-registration-hardening.md) | Fill-personal safety boundaries, Team-seat preflight, and SignupProfile consistency | Active |
24
+ | [Account Disable and CPA Sync Contract](./account-disable-cpa-sync.md) | Disabled-account automation exclusion, active CPA publish preflight, and explicit CPA list failure handling | Active |
25
+ | [Setup Diagnostics Contracts](./setup-diagnostics.md) | Setup-stage read-only diagnostics such as DNS checks and auth/rate-limit behavior | Active |
26
 
27
  ---
28
 
.trellis/tasks/05-19-autoteam1-full-parity-completion-audit/prd.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # autoteam-1 full parity completion audit
2
+
3
+ ## Goal
4
+
5
+ Continue the long-running objective: absorb the good, proven ideas from
6
+ `D:\Desktop\autoteam-1\AutoTeam` into the current project at `D:\Desktop\AutoTeam`.
7
+
8
+ This task is not a blanket copy. The two repositories have diverged and the current
9
+ project already contains stronger local work in several areas. The work here is to
10
+ turn "all good points" into a concrete, evidence-backed checklist, identify the
11
+ remaining gaps, and then implement the next safe migration slice without disturbing
12
+ unrelated active work.
13
+
14
+ ## Current Baseline
15
+
16
+ Recent current-repo commits already landed the largest seat-rotation slice:
17
+
18
+ * `05bf6da fix: harden seat rotation and direct signup race`
19
+ * `d8f55d5 chore(task): archive 05-19-autoteam1-seat-rotation-hardening-migration`
20
+ * `efad4b5 chore: record journal`
21
+
22
+ That prior slice covered `ROTATE_SKIP_REUSE`, remove-before-create replacement,
23
+ managed child validation, direct signup race wiring, multi-master direct parallel
24
+ propagation, auto-check cooldown/blocker handling, backend specs, and unit tests.
25
+
26
+ ## Requirements
27
+
28
+ * Build a prompt-to-artifact checklist for the global objective, not just the last
29
+ rotation task.
30
+ * Compare target-repo recent commits and target-only files against current code,
31
+ tests, specs, and UI artifacts.
32
+ * Classify each target capability as:
33
+ * absorbed with evidence,
34
+ * partially absorbed and needing deeper audit,
35
+ * not absorbed and worth migrating,
36
+ * not worth migrating because current repo has a better/incompatible pattern.
37
+ * Preserve current repo advantages: multi-provider mail fallback, account-state
38
+ machine, multi-master workspace pool, cleaner frontend design direction, CPA
39
+ delete guard, and the 3-seat Team cap.
40
+ * Do not copy target files wholesale. Migrations must be adapted to current module
41
+ boundaries and tested with current tests.
42
+ * Do not use `/api/sync` as a smoke test and do not restart live Docker during
43
+ audit-only work.
44
+ * Do not include unrelated existing dirty files in commits.
45
+
46
+ ## Acceptance Criteria
47
+
48
+ * [x] Research file contains a target-commit-to-current-evidence matrix for recent
49
+ target commits.
50
+ * [x] Research file lists target-only source/UI/test files and states whether they
51
+ are equivalent, superseded, or candidates for migration.
52
+ * [x] At least one concrete next implementation slice is selected, with explicit
53
+ in-scope and out-of-scope boundaries.
54
+ * [x] If implementation is started, related tests are added or updated and `ruff`
55
+ plus relevant `pytest` are run.
56
+ * [x] If no implementation is appropriate after audit, the audit explains why the
57
+ global objective is still not complete or what evidence is needed before marking
58
+ it complete.
59
+
60
+ ## Initial Findings
61
+
62
+ See `research/autoteam1-full-parity-matrix.md`.
63
+
64
+ ## Implemented Slices
65
+
66
+ * P1: Added a current-repo regression test for target-2 exhausted blocker removal
67
+ with a stale remote overcount. The adapted current behavior remains
68
+ remove-before-create rather than target-style preswitch.
69
+ * P2: Added read-only DNS diagnostics via `src/autoteam/dns_diagnostics.py` and
70
+ `POST /api/setup/dns/check`. Mutating Cloudflare DNS upsert remains out of scope.
71
+ * P3: Migrated target `account_ops` Team-state parsing and deletion safeguards into
72
+ current repo boundaries: nested Team member/invite helpers, readable auth/HTML
73
+ failures, invite-delete fallback, configured CPA/Sub2API target cleanup, generic
74
+ mail account deletion, local credential seat protection, and `/api/team/members`
75
+ normalized rendering.
76
+ * P4: Migrated target `8f17448` CPA credential gate into current auto-check shape:
77
+ read-only CLIProxyAPI provider-auth metadata can trigger the existing `auto-fill`
78
+ path when Team is full, local active children are below target, and CPA has zero
79
+ usable provider credentials. Management failure is not treated as zero.
80
+ * P5: Migrated reset-quota local recovery: `cmd_reset_quota_recovery`, CLI
81
+ `reset-quota`, `POST /api/tasks/reset-quota`, and tests recover stale exhausted
82
+ rows without touching Team, CPA, or Sub2API.
83
+ * P6: Migrated main-Codex after-admin improvements in current repo shape:
84
+ local-only `MainCodexLoginFlow`, action-aware main-Codex status,
85
+ `POST /api/main-codex/login`, sync-target preflight for
86
+ `POST /api/main-codex/start`, and explicit remote deletion routes through
87
+ `sync_targets.py`.
88
+ * P7: Migrated the safe auth-path subset from target auth-repair work:
89
+ manager-level auth file resolution now handles host-absolute, `/app/...`
90
+ container paths, project-relative `data/auths/...` / `auths/...`, and bare
91
+ filenames across the current repo's auth search dirs. Team reconciliation now
92
+ protects standby/auth-pending rows when a real local auth file exists and
93
+ restores recovered Team auth rows with `protect_team_seat=True`.
94
+ * P8: Migrated target OAuth/auth-repair diagnostic labels and same-round delay
95
+ helper for transient organization/region pages such as `oauth_timeout`,
96
+ `unsupported_region`, `account_selection`, and `no_valid_organizations`.
97
+ * P9: Migrated target workspace-selection hardening in current repo shape:
98
+ `chatgpt_api` now filters workspace candidate noise, waits for post-selection
99
+ ChatGPT readiness, and shortcuts completed ChatGPT home states; `codex_auth`
100
+ exposes compatibility wrappers to the existing shared `oauth_workspace.py`
101
+ detectors/selectors.
102
+ * P11: Migrated the safe lightweight Codex OAuth helper subset from target
103
+ `test_signup_flow_profiles.py`: API organization dropdown selection, account
104
+ chooser selection, OAuth trace filtering/classification, timeout and
105
+ `no_valid_organizations` retry-page recovery, login-challenge completion,
106
+ OTP rejection cache hashing, and OTP submit acceptance of OAuth progress URLs.
107
+ * P12: Migrated split verification-code handling for direct and invite
108
+ registration: delayed single-character input detection, single-input fallback,
109
+ structured invite diagnostics, and shared submit helpers that wait for the
110
+ registration step to advance.
111
+ * P13: Migrated the safe session fallback subset for Codex auth:
112
+ `_fetch_team_session_bundle_from_context`, explicit
113
+ `pre_signed_in_cookies`, and opt-in `return_result=True` wrapping. The default
114
+ `login_codex_via_browser()` contract still returns the existing bundle/`None`
115
+ shape.
116
+ * P14: Migrated the safe `_login_codex_with_result` subset from target
117
+ auth-repair work. The helper normalizes explicit `return_result=True`, legacy
118
+ bundle/`None`, retryable failures, and non-Team bundles into one result shape,
119
+ while keeping it isolated from `cmd_check` and Team-seat release policy.
120
+ * P15: Migrated the safe `cmd_check` auth-repair entry subset from target:
121
+ `force_auth_repair`, `preserve_low_active`, historical-low-quota handling when
122
+ live quota returns `network_error`, auth-pending scanning, and per-account mail
123
+ provider routing. This keeps the current persisted `auth_invalid` alias and
124
+ does not change `_record_auth_repair_failure` release policy.
125
+ * P16: Migrated the low-risk `_record_auth_repair_failure` policy subset:
126
+ repeated `email_verification` can release a Team blocker after retry budget is
127
+ exhausted, missing-auth `login_state_lost` releases and retires the unusable
128
+ row, released repair failures are marked disabled/reuse-disabled to avoid
129
+ accidental standby reuse, and protected local credential seats are paused
130
+ without release.
131
+ * P17: Migrated the Cloudflare temp-email compatibility facade and metadata
132
+ extraction improvement: `autoteam.cloudflare_temp_email` remains a legacy
133
+ import path, `normalize_cloudflare_temp_email_base_url()` strips `/admin`,
134
+ `MailProvider` extract helpers prefer `metadata.ai_extract` before subject/body
135
+ parsing, and target-style `test_cloudflare_temp_email.py` now passes against
136
+ current repo.
137
+ * P18: Migrated the same real-time quota principle into CPA publish routing:
138
+ `sync_to_cpa()` now re-checks active auths immediately before upload, keeps the
139
+ remote copy on `network_error`, publishes only when `check_codex_quota()`
140
+ returns `ok`, and deletes remote active files on exhausted/terminal failures.
141
+ Tests cover the live keep-remote and delete-remote branches plus proxy refresh
142
+ before upload.
143
+
144
+ ## Open Decisions
145
+
146
+ * Target `ConfigPage.vue` exact migration is rejected for this task. Its grouping
147
+ idea is useful, but the component depends on target-only `/api/config/runtime`
148
+ and `/api/config/source` endpoints, keeps a raw `.env` editor, and reintroduces
149
+ dark glass / emoji styling. A current-style runtime configuration page can be a
150
+ separate frontend/API safety task, not a direct parity copy.
151
+ * Target `ThemeToggle.vue` remains rejected for this project because it reintroduces
152
+ dark gradient / emoji styling that the current project intentionally moved away
153
+ from.
154
+ * Target's old positional `SignupProfile("Name", year, month, day, age)` public
155
+ constructor remains rejected. Current repo keeps the stronger immutable
156
+ `SignupProfile(full_name, birthday, age=...)` snapshot contract; the underlying
157
+ birthday/age/profile propagation behavior is covered by current tests.
158
+ * Remaining target auth-repair assertions after the migrated safe helper subsets
159
+ are not a blanket migration target:
160
+ * target's persisted `"auth_pending"` literal is intentionally rejected because
161
+ current repo maps that lifecycle state to persisted `STATUS_AUTH_INVALID`
162
+ (`"auth_invalid"`) and lets the account-state machine treat it as auth
163
+ pending;
164
+ * target's exact `update_account(email, {"status": ...})` call-shape assertions
165
+ conflict with current `_reason`-carrying state-machine transition logging;
166
+ * target's add-phone retry-disabled "pause without release" behavior is
167
+ rejected for current automated provider children because it can leave an
168
+ unrepairable child occupying one of the two managed seats under the hard
169
+ Team cap. Current capacity-first release behavior is retained.
170
+
171
+ ## Out of Scope For This Audit Pass
172
+
173
+ * Raising the Team seat cap above `1 owner + 2 managed children`.
174
+ * Resetting or cleaning the dirty worktree.
175
+ * Git commit and push are now in scope for the final repository handoff because
176
+ the user explicitly requested them on 2026-05-20.
177
+ * Mutating live remote services such as CPA, Sub2API, Cloudflare DNS, or OpenAI
178
+ Team.
.trellis/tasks/05-19-autoteam1-full-parity-completion-audit/research/autoteam1-full-parity-matrix.md ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # autoteam-1 full parity matrix
2
+
3
+ Date: 2026-05-19
4
+
5
+ ## Objective Restatement
6
+
7
+ User objective: absorb all good points from `D:\Desktop\autoteam-1\AutoTeam` into
8
+ the current repo `D:\Desktop\AutoTeam`.
9
+
10
+ Concrete success criteria:
11
+
12
+ 1. Every recent target-repo improvement is mapped to current code, tests, specs, or
13
+ an explicit migration decision.
14
+ 2. Existing current-repo superior/incompatible implementations are preserved.
15
+ 3. Remaining gaps become small implementation slices, not whole-file overwrites.
16
+ 4. The final completion decision must be based on real artifacts and test/runtime
17
+ evidence, not on commit count or broad similarity.
18
+
19
+ ## Target Recent Commit Matrix
20
+
21
+ Target recent commits inspected with `git -C D:/Desktop/autoteam-1/AutoTeam show --stat --oneline`:
22
+
23
+ | Target commit | Capability | Current evidence | Status | Notes |
24
+ | --- | --- | --- | --- | --- |
25
+ | `8f17448 fix: add CPA credential gate to auto-check` | auto-check should consider read-only CPA provider-auth availability when Team is full but local active children are short | `src/autoteam/api.py::_collect_cpa_credential_gate`, auto-fill branch, `tests/unit/test_api_status.py::test_auto_check_uses_read_only_cpa_gate_when_team_full_and_no_credentials`, `test_auto_check_cpa_gate_does_not_treat_management_failure_as_zero_credentials` | Absorbed by adapted design | Current repo keeps command `auto-fill` / `cmd_rotate(..., background_post_sync=True)` rather than target's larger auto-check restructuring. Gate is read-only and does not call CPA sync/upload/delete, and the same live provider-auth truth now also drives active CPA publish routing in `src/autoteam/cpa_sync.py`. |
26
+ | `276ed0b fix: harden quota blocker rotation` | quota blocker replacement, auto-check action semantics | `src/autoteam/manager.py` has `_replaceable_pool_blocker_reason`, `_wait_for_remote_capacity_after_removal`, `_validate_managed_account_operational`; tests in `tests/unit/test_manager_rotate.py` and `tests/unit/test_api_status.py` | Absorbed in `05bf6da` | Need no whole-file copy. |
27
+ | `f120570 fix rotate post sync and cliproxy health` | deferred rotate post-sync and read-only CLIProxy health | `src/autoteam/cliproxy_health.py`, `/api/status.cliproxy`, `tests/unit/test_cliproxy_health.py`, `tests/unit/test_manager_rotate.py` | Absorbed | Current repo keeps read-only health and CPA delete guard. |
28
+ | `6f4d106 fix invite blank page recovery` | recover post-auth blank registration pages | `src/autoteam/invite.py` has `_recover_blank_invite_page`; tests in `tests/unit/test_invite_blank_page_recovery.py` | Absorbed | Current tests are split from target `test_signup_flow_profiles.py`, but cover the same blank-page recovery behavior. |
29
+ | `15e50d6 Improve registration credential sync and diagnostics` | sync newly ready credential once, improve CPA/Sub2API diagnostics | `src/autoteam/manager.py` has `_sync_ready_credential_to_targets`; `src/autoteam/sync_targets.py` has `sync_account_to_configured_targets`; tests in `test_sync_targets.py`, `test_cpa_sync.py`, `test_sub2api_sync.py` | Mostly absorbed | Further audit should compare target invite diagnostics line-by-line before declaring final completion. |
30
+ | `d952ce6 fix rotation cooldown safety boundary` | cooldown must not block real shortage | `tests/unit/test_api_status.py` includes shortage/cooldown and blocker replacement cases | Absorbed | Strengthened in `05bf6da`. |
31
+ | `2fb3031 fix rotation validation and ipv6 pool status` | `/api/status` runtime/IPv6/rotation validation fields | current `api.py`, `ipv6_pool.py`, `tests/unit/test_api_status.py`, `tests/unit/test_ipv6_pool.py` | Absorbed | Current repo also has richer IPv6 proxy isolation task artifacts. |
32
+ | `7d46b46 fix autoteam seat rotation runtime hardening` | runtime resources, Playwright cleanup, Docker bounds, rotation contract, safer Team/account cleanup helpers | current prior commits and specs: `runtime-docker-hardening.md`, `free-registration-hardening.md`; `src/autoteam/account_ops.py`, `/api/team/members`, `tests/unit/test_account_ops.py` | Absorbed core plus account-ops slice | Docker live deployment was intentionally not restarted in current repo. |
33
+ | `dafa32f fix: preswitch exhausted accounts in seat-2 rotation` | pre-switch exhausted child before replacement | current `cmd_rotate` intentionally uses safer remove-before-create for replaceable blockers; `tests/unit/test_manager_rotate.py::test_cmd_rotate_target2_refills_after_exhausted_removal_despite_transient_overcount` covers exhausted target-2 refill order | Absorbed by adapted design | Do not copy target preswitch because it can temporarily exceed the Team cap. Current regression asserts remove before replacement. |
34
+ | `89932b2 Fix seat-2 preswitch transient overcount cleanup` | cleanup overcount after preswitch | current `cmd_rotate` uses conservative `min(api_count, initial_api_count - removed_now)` after removal; regression test injects stale `api_count=3` and still refills from standby | Absorbed by adapted design | Target overcount risk is covered without creating a transient over-cap state. |
35
+ | `123e80f Add configurable auto-check seats and seat-2 preswitch` | target seat config, settings UI, preswitch | current `.env.example` and API clamp target seats to 1..3; `Settings.vue` has auto-check target controls | Mostly absorbed | Preswitch exactness remains the only uncertain part. |
36
+ | `a0c852f fix-add-phone-auth-retry` | add-phone auth retry backoff | current `AUTO_CHECK_RETRY_ADD_PHONE`, `AUTO_CHECK_ADD_PHONE_MAX_RETRIES`, auth repair tests | Absorbed | Current account-state work appears richer. |
37
+ | `e9d614f feat: add account disable and bulk toggle controls` | disable/bulk enable UI/API and sync exclusion | current `accounts.is_account_disabled`, `/api/accounts/*/disable`, bulk endpoints, `Dashboard.vue`, tests in `test_accounts.py`, `test_api_status.py`, `test_cpa_sync.py`, `test_sub2api_sync.py` | Absorbed | Current frontend style is different but functionality exists. |
38
+ | `6fa85a5 Update main codex remote deletion targets` | Main-Codex local-only login path, sync-target preflight, and remote deletion across CPA/Sub2API | `src/autoteam/codex_auth.py::MainCodexLoginFlow`, `src/autoteam/api.py` routes `/api/main-codex/login`, `/api/main-codex/delete-remote-files`, `/api/main-codex/delete-cpa`, tests in `tests/unit/test_api_main_codex_after_admin.py` and `tests/unit/test_round10_master_codex_session.py` | Absorbed by adapted design | Current repo uses `sync_targets.py` configured-target router and keeps admin-login local auth refresh without remote sync. |
39
+ | `66249a9 feat: add dashboard quota reset action` | Reset local quota recovery metadata and recover exhausted rows to checkable local states | `manager.cmd_reset_quota_recovery`, CLI `reset-quota`, API `POST /api/tasks/reset-quota`, `tests/unit/test_manager_reset_quota.py`, `tests/unit/test_api_status.py` | Absorbed | Current behavior is local-only: no Team/CPA/Sub2API mutation. |
40
+ | `563e8bc Randomize signup profile details` | randomized signup profile consistency | current `SignupProfile` and profile-propagation tests in `test_round12_s3_cherry_pick.py`, `test_free_registration_hardening.py` | Partially absorbed / needs assertion audit | Target's public dataclass shape differs; do not change current immutable profile contract without auditing all free-registration callers. |
41
+ | `5782586 fix: harden codex team workspace selection` | workspace selection, signup-flow helper, and session Codex auth hardening | current `oauth_workspace.py`, `SessionCodexAuthFlow`, `chatgpt_api._workspace_candidate_kind`, `ChatGPTTeamAPI._wait_for_post_workspace_ready`, `codex_auth` compatibility wrappers/lightweight OAuth helpers/session fallback, direct/invite split-code helpers, `tests/unit/test_workspace_oauth_parity.py` | Absorbed by adapted design | Target `test_chatgpt_workspace.py`, `test_codex_auth_session.py`, and all non-profile-shape assertions from target `test_signup_flow_profiles.py` now pass against current. Remaining target signup-flow failures are the rejected old positional `SignupProfile` constructor shape only. |
42
+
43
+ ## Target-only Files And Migration Decisions
44
+
45
+ Target-only files from `git ls-files` comparison:
46
+
47
+ | Target-only artifact | Current equivalent | Decision |
48
+ | --- | --- | --- |
49
+ | `src/autoteam/cloudflare_temp_email.py` | `src/autoteam/cloudflare_temp_email.py` facade plus `src/autoteam/mail/cf_temp_email.py` and `MAIL_PROVIDER` / `MAIL_PROVIDER_CHAIN` | Absorbed as compatibility facade; source of truth remains provider package | Current implementation supports cf_temp_email under a provider package and adds maillab/addy/simplelogin fallback. The top-level module is a legacy import shim only, not a move back to target's simpler architecture. |
50
+ | `src/autoteam/mail_provider.py` | `src/autoteam/mail/__init__.py`, `mail/base.py`, `mail/fallback.py`, provider modules | Superseded. Target helper is simpler; current abstraction is stronger. |
51
+ | `src/autoteam/cloudflare_dns.py` | `src/autoteam/dns_diagnostics.py`, `/api/setup/dns/check`, `tests/unit/test_dns_diagnostics.py` | Read-only portion migrated. The mutating `upsert_record` / `ensure_admin_dns` path remains rejected unless the user explicitly asks for DNS automation. |
52
+ | `web/src/components/ConfigPage.vue` | current `SetupPage.vue`, `Settings.vue`, `MailProviderCard.vue` | UX grouping useful, exact component not migrated | Target's unified runtime/source configuration grouping is useful, especially category tabs and `.env` source editing. The exact file is dark, emoji-heavy, and depends on `/api/config/runtime` plus `/api/config/source`; migrate only after a separate current-style safety/design pass. |
53
+ | `web/src/components/ThemeToggle.vue`, `web/src/theme.js` | no direct current equivalent | Reject for now. Target implementation reintroduces dark gradient/emoji styling that conflicts with current UI direction. |
54
+ | `tests/unit/test_account_ops.py` | `tests/unit/test_account_ops.py` | Migrated. Current tests cover target assertions plus current `/api/team/members` normalized rendering. Target test file also passes against current code. |
55
+ | `web/pnpm-lock.yaml`, `web/public/favicon.svg`, `src/autoteam/web/dist/assets/*`, `src/autoteam/web/dist/favicon.svg` | current web build pipeline, `web/package-lock.json`, regenerated `src/autoteam/web/dist/*` | Superseded as build artifacts | These are target build outputs or lockfile assets, not feature-source truth. Current repo regenerates its own web build artifacts and keeps `web/package-lock.json`; do not copy target build outputs verbatim. |
56
+ | `test_signup_profile.py` | `test_round12_s3_cherry_pick.py`, `test_free_registration_hardening.py`, `src/autoteam/signup_profile.py` | Superseded by current shape | Target file fails against current immutable snapshot interface because current repo intentionally keeps `SignupProfile(full_name, birthday, age="...")` plus `birthday_text` / `age_text`; the underlying behavior (deterministic generation, immutability, positional birthday ordering, RNG injection) is already covered in current tests. |
57
+ | `test_chatgpt_workspace.py` | `tests/unit/test_workspace_oauth_parity.py`, `src/autoteam/chatgpt_api.py` | Migrated | Target file now passes against current repo. Current coverage keeps workspace noise filtering, fallback classification, post-selection readiness, and completed ChatGPT home shortcut behavior. |
58
+ | `test_codex_auth_session.py` | `tests/unit/test_workspace_oauth_parity.py`, `tests/unit/test_round11_oauth_workspace_consent.py`, `src/autoteam/oauth_workspace.py`, `src/autoteam/codex_auth.py` | Migrated core workspace/session assertions | Target file now passes against current repo. Current repo keeps shared implementation in `oauth_workspace.py` and exposes thin compatibility wrappers from `codex_auth.py`. |
59
+ | `test_signup_flow_profiles.py` | `test_free_registration_hardening.py`, `test_round12_s3_cherry_pick.py`, `test_workspace_oauth_parity.py`, `src/autoteam/codex_auth.py`, `src/autoteam/invite.py`, `src/autoteam/manager.py` | Absorbed except rejected old profile constructor shape | Current repo covers snapshot propagation, about-you retries, workspace/OAuth selection, split-code target waits, direct/invite code submit helpers, OAuth lightweight recovery, OTP rejection cache, session-bundle extraction, and `login_codex_via_browser(return_result=..., pre_signed_in_cookies=...)`. Full target file now reaches `24 passed, 11 failed`; every remaining failure is the old positional `SignupProfile("Name", year, month, day, age)` API, which current repo intentionally rejects in favor of the immutable mapping snapshot contract. |
60
+ | `test_manager_auth_repair.py` | `tests/unit/test_manager_auth_paths.py`, auth-repair related tests in `tests/unit/test_round12_s3_cherry_pick.py` and `tests/unit/test_api_status.py` | Safe auth-path/result-helper/cmd-check/release-policy subsets migrated; exact target status literal remains rejected | Current now resolves host, container, project-relative, and bare auth-file paths, protects Team seats when a real local auth exists, exposes `_login_codex_with_result()` with target-compatible retry/result behavior, lets `cmd_check(...force_auth_repair...)` ignore cooldown, preserves historical low-quota candidates on network errors, releases repeated email-verification and missing-auth `login_state_lost` blockers, retires released failed repairs, and preserves protected local credentials. The full target file still has incompatible expectations around the target-only `"auth_pending"` persisted literal, exact `update_account()` call shape without `_reason`, and add-phone retry-disabled no-release behavior. Those are rejected for current's state machine and capacity-first hard-cap policy. |
61
+ | `test_cloudflare_temp_email.py` | `src/autoteam/cloudflare_temp_email.py`, `src/autoteam/mail/base.py`, `src/autoteam/mail/cf_temp_email.py`, current `tests/unit/test_cloudmail.py` | Absorbed as compatibility facade + metadata-preferred extraction | Current repo now keeps the provider-package source of truth, but exposes a target-compatible top-level compatibility module, strips `/admin` from the base URL, and prefers `metadata.ai_extract` before subject/body parsing for OTP and invite extraction. |
62
+
63
+ ## Current-only Strengths To Preserve
64
+
65
+ * `src/autoteam/mail/` package with provider fallback chain and richer provider options.
66
+ * Account-state lifecycle tests and reconciliation anomaly tests.
67
+ * Multi-master workspace pool and direct-parallel budget propagation.
68
+ * Frontend component system (`AtButton`, status/health components, composables) and cleaner UI direction.
69
+ * CPA delete guard and disabled-account exclusion.
70
+ * `1 owner + 2 managed children = 3 seats` hard cap.
71
+
72
+ ## Recommended Next Slice
73
+
74
+ Slice P1: seat-2 preswitch transient-overcount coverage audit and regression tests. **Completed in this task.**
75
+
76
+ Reason:
77
+
78
+ * It is close to the just-landed rotation work and still marked partially absorbed.
79
+ * It can be verified entirely with unit tests.
80
+ * It avoids high-risk live operations and avoids touching frontend style.
81
+
82
+ Slice P2: Cloudflare DNS read-only diagnostics. **Completed in this task.**
83
+
84
+ Reason:
85
+
86
+ * It is the clearest target-only useful source helper.
87
+ * Current docs still instruct manual DNS setup.
88
+ * Only read-only `check_admin_dns` should be migrated first; mutating `ensure_admin_dns` should remain out of scope until the user explicitly wants DNS automation.
89
+
90
+ Slice P3: account cleanup and Team response shape hardening. **Completed in this task.**
91
+
92
+ Reason:
93
+
94
+ * Target `tests/unit/test_account_ops.py` exposed a useful safety surface that was not
95
+ covered in current repo before this slice.
96
+ * The migration is backend-only and does not touch live remote services.
97
+ * Current repo keeps its stronger `src/autoteam/mail/` provider package and
98
+ `sync_targets.py` router instead of copying target top-level `mail_provider.py`.
99
+
100
+ Implemented current evidence:
101
+
102
+ * `src/autoteam/account_ops.py` now parses Team member/invite wrapper variants,
103
+ nested `user` / `account_user` fields, readable auth/HTML failures, invite delete
104
+ fallback, local credential seat protection, configured CPA/Sub2API deletion, and
105
+ generic `mail_account_id` / legacy `cloudmail_account_id` deletion.
106
+ * `src/autoteam/api.py` `/api/team/members` now renders normalized nested Team rows
107
+ using the same helpers rather than assuming top-level fields.
108
+ * `.trellis/spec/backend/account-disable-cpa-sync.md` records the executable
109
+ contract for deletion and Team state parsing.
110
+
111
+ Slice P4: CPA credential gate for auto-check. **Completed in this task.**
112
+
113
+ Reason:
114
+
115
+ * Target commit `8f17448` was newer than the previous matrix and exposed a real
116
+ remaining runtime-decision gap.
117
+ * Current repo already has read-only CLIProxyAPI health, so the useful value is the
118
+ gate decision, not new remote mutation behavior.
119
+ * It can be tested without touching live CPA/Sub2API/Team.
120
+
121
+ Implemented current evidence:
122
+
123
+ * `src/autoteam/api.py::_collect_cpa_credential_gate()` checks only configured CPA
124
+ enablement plus read-only `get_cliproxy_health(cache_ttl=0.0, force_refresh=True)`.
125
+ * `src/autoteam/api.py::_auto_check_loop()` now lets zero available CPA provider
126
+ credentials trigger the existing `auto-fill` path when Team is full but local
127
+ active children are below target.
128
+ * Management failure returns `zero_available=false`; it is not treated as no
129
+ credentials.
130
+ * `.trellis/spec/backend/runtime-docker-hardening.md` records the read-only gate
131
+ contract.
132
+
133
+ Slice P5: reset-quota local recovery. **Completed in this task.**
134
+
135
+ Reason:
136
+
137
+ * Target commit `66249a9` exposed a useful operator recovery action after quota
138
+ reset windows or stale exhausted metadata.
139
+ * The current migration is local-only and does not mutate Team, CPA, Sub2API, or
140
+ browser sessions.
141
+
142
+ Implemented current evidence:
143
+
144
+ * `src/autoteam/accounts.py` defines `STATUS_AUTH_PENDING` as an alias for the
145
+ persisted `auth_invalid` lifecycle state.
146
+ * `src/autoteam/manager.py::cmd_reset_quota_recovery()` clears local quota reset
147
+ metadata for non-main accounts and restores exhausted rows to either `active`
148
+ when an auth file exists or `auth_pending`/`auth_invalid` when it does not.
149
+ * `src/autoteam/api.py` exposes `POST /api/tasks/reset-quota`.
150
+ * `tests/unit/test_manager_reset_quota.py` covers local-only state transitions.
151
+
152
+ Slice P6: main-Codex local login and remote deletion target router. **Completed in this task.**
153
+
154
+ Reason:
155
+
156
+ * Target commit `6fa85a5` and target-only
157
+ `tests/unit/test_api_main_codex_after_admin.py` exposed a useful split between
158
+ local main-Codex login and explicit remote sync/delete actions.
159
+ * The migration is safe when adapted to current `sync_targets.py`: sync and delete
160
+ remain explicit operator actions, while admin-login local refresh does not upload
161
+ or delete CPA/Sub2API files.
162
+
163
+ Implemented current evidence:
164
+
165
+ * `src/autoteam/codex_auth.py::MainCodexLoginFlow` saves a main auth file without
166
+ remote sync; `MainCodexSyncFlow` extends it and still syncs only on the sync path.
167
+ * `src/autoteam/api.py` now tracks `main_codex.action`, exposes
168
+ `POST /api/main-codex/login`, requires enabled sync targets before
169
+ `POST /api/main-codex/start`, and adds explicit remote deletion routes
170
+ `/api/main-codex/delete-remote-files` plus legacy `/api/main-codex/delete-cpa`.
171
+ * `web/src/api.js` exposes client helpers for the new routes without adding a new
172
+ UI surface yet.
173
+ * `.trellis/spec/backend/runtime-docker-hardening.md` records the local-login,
174
+ sync-target preflight, and explicit-delete contracts.
175
+
176
+ Slice P7: manager auth-path resolution and protected Team credential recovery.
177
+
178
+ Reason:
179
+
180
+ * Target `test_manager_auth_repair.py` exposed a low-risk current gap: manager
181
+ code did not consistently resolve auth paths persisted as container paths,
182
+ project-relative paths, or bare filenames.
183
+ * The migration is local-file and reconciliation only. It does not mutate Team,
184
+ CPA, Sub2API, or browser sessions.
185
+ * It strengthens the current account-state lifecycle without copying target's
186
+ larger auth-repair command shape.
187
+
188
+ Implemented current evidence:
189
+
190
+ * `src/autoteam/manager.py::_resolve_auth_file_path()` handles host absolute
191
+ paths, `/app/...` container paths, `data/auths/...`, `auths/...`, and bare
192
+ names searched under current auth dirs.
193
+ * `src/autoteam/manager.py::_find_team_auth_file()` now searches the current
194
+ auth search dirs instead of only one path convention.
195
+ * `sync_account_states()` now preserves Team seats for standby/auth-pending rows
196
+ with real local auth files and restores recovered Team rows with
197
+ `protect_team_seat=True` plus `workspace_account_id`.
198
+ * `tests/unit/test_manager_auth_paths.py` covers the adapted behavior.
199
+
200
+ Remaining target auth-repair audit:
201
+
202
+ * Full target `test_manager_auth_repair.py` still fails against current because
203
+ several assertions describe target-only helper names or behavior policies, not
204
+ simple path handling.
205
+ * Do not migrate those as a batch. Each remaining category needs a separate
206
+ behavior decision against current account-state and hard Team-cap contracts.
207
+
208
+ Slice P8: OAuth/auth-repair transient error labels and retry-delay helper. **Completed in this task.**
209
+
210
+ Reason:
211
+
212
+ * Target `test_manager_auth_repair.py::test_auth_repair_error_label_handles_oauth_timeout`
213
+ exposed a small diagnostic gap with no remote mutation risk.
214
+ * Current repo already has auth-repair retry metadata; richer labels make cooldown
215
+ and pause messages more actionable without changing the state machine.
216
+
217
+ Implemented current evidence:
218
+
219
+ * `src/autoteam/manager.py::_auth_repair_error_label()` now labels
220
+ `oauth_timeout`, `unsupported_region`, `account_selection`,
221
+ `missing_auth_file`, and `auth_error_discard`.
222
+ * `src/autoteam/manager.py::_oauth_retry_delay_seconds()` records the short
223
+ same-round delays target uses for transient organization/region pages.
224
+ * `tests/unit/test_round12_s3_cherry_pick.py` covers the adapted label and delay
225
+ contract.
226
+
227
+ Slice P9: workspace selection and session Codex helper parity. **Completed in this task.**
228
+
229
+ Reason:
230
+
231
+ * Target `test_chatgpt_workspace.py` and `test_codex_auth_session.py` exposed a
232
+ behavior gap: current had the stronger shared `oauth_workspace.py` helpers but
233
+ did not expose target-compatible wrappers from `codex_auth.py`, and
234
+ `ChatGPTTeamAPI.select_workspace_option()` did not wait for post-selection
235
+ ChatGPT readiness before reclassifying the login step.
236
+ * The migration is pure helper/UI-flow hardening and is unit-testable without
237
+ live browser or remote services.
238
+
239
+ Implemented current evidence:
240
+
241
+ * `src/autoteam/chatgpt_api.py` now classifies workspace labels through
242
+ `_workspace_candidate_kind()`, filters page heading/legal-link noise, marks
243
+ personal/free/new-org options as fallback, waits for post-workspace readiness,
244
+ and returns completed when the page has already landed on ChatGPT home.
245
+ * `src/autoteam/codex_auth.py` exposes compatibility wrappers around the shared
246
+ `oauth_workspace.py` detector/candidate/selector functions.
247
+ * `tests/unit/test_workspace_oauth_parity.py` keeps current regression coverage.
248
+ * Target `test_chatgpt_workspace.py` and `test_codex_auth_session.py` pass against
249
+ current repo after this migration.
250
+
251
+ Slice P10: target ConfigPage UX audit.
252
+
253
+ Reason:
254
+
255
+ * Target's grouping of runtime config into mail, sync, security, admin,
256
+ auto-check, source, and proxy categories is useful.
257
+ * Current repo already has a stronger `SetupPage` / `Settings` /
258
+ `MailProviderCard` split and a cleaner restrained UI direction.
259
+ * The exact target component should not be copied: it uses dark glass cards,
260
+ emoji category icons, gradient accents, and depends on target-only
261
+ `/api/config/runtime` plus `/api/config/source` endpoints.
262
+ * Future migration, if desired, should be a current-style runtime configuration
263
+ pass with explicit source-editor safety review, not a whole-file copy.
264
+ * Source check on 2026-05-19 confirmed target frontend clients call
265
+ `/api/config/runtime` and `/api/config/source`, and target backend implements
266
+ those endpoints as runtime config read/write plus raw `.env` source read/write.
267
+ Current repo intentionally exposes narrower setup/runtime endpoints such as
268
+ `/api/config/register-domain`, `/api/config/preferred-seat-type`,
269
+ `/api/config/sync-probe`, and `/api/config/auto-check`, and `web/src/api.js`
270
+ has no runtime/source config client. Exact target UI/API migration would
271
+ therefore be a new source-editor safety feature, not a parity patch.
272
+
273
+ Slice P11: Codex OAuth lightweight challenge/recovery helpers. **Completed in this task.**
274
+
275
+ Reason:
276
+
277
+ * Target `test_signup_flow_profiles.py` exposed a safe helper subset that improves
278
+ Codex OAuth robustness without changing live browser-login orchestration or
279
+ session-bundle extraction policy.
280
+ * The migration is DOM/trace/cache helper hardening only: organization dropdown
281
+ selection, choose-account selection, OAuth trace filtering, retryable error
282
+ classification, timeout/no-valid-organization retry-page recovery, login
283
+ challenge completion, OTP rejection cache hashing, and OTP submit acceptance of
284
+ OAuth progress URLs.
285
+ * Higher-risk target helpers remain out of this slice: split verification-code
286
+ target waits, `_fetch_team_session_bundle_from_context`,
287
+ `login_codex_via_browser(return_result=..., pre_signed_in_cookies=...)`, and
288
+ the rejected old `SignupProfile` constructor shape.
289
+
290
+ Implemented current evidence:
291
+
292
+ * `src/autoteam/codex_auth.py` now contains the lightweight OAuth helpers and
293
+ bounded trace/cache constants.
294
+ * `tests/unit/test_workspace_oauth_parity.py` covers the current-adapted helper
295
+ behavior with Playwright-like fake locators whose empty `.first` is not visible.
296
+ * The selected target subset from `test_signup_flow_profiles.py` passes against
297
+ current repo (`13 passed`).
298
+
299
+ Slice P12: direct/invite split-code verification helpers. **Completed in this task.**
300
+
301
+ Reason:
302
+
303
+ * Target `test_signup_flow_profiles.py` exposed a real registration robustness
304
+ gap: OpenAI email verification can render delayed six-box inputs, and current
305
+ invite registration previously logged only a generic missing-input warning.
306
+ * The migration is DOM-bound helper hardening. It does not alter seat caps,
307
+ remote sync, CPA/Sub2API behavior, or browser-login policy.
308
+
309
+ Implemented current evidence:
310
+
311
+ * `src/autoteam/manager.py` now exposes `_DIRECT_MULTI_CODE_SELECTOR`,
312
+ `_wait_for_direct_code_target()`, and `_submit_direct_verification_code()`.
313
+ The direct flow waits for delayed split/single code inputs before submitting.
314
+ * `src/autoteam/invite.py` now exposes `INVITE_CODE_SELECTORS`,
315
+ `INVITE_MULTI_CODE_SELECTOR`, `_wait_for_invite_code_target()`,
316
+ `_submit_invite_verification_code()`, and structured input diagnostics for
317
+ timeout/advanced-step cases.
318
+ * `tests/unit/test_workspace_oauth_parity.py` covers delayed split-code helper
319
+ behavior in the current repo.
320
+ * Selected target split-code assertions from `test_signup_flow_profiles.py` pass
321
+ against current repo (`5 passed`).
322
+
323
+ Slice P13: Codex session fallback from pre-signed ChatGPT cookies. **Completed in this task.**
324
+
325
+ Reason:
326
+
327
+ * Target `test_signup_flow_profiles.py` showed a useful safe path for newly
328
+ registered accounts: if a valid ChatGPT Team session already exists in the
329
+ browser context, fetch `/api/auth/session` and use that Team access token
330
+ before entering the full OAuth challenge path.
331
+ * The migration is opt-in and backwards-compatible: existing callers still get
332
+ the old bundle/`None` return shape unless they explicitly pass
333
+ `return_result=True`; session fallback is attempted only when
334
+ `pre_signed_in_cookies` is provided.
335
+
336
+ Implemented current evidence:
337
+
338
+ * `src/autoteam/codex_auth.py::_fetch_team_session_bundle_from_context()` injects
339
+ Team account cookies, opens `https://chatgpt.com/admin/workspace/<account_id>`,
340
+ extracts `/api/auth/session.accessToken`, validates Team JWT claims, and can
341
+ accept a quota-verified session token whose JWT workspace claim is stale.
342
+ * `login_codex_via_browser(..., pre_signed_in_cookies=..., return_result=True)`
343
+ returns the target-compatible `{ok, bundle, error_type, error_detail,
344
+ retryable}` shape on the explicit session-fallback path while preserving the
345
+ default return contract.
346
+ * `tests/unit/test_workspace_oauth_parity.py` covers pre-signed session fallback
347
+ in the current repo.
348
+ * Selected target session fallback assertions from `test_signup_flow_profiles.py`
349
+ pass against current repo (`4 passed`).
350
+
351
+ Slice P14: Codex auth-repair result wrapper helper. **Completed in this task.**
352
+
353
+ Reason:
354
+
355
+ * Target `test_manager_auth_repair.py` exposed one safe auth-repair helper that can
356
+ be migrated without changing `cmd_check`, account-state lifecycle, or Team-seat
357
+ release decisions.
358
+ * `_login_codex_with_result()` is a local normalization wrapper around the already
359
+ migrated `login_codex_via_browser(..., return_result=True)` contract. It can be
360
+ tested with pure monkeypatched unit tests and does not touch live browser,
361
+ Team, CPA, Sub2API, or Cloudflare state.
362
+ * Higher-risk target auth-repair behavior remains outside this slice:
363
+ `cmd_check(...force_auth_repair...)`, historical low-quota network-error
364
+ replacement policy, retry-disabled add-phone no-release behavior,
365
+ `auth_pending` literal expectations, repeated email-verification release, and
366
+ local-credential `login_state_lost` pause/release policy.
367
+
368
+ Implemented current evidence:
369
+
370
+ * `src/autoteam/manager.py::_login_codex_with_result()` now normalizes explicit
371
+ `{ok, bundle, error_type, error_detail, retryable}` results, legacy
372
+ bundle/`None` results, thrown exceptions, and non-Team bundles into one result
373
+ shape with `attempts`.
374
+ * `AUTH_REPAIR_SINGLE_ATTEMPT_FAILURE_TYPES` prevents same-round retries for
375
+ failures that should be handled by the surrounding auth-repair state machine,
376
+ while retryable `auth_code_missing` can retry within the same call.
377
+ * `tests/unit/test_round12_s3_cherry_pick.py::TestLoginCodexWithResult` covers
378
+ retryable same-round success, single-attempt terminal categories, and non-Team
379
+ bundle rejection.
380
+ * The selected target `_login_codex_with_result` assertions from
381
+ `test_manager_auth_repair.py` pass against current repo (`5 passed`).
382
+ * After P14, full target `test_manager_auth_repair.py` reached
383
+ `14 passed, 13 failed`; the remaining failures were the higher-risk policy
384
+ categories listed above and were intentionally split into later slices.
385
+
386
+ Slice P15: `cmd_check` auth-repair entry and historical-low-quota handling. **Completed in this task.**
387
+
388
+ Reason:
389
+
390
+ * Target `test_manager_auth_repair.py` exposed useful `cmd_check` behavior that
391
+ is safe when adapted to current state aliases and mail-provider routing:
392
+ force-auth-repair should ignore cooldown, auth-pending rows should be scanned,
393
+ and low historical quota should be usable when the live quota endpoint returns
394
+ a temporary network error.
395
+ * The migration is local decision logic only. It does not change Team-seat release
396
+ policy, CPA/Sub2API sync, live Docker state, or the hard `1 owner + 2 managed
397
+ children` cap.
398
+
399
+ Implemented current evidence:
400
+
401
+ * `src/autoteam/manager.py::cmd_check()` now accepts
402
+ `force_auth_repair=False`, `preserve_low_active=False`, and
403
+ `preserved_low_accounts=None` while preserving the legacy positional
404
+ `include_standby` parameter.
405
+ * `_is_auth_repair_pending_status()` accepts both current persisted
406
+ `STATUS_AUTH_INVALID` and the target legacy `"auth_pending"` literal as input.
407
+ Output still uses current `STATUS_AUTH_INVALID`.
408
+ * `_historical_low_quota_info()` derives a bounded low-quota decision from saved
409
+ `last_quota` only when the reset window has not elapsed.
410
+ * `preserve_low_active=True` records low active accounts in
411
+ `preserved_low_accounts` instead of immediately marking them exhausted; default
412
+ mode can still mark historical-low accounts exhausted.
413
+ * `force_auth_repair=True` bypasses retry cooldown/paused skips, uses
414
+ `_get_account_mail_client()` for per-account mail-provider routing, and calls
415
+ `_login_codex_with_result()` without passing empty proxy kwargs to old
416
+ monkeypatch/test doubles.
417
+ * Selected target `cmd_check` auth-repair assertions now pass against current repo
418
+ (`6 passed`).
419
+
420
+ Slice P16: `_record_auth_repair_failure` safe release-policy subset. **Completed in this task.**
421
+
422
+ Reason:
423
+
424
+ * Target auth-repair work contains one useful seat-rotation safety improvement:
425
+ when a repair failure has actually released a Team seat, the local row should
426
+ not silently re-enter standby reuse.
427
+ * Current repo already has protected local credential rules. Target's
428
+ `login_state_lost` guard was adapted to preserve those local credentials rather
429
+ than releasing them.
430
+ * The migration deliberately does not adopt target's persisted `"auth_pending"`
431
+ literal or exact `update_account()` call shape because current account-state
432
+ transition logging relies on `STATUS_AUTH_INVALID` plus `_reason`.
433
+
434
+ Implemented current evidence:
435
+
436
+ * `src/autoteam/manager.py` now defines release-after-retry and Team-blocker
437
+ classifications for auth repair.
438
+ * Repeated `email_verification` can pause and release after the retry budget is
439
+ exhausted.
440
+ * Missing-auth `login_state_lost` can release the Team blocker; protected local
441
+ credential seats cancel release and remain in current auth-pending state.
442
+ * Released repair failures are marked `disabled=True`, `reuse_disabled=True`, and
443
+ `retired_reason="auth_repair_failed:<type>"`, then account IPv6 proxy state is
444
+ released best-effort.
445
+ * Current `TestRecordAuthRepairFailure`, `TestLoginCodexWithResult`,
446
+ `TestCmdCheckAuthRepairEntry`, `test_api_status`, and `test_round12_wireup`
447
+ suites pass.
448
+ * Full target `test_manager_auth_repair.py` now reaches `20 passed, 7 failed`.
449
+ Remaining failures are the intentionally rejected `"auth_pending"` persisted
450
+ literal, exact update-call shape, and add-phone retry-disabled no-release
451
+ policy. Current keeps capacity-first release for unrepairable managed children.
452
+
453
+ Slice P17: Cloudflare temp-email compatibility facade and metadata-preferred extraction. **Completed in this task.**
454
+
455
+ Reason:
456
+
457
+ * Target `test_cloudflare_temp_email.py` exposed a small compatibility gap:
458
+ the current project already had a stronger `src/autoteam/mail/` provider package,
459
+ but not the legacy top-level import path or the target's `/admin` base URL
460
+ normalization behavior.
461
+ * The useful part of the target behavior is not the top-level architecture; it is
462
+ the provider parsing improvement. Metadata-derived `ai_extract` results should
463
+ win over subject/body fallback parsing because they are more direct and less
464
+ brittle.
465
+
466
+ Implemented current evidence:
467
+
468
+ * `src/autoteam/cloudflare_temp_email.py` now exposes a target-compatible
469
+ `CloudflareTempEmailClient` wrapper while keeping `src/autoteam/mail/cf_temp_email.py`
470
+ as the source of truth.
471
+ * `normalize_cloudflare_temp_email_base_url()` strips `/admin` and trailing
472
+ slashes from target-style inputs.
473
+ * `src/autoteam/mail/base.py` now prefers `metadata.ai_extract.result` for both
474
+ verification code and invite-link extraction before falling back to text/body
475
+ parsing.
476
+ * Current `tests/unit/test_cloudmail.py` covers the compatibility facade and
477
+ metadata-preferred extraction behavior.
478
+ * Target `tests/unit/test_cloudflare_temp_email.py` now passes against current
479
+ repo (`6 passed`), and the combined current cloudmail/mail sniff suite still
480
+ passes.
481
+
482
+ Slice P18: active CPA credential publish preflight. **Completed in this task.**
483
+
484
+ Reason:
485
+
486
+ * Target `8f17448` made CPA provider-auth availability a real-time decision input
487
+ for auto-check. The same principle must also hold when current repo publishes
488
+ active auth files to CPA: local `STATUS_ACTIVE` is not enough evidence that a
489
+ credential should be uploaded or that its remote copy should be removed.
490
+ * The current migration is adapted to existing CPA delete guards. A quota network
491
+ failure is treated as "unknown, keep remote", not "zero usable" and not
492
+ "delete remote".
493
+
494
+ Implemented current evidence:
495
+
496
+ * `src/autoteam/cpa_sync.py::_active_auth_publish_decision()` reads each active
497
+ auth file and calls `check_codex_quota(access_token, timeout=8)` immediately
498
+ before publish.
499
+ * `sync_to_cpa()` uploads only `ok` active credentials, preserves remote files on
500
+ `network_error` / quota exceptions, and routes exhausted or terminal failures to
501
+ the existing remote-delete path.
502
+ * `sync_to_cpa()` now returns `active_publish.skipped_unknown`,
503
+ `active_publish.kept_remote`, and `active_publish.delete_remote` counters so
504
+ operators can see why active files were not uploaded.
505
+ * `tests/unit/test_cpa_sync.py` covers exhausted active credentials deleting the
506
+ matching remote file, network-error active credentials preserving the remote
507
+ copy, disabled-account preservation, and proxy refresh before upload.
508
+
509
+ ## Commands/Evidence Collected
510
+
511
+ * `git -C D:/Desktop/autoteam-1/AutoTeam log --oneline -12`
512
+ * `git log --oneline -12`
513
+ * `git -C D:/Desktop/autoteam-1/AutoTeam show --stat --oneline ...`
514
+ * `diff -qr --exclude=.git --exclude=__pycache__ --exclude=node_modules --exclude=dist ... src/autoteam`
515
+ * `diff -qr --exclude=.git --exclude=__pycache__ --exclude=node_modules --exclude=dist ... web/src`
516
+ * `rg` searches for blank-page recovery, blocker rotation, disable/bulk controls, sync diagnostics, and Cloudflare DNS/Temp Email.
517
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_manager_rotate.py tests/unit/test_api_status.py`
518
+ * `python -m pytest -q tests/unit/test_manager_rotate.py tests/unit/test_api_status.py` -> `21 passed, 1 warning`
519
+ * `python -m ruff check src/autoteam/dns_diagnostics.py src/autoteam/api.py tests/unit/test_dns_diagnostics.py tests/unit/test_manager_rotate.py tests/unit/test_api_status.py`
520
+ * `python -m py_compile src/autoteam/dns_diagnostics.py src/autoteam/api.py src/autoteam/manager.py`
521
+ * `python -m pytest -q tests/unit/test_dns_diagnostics.py tests/unit/test_manager_rotate.py tests/unit/test_api_status.py` -> `27 passed, 1 warning`
522
+ * `python -m pytest -q tests/unit/test_dns_diagnostics.py tests/unit/test_mail_provider_probe.py tests/unit/test_manager_rotate.py tests/unit/test_api_status.py` -> `38 passed, 1 warning`
523
+ * `python -m ruff check src/autoteam/account_ops.py src/autoteam/api.py tests/unit/test_account_ops.py` -> pass
524
+ * `python -m pytest -q tests/unit/test_account_ops.py` -> `8 passed, 1 warning`
525
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_account_ops.py` -> `7 passed`
526
+ * `python -m pytest -q tests/unit/test_round6_patches.py tests/unit/test_spec2_lifecycle.py tests/unit/test_cpa_sync.py tests/unit/test_sub2api_sync.py` -> `66 passed, 1 warning`
527
+ * `python -m pytest -q tests/unit/test_account_ops.py tests/unit/test_api_playwright_cleanup.py tests/unit/test_api_status.py tests/unit/test_manager_rotate.py` -> `43 passed, 1 warning`
528
+ * `python -m ruff check src/autoteam/account_ops.py src/autoteam/api.py tests/unit/test_account_ops.py tests/unit/test_api_playwright_cleanup.py tests/unit/test_api_status.py tests/unit/test_manager_rotate.py tests/unit/test_round6_patches.py tests/unit/test_spec2_lifecycle.py tests/unit/test_cpa_sync.py tests/unit/test_sub2api_sync.py` -> pass
529
+ * `python -m py_compile src/autoteam/account_ops.py src/autoteam/api.py` -> pass
530
+ * `python -m ruff check src/autoteam/api.py tests/unit/test_api_status.py` -> pass
531
+ * `python -m pytest -q tests/unit/test_api_status.py::test_auto_check_cooldown_does_not_delay_real_team_shortage tests/unit/test_api_status.py::test_auto_check_cooldown_keeps_full_team_from_refilling tests/unit/test_api_status.py::test_auto_check_cooldown_allows_full_team_blocker_replacement tests/unit/test_api_status.py::test_auto_check_uses_read_only_cpa_gate_when_team_full_and_no_credentials tests/unit/test_api_status.py::test_auto_check_cpa_gate_does_not_treat_management_failure_as_zero_credentials` -> `5 passed, 1 warning`
532
+ * `python -m pytest -q tests/unit/test_api_status.py` -> `18 passed, 1 warning`
533
+ * `python -m ruff check src/autoteam/accounts.py src/autoteam/manager.py src/autoteam/api.py tests/unit/test_manager_reset_quota.py tests/unit/test_api_status.py` -> pass
534
+ * `python -m pytest -q tests/unit/test_manager_reset_quota.py tests/unit/test_api_status.py` -> `19 passed, 1 warning`
535
+ * `python -m ruff check src/autoteam/api.py src/autoteam/codex_auth.py tests/unit/test_api_main_codex_after_admin.py tests/unit/test_round10_master_codex_session.py` -> pass
536
+ * `python -m pytest -q tests/unit/test_api_main_codex_after_admin.py tests/unit/test_round10_master_codex_session.py` -> `14 passed, 1 warning`
537
+ * `python -m ruff check src/autoteam/api.py src/autoteam/codex_auth.py src/autoteam/accounts.py src/autoteam/manager.py tests/unit/test_api_main_codex_after_admin.py tests/unit/test_round10_master_codex_session.py tests/unit/test_manager_reset_quota.py tests/unit/test_api_status.py` -> pass
538
+ * `python -m pytest -q tests/unit/test_api_main_codex_after_admin.py tests/unit/test_round10_master_codex_session.py tests/unit/test_manager_reset_quota.py tests/unit/test_api_status.py tests/unit/test_sync_targets.py` -> `38 passed, 1 warning`
539
+ * `npm run build` from `web/` -> Vite build succeeded; refreshed `src/autoteam/web/dist`.
540
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_api_main_codex_after_admin.py` -> `5 passed, 1 failed, 1 warning`. The failure is the target's exact `_finish_admin_login` expectation: current repo deliberately refreshes the local main auth file after admin login and records `main_auth` / `main_auth_error`; adapted current test asserts no remote sync instead.
541
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_profile.py` -> `2 passed, 2 failed`. The failures are the target's older public shape (`age` as `int` and positional `SignupProfile(name, year, month, day, age)`), while current repo intentionally uses an immutable birthday mapping plus `age_text` for browser form consumers.
542
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_manager_auth_paths.py` -> pass.
543
+ * `python -m pytest -q tests/unit/test_manager_auth_paths.py` -> `4 passed`.
544
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_has_auth_file_resolves_container_data_auth_path D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_find_team_auth_file_searches_auth_dirs D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_sync_account_states_recovers_team_auth_file_as_protected D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_sync_account_states_promotes_auth_pending_remote_member_with_auth` -> `4 passed`.
545
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py` -> `8 passed, 19 failed, 1 warning`. Remaining failures are categorized above and should not be copied wholesale into current repo without separate behavior review.
546
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_round12_s3_cherry_pick.py` -> pass.
547
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_auth_repair_error_label_handles_oauth_timeout` -> `49 passed, 1 warning`.
548
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_chatgpt_workspace.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_codex_auth_session.py` before migration -> `3 passed, 9 failed`; failures were missing workspace helpers/wrappers.
549
+ * `python -m ruff check src/autoteam/chatgpt_api.py src/autoteam/codex_auth.py tests/unit/test_workspace_oauth_parity.py` -> pass.
550
+ * `python -m pytest -q tests/unit/test_workspace_oauth_parity.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_chatgpt_workspace.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_codex_auth_session.py tests/unit/test_round11_oauth_workspace_consent.py tests/unit/test_oauth_workspace_select.py` -> `45 passed`.
551
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py` -> `3 passed, 32 failed`. Remaining failures are split between rejected old `SignupProfile` constructor shape and separate OAuth challenge/session-bundle helper gaps.
552
+ * `python -m ruff check src/autoteam/codex_auth.py tests/unit/test_workspace_oauth_parity.py` -> pass.
553
+ * `python -m pytest -q tests/unit/test_workspace_oauth_parity.py` -> `6 passed`.
554
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_select_existing_api_organization_prefers_non_new_option D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_select_choose_account_prefers_matching_email D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_oauth_trace_filters_and_trims_relevant_urls D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_oauth_trace_detects_login_challenge_redirect D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_oauth_login_challenge_page_detects_log_in_url D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_complete_oauth_login_challenge_password_path D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_classify_oauth_failure_handles_no_valid_organizations D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_classify_oauth_failure_handles_timeout D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_recover_oauth_timeout_page_clicks_try_again D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_recover_oauth_no_valid_organizations_page_clicks_try_again D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_wait_for_otp_submit_result_accepts_oauth_progress_url D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_otp_rejection_cache_hashes_code_and_loads_recent D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_classify_oauth_failure_handles_unsupported_region` -> `13 passed`.
555
+ * `python -m pytest -q tests/unit/test_workspace_oauth_parity.py tests/unit/test_round11_oauth_workspace_consent.py tests/unit/test_oauth_workspace_select.py tests/unit/test_round12_s3_cherry_pick.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_chatgpt_workspace.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_codex_auth_session.py` -> `95 passed, 1 warning`.
556
+ * `python -m ruff check src/autoteam/manager.py src/autoteam/invite.py` -> pass.
557
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_wait_for_direct_code_target_handles_delayed_split_inputs D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_wait_for_invite_code_target_handles_delayed_split_inputs D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_wait_for_invite_code_target_reports_advanced_step D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_register_with_invite_logs_code_input_diagnostics D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_submit_direct_verification_code_supports_split_inputs` -> `5 passed`.
558
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py tests/unit/test_free_registration_hardening.py tests/unit/test_api_playwright_cleanup.py tests/unit/test_manager_fill.py` -> `74 passed, 1 warning`.
559
+ * `python -m ruff check src/autoteam/codex_auth.py` -> pass.
560
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_fetch_team_session_bundle_from_context_returns_team_token D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_fetch_team_session_bundle_rejects_non_team_token D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_fetch_team_session_bundle_accepts_quota_verified_session_token D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py::test_login_codex_via_browser_uses_session_fallback_before_oauth` -> `4 passed`.
561
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_signup_flow_profiles.py` -> `24 passed, 11 failed`. Remaining failures are all target's older positional `SignupProfile("Name", year, month, day, age)` API. Current repo intentionally keeps immutable `SignupProfile(full_name, birthday, age=...)` and already covers equivalent behavior in current tests.
562
+ * `python -m ruff check src/autoteam/codex_auth.py src/autoteam/manager.py src/autoteam/invite.py tests/unit/test_workspace_oauth_parity.py` -> pass.
563
+ * `python -m pytest -q tests/unit/test_workspace_oauth_parity.py` -> `8 passed`.
564
+ * `python -m pytest -q tests/unit/test_workspace_oauth_parity.py tests/unit/test_round11_oauth_workspace_consent.py tests/unit/test_oauth_workspace_select.py tests/unit/test_round12_s3_cherry_pick.py tests/unit/test_free_registration_hardening.py tests/unit/test_api_playwright_cleanup.py tests/unit/test_manager_fill.py tests/unit/test_round11_session_token_injection.py` -> `126 passed, 1 warning`.
565
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py::TestLoginCodexWithResult` -> `5 passed`.
566
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_login_codex_with_result_retries_retryable_failures_within_same_round D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_login_codex_with_result_stops_immediately_on_hard_failure D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_login_codex_with_result_single_attempts_email_verification D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_login_codex_with_result_single_attempts_login_state_lost D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_login_codex_with_result_rejects_non_team_bundle` -> `5 passed`.
567
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_round12_s3_cherry_pick.py` -> pass.
568
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py tests/unit/test_workspace_oauth_parity.py tests/unit/test_api_status.py` -> `79 passed, 1 warning`.
569
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py` -> `14 passed, 13 failed, 1 warning`. Remaining failures are `cmd_check` signature/force-auth-repair/historical-low-quota behavior and `_record_auth_repair_failure` release/status policy differences, not the result-wrapper helper.
570
+ * `python -m py_compile tests/unit/test_round12_s3_cherry_pick.py` -> pass.
571
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry` -> `2 passed, 1 warning`.
572
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_round12_s3_cherry_pick.py` -> pass.
573
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py::TestLoginCodexWithResult tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry tests/unit/test_api_status.py` -> `25 passed, 1 warning`.
574
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_preserves_active_credential_on_network_error D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_uses_historical_low_quota_on_network_error_for_remove_first D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_marks_historical_low_quota_exhausted_on_network_error D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_skips_cooled_down_auth_pending_account D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_force_auth_repair_ignores_cooldown D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_preserves_low_active_for_seat2_remove_first` -> `6 passed, 1 warning`.
575
+ * `python -m py_compile src/autoteam/manager.py tests/unit/test_round12_s3_cherry_pick.py` -> pass.
576
+ * `python -m ruff check src/autoteam/manager.py tests/unit/test_round12_s3_cherry_pick.py` -> pass.
577
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py::TestRecordAuthRepairFailure` -> `8 passed, 1 warning`.
578
+ * `python -m pytest -q tests/unit/test_round12_s3_cherry_pick.py::TestLoginCodexWithResult tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry tests/unit/test_round12_s3_cherry_pick.py::TestRecordAuthRepairFailure tests/unit/test_api_status.py` -> `33 passed, 1 warning`.
579
+ * `python -m pytest -q tests/unit/test_round12_wireup.py` -> `18 passed, 1 warning`.
580
+ * `python -m ruff check src/autoteam/cloudflare_temp_email.py src/autoteam/mail/base.py src/autoteam/mail/cf_temp_email.py tests/unit/test_cloudmail.py` -> pass.
581
+ * `python -m pytest -q tests/unit/test_cloudmail.py tests/unit/test_mail_cf_temp_email_sniff.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_cloudflare_temp_email.py` -> `27 passed in 0.16s`.
582
+ * `python -m pytest -q tests/unit/test_cloudmail.py tests/unit/test_mail_cf_temp_email_sniff.py tests/unit/test_round12_s3_cherry_pick.py::TestRecordAuthRepairFailure tests/unit/test_round12_s3_cherry_pick.py::TestLoginCodexWithResult tests/unit/test_round12_s3_cherry_pick.py::TestCmdCheckAuthRepairEntry` -> `36 passed, 1 warning`.
583
+ * `python -m pytest -q tests/unit/test_api_status.py tests/unit/test_round12_wireup.py tests/unit/test_manager_auth_paths.py tests/unit/test_manager_reset_quota.py` -> `41 passed, 1 warning`.
584
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_cloudflare_temp_email.py D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_preserves_active_credential_on_network_error D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_force_auth_repair_ignores_cooldown D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py::test_cmd_check_uses_historical_low_quota_on_network_error_for_remove_first` -> `9 passed, 1 warning`.
585
+ * `python -m pytest -q D:/Desktop/autoteam-1/AutoTeam/tests/unit/test_manager_auth_repair.py` -> `20 passed, 7 failed, 1 warning`. Remaining failures are target-only `"auth_pending"` literal assertions, exact `update_account()` call-shape differences caused by current `_reason` state-machine logging, and the rejected add-phone retry-disabled no-release policy.
586
+ * `python -m ruff check src/autoteam/codex_auth.py src/autoteam/cpa_sync.py src/autoteam/accounts.py src/autoteam/mail/__init__.py src/autoteam/manager.py tests/unit/test_cpa_sync.py tests/unit/test_free_registration_hardening.py tests/unit/test_round10_master_codex_session.py` -> pass.
587
+ * `python -m pytest -q tests/unit/test_cpa_sync.py tests/unit/test_free_registration_hardening.py tests/unit/test_round10_master_codex_session.py tests/unit/test_api_status.py tests/unit/test_api_main_codex_after_admin.py tests/unit/test_manager_reinvite.py tests/unit/test_round12_wireup.py tests/unit/test_round11_oauth_failure_backoff.py tests/unit/test_round11_oauth_failure_kick_ws.py tests/unit/test_round11_session_token_injection.py tests/unit/test_round11_fresh_relogin_fallback.py tests/unit/test_round11_oauth_workspace_consent.py tests/unit/test_round11_personal_oauth_retry.py` -> `132 passed, 1 warning`.
588
+
589
+ ## Completion Audit Checklist
590
+
591
+ | Objective item | Evidence inspected | Result |
592
+ | --- | --- | --- |
593
+ | Map every recent target commit to current code/tests/spec or a decision | `Target Recent Commit Matrix` covers the latest target commits from `8f17448` through `5782586` and current commits from `efad4b5` backward | Complete |
594
+ | Classify target-only source/UI/test files | `Target-only Files And Migration Decisions` covers source helpers, UI components, target-only test files, lockfile/favicon/build artifacts | Complete |
595
+ | Absorb safe seat-rotation and account-state improvements | P1, P3, P4, P5, P7, P15, P16, P18 plus current tests and selected target tests | Complete |
596
+ | Preserve current stronger architecture | Current-only strengths section keeps mail provider package/fallback, account-state machine, multi-master pool, CPA delete guard, cleaner UI, and hard 3-seat cap | Complete |
597
+ | Avoid whole-file target copies | All slices are adapted helpers/tests/spec updates; target `ConfigPage.vue`, `ThemeToggle.vue`, old `SignupProfile` shape, and raw build artifacts are rejected | Complete |
598
+ | Avoid unsafe live mutations | No `/api/sync`, Docker restart, CPA/Sub2API/Cloudflare/OpenAI Team mutation, or remote deletion smoke test was used in this audit | Complete |
599
+ | Verify implementation with real commands | Commands/Evidence section records `ruff`, current pytest suites, and selected target pytest runs | Complete |
600
+ | Identify unresolved or rejected target assertions | Remaining target auth-repair failures are explicitly rejected as incompatible status literal/update-call/capacity-policy expectations | Complete |
601
+
602
+ ## Completion Finding
603
+
604
+ The global objective for this audit pass is complete: every inspected target
605
+ recent commit and target-only artifact is now either absorbed in current shape or
606
+ explicitly rejected as not suitable for this project. The broad seat-rotation core,
607
+ preswitch/transient-overcount risk, target-only DNS helper value, account cleanup
608
+ / Team response-shape hardening, CPA credential gate logic, active CPA publish
609
+ preflight, reset-quota recovery, main-Codex local-login / remote-deletion behavior,
610
+ auth-path credential recovery subset, OAuth diagnostic labels, workspace/session
611
+ helper parity, `cmd_check` auth-repair entry behavior, historical-low-quota
612
+ recovery, the safe `_record_auth_repair_failure` release-policy subset, and
613
+ Cloudflare temp-email compatibility/metadata extraction now have adapted
614
+ current-repo implementations and tests. Target `ConfigPage.vue` is classified as
615
+ "do not exact-copy; replan as a separate current-style config safety feature if
616
+ desired." Target
617
+ `test_signup_flow_profiles.py` is fully dispositioned: all
618
+ non-profile-shape behavior is absorbed, and the old positional `SignupProfile`
619
+ constructor API is intentionally rejected. Remaining target auth-repair failures
620
+ are not incomplete work: target's `"auth_pending"` persisted literal is rejected,
621
+ exact `update_account()` call-shape assertions conflict with current
622
+ state-machine logging, and add-phone retry-disabled no-release behavior is
623
+ rejected because it can leave an unrepairable child occupying scarce Team
624
+ capacity under the current hard cap.
src/autoteam/accounts.py CHANGED
@@ -32,6 +32,7 @@ STATUS_STANDBY = "standby" # 已移出 team,等待额度恢复
32
  STATUS_PENDING = "pending" # 已邀请,等待注册完成
33
  STATUS_PERSONAL = "personal" # 已主动退出 team,走个人号 Codex OAuth,不再参与 Team 轮转
34
  STATUS_AUTH_INVALID = "auth_invalid" # auth_file token 已不可用(401/403),待 reconcile 清理或重登
 
35
  STATUS_ORPHAN = "orphan" # 在 workspace 里占着席位,但本地没 auth_file(残废,待人工介入或兜底 kick)
36
  # Round 9 SPEC v2.0 — 母号 cancel_at_period_end 期内子号过渡态:
37
  # 仍持有有效 auth_file + token,wham 200 plan=team 可继续消耗配额;
@@ -90,6 +91,15 @@ def is_account_disabled(acc: dict | None) -> bool:
90
  def _normalize_account(acc: dict) -> dict:
91
  normalized = dict(acc or {})
92
  normalized["disabled"] = bool(normalized.get("disabled", False))
 
 
 
 
 
 
 
 
 
93
  return normalized
94
 
95
 
@@ -119,7 +129,17 @@ def find_account(accounts, email):
119
  return None
120
 
121
 
122
- def add_account(email, password, cloudmail_account_id=None, seat_type=SEAT_UNKNOWN, workspace_account_id=None):
 
 
 
 
 
 
 
 
 
 
123
  """添加新账号。
124
 
125
  seat_type 取值见 SEAT_CHATGPT / SEAT_CODEX / SEAT_UNKNOWN。
@@ -137,6 +157,12 @@ def add_account(email, password, cloudmail_account_id=None, seat_type=SEAT_UNKNO
137
  patch["seat_type"] = seat_type
138
  if workspace_account_id and not existing.get("workspace_account_id"):
139
  patch["workspace_account_id"] = workspace_account_id
 
 
 
 
 
 
140
  if patch:
141
  update_account(email, **patch)
142
  return
@@ -151,6 +177,17 @@ def add_account(email, password, cloudmail_account_id=None, seat_type=SEAT_UNKNO
151
  "email": email,
152
  "password": password,
153
  "cloudmail_account_id": cloudmail_account_id,
 
 
 
 
 
 
 
 
 
 
 
154
  "status": STATUS_PENDING,
155
  "seat_type": seat_type or SEAT_UNKNOWN,
156
  "workspace_account_id": workspace_account_id, # 邀请时所在的母号 workspace ID,母号切换检测用
 
32
  STATUS_PENDING = "pending" # 已邀请,等待注册完成
33
  STATUS_PERSONAL = "personal" # 已主动退出 team,走个人号 Codex OAuth,不再参与 Team 轮转
34
  STATUS_AUTH_INVALID = "auth_invalid" # auth_file token 已不可用(401/403),待 reconcile 清理或重登
35
+ STATUS_AUTH_PENDING = STATUS_AUTH_INVALID # 向后兼容 target 命名,落盘值仍为 auth_invalid
36
  STATUS_ORPHAN = "orphan" # 在 workspace 里占着席位,但本地没 auth_file(残废,待人工介入或兜底 kick)
37
  # Round 9 SPEC v2.0 — 母号 cancel_at_period_end 期内子号过渡态:
38
  # 仍持有有效 auth_file + token,wham 200 plan=team 可继续消耗配额;
 
91
  def _normalize_account(acc: dict) -> dict:
92
  normalized = dict(acc or {})
93
  normalized["disabled"] = bool(normalized.get("disabled", False))
94
+ normalized["mail_provider"] = str(normalized.get("mail_provider") or "").strip().lower()
95
+ mail_account_id = normalized.get("mail_account_id")
96
+ normalized["mail_account_id"] = (
97
+ str(mail_account_id).strip() if mail_account_id is not None and str(mail_account_id).strip() else None
98
+ )
99
+ service_id = normalized.get("mail_service_id")
100
+ normalized["mail_service_id"] = (
101
+ str(service_id).strip() if service_id is not None and str(service_id).strip() else None
102
+ )
103
  return normalized
104
 
105
 
 
129
  return None
130
 
131
 
132
+ def add_account(
133
+ email,
134
+ password,
135
+ cloudmail_account_id=None,
136
+ seat_type=SEAT_UNKNOWN,
137
+ workspace_account_id=None,
138
+ *,
139
+ mail_provider=None,
140
+ mail_account_id=None,
141
+ mail_service_id=None,
142
+ ):
143
  """添加新账号。
144
 
145
  seat_type 取值见 SEAT_CHATGPT / SEAT_CODEX / SEAT_UNKNOWN。
 
157
  patch["seat_type"] = seat_type
158
  if workspace_account_id and not existing.get("workspace_account_id"):
159
  patch["workspace_account_id"] = workspace_account_id
160
+ if mail_provider and not existing.get("mail_provider"):
161
+ patch["mail_provider"] = str(mail_provider).strip().lower()
162
+ if mail_account_id is not None and not existing.get("mail_account_id"):
163
+ patch["mail_account_id"] = str(mail_account_id).strip()
164
+ if mail_service_id and not existing.get("mail_service_id"):
165
+ patch["mail_service_id"] = str(mail_service_id).strip()
166
  if patch:
167
  update_account(email, **patch)
168
  return
 
177
  "email": email,
178
  "password": password,
179
  "cloudmail_account_id": cloudmail_account_id,
180
+ "mail_provider": str(mail_provider or "").strip().lower(),
181
+ "mail_account_id": (
182
+ str(mail_account_id).strip()
183
+ if mail_account_id is not None and str(mail_account_id).strip()
184
+ else None
185
+ ),
186
+ "mail_service_id": (
187
+ str(mail_service_id).strip()
188
+ if mail_service_id is not None and str(mail_service_id).strip()
189
+ else None
190
+ ),
191
  "status": STATUS_PENDING,
192
  "seat_type": seat_type or SEAT_UNKNOWN,
193
  "workspace_account_id": workspace_account_id, # 邀请时所在的母号 workspace ID,母号切换检测用
src/autoteam/codex_auth.py CHANGED
@@ -13,6 +13,7 @@ from pathlib import Path
13
  from playwright.sync_api import sync_playwright
14
 
15
  import autoteam.display # noqa: F401
 
16
  from autoteam.accounts import is_supported_plan, normalize_plan_type
17
  from autoteam.admin_state import (
18
  get_admin_email,
@@ -28,12 +29,24 @@ from autoteam.invite import ( # SPEC-2 shared/add-phone-detection §3 — OAuth
28
  )
29
  from autoteam.playwright_lifecycle import close_playwright_objects
30
  from autoteam.signup_profile import SignupProfile, generate_signup_profile
31
- from autoteam.textio import write_text
32
 
33
  logger = logging.getLogger(__name__)
34
 
35
  PROJECT_ROOT = Path(__file__).parent.parent.parent
36
  SCREENSHOT_DIR = PROJECT_ROOT / "screenshots"
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  # Codex OAuth 配置
39
  CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
@@ -41,6 +54,9 @@ CODEX_AUTH_URL = "https://auth.openai.com/oauth/authorize"
41
  CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
42
  CODEX_CALLBACK_PORT = 1455
43
  CODEX_REDIRECT_URI = f"http://localhost:{CODEX_CALLBACK_PORT}/auth/callback"
 
 
 
44
 
45
  # SPEC-2 shared/quota-classification §4.4 I5 — Codex backend 最小推理端点(用于 uninitialized_seat 二次验证)
46
  _CODEX_SMOKE_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
@@ -79,6 +95,197 @@ def _parse_jwt_payload(token):
79
  return {}
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  def _screenshot(page, name):
83
  SCREENSHOT_DIR.mkdir(exist_ok=True)
84
  page.screenshot(path=str(SCREENSHOT_DIR / name), full_page=True)
@@ -98,6 +305,21 @@ def _build_auth_url(code_challenge, state):
98
  return f"{CODEX_AUTH_URL}?{urllib.parse.urlencode(params)}"
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def _exchange_auth_code(auth_code, code_verifier, fallback_email=None):
102
  logger.info("[Codex] 获取到 auth code,交换 token...")
103
 
@@ -292,6 +514,196 @@ def fetch_nextauth_backend_access_token(page):
292
  return access_token
293
 
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  def fetch_personal_uuid(access_token):
296
  """Round 11 V8 — POST https://chatgpt.com/backend-api/accounts/personal idempotent getOrCreate.
297
 
@@ -646,6 +1058,378 @@ def _click_primary_auth_button(page, field, labels):
646
  return False
647
 
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  def _is_google_redirect(page):
650
  url = (page.url or "").lower()
651
  if "accounts.google.com" in url:
@@ -662,6 +1446,7 @@ _OTP_INPUT_SELECTORS = (
662
  'input[name="code"], input[inputmode="numeric"], input[autocomplete="one-time-code"], '
663
  'input[placeholder*="验证码"], input[placeholder*="code" i]'
664
  )
 
665
  _OTP_INVALID_HINTS = (
666
  "invalid code",
667
  "incorrect code",
@@ -693,6 +1478,25 @@ def _detect_otp_error(page):
693
  return None
694
 
695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  def _wait_for_otp_submit_result(page, timeout=12):
697
  """
698
  等待验证码提交结果:
@@ -703,6 +1507,11 @@ def _wait_for_otp_submit_result(page, timeout=12):
703
  deadline = time.time() + timeout
704
 
705
  while time.time() < deadline:
 
 
 
 
 
706
  err = _detect_otp_error(page)
707
  if err:
708
  return "invalid", err
@@ -710,12 +1519,212 @@ def _wait_for_otp_submit_result(page, timeout=12):
710
  return "accepted", None
711
  time.sleep(0.5)
712
 
 
 
 
 
 
713
  err = _detect_otp_error(page)
714
  if err:
715
  return "invalid", err
716
  return "pending", None
717
 
718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  def _typewrite_credential(page, locator, value, *, delay_ms=50, post_sleep=1.0):
720
  """逐字符 keyboard.type 填入 credential,触发 React onChange 事件链.
721
 
@@ -853,42 +1862,20 @@ def _perform_fresh_relogin_in_context(context, email, password, mail_client, *,
853
 
854
  # === 可能 OTP ===
855
  try:
856
- ci = page.locator(_OTP_INPUT_SELECTORS).first
857
- if ci.is_visible(timeout=5000) and mail_client:
858
- logger.info("[Codex] fresh re-login: 需要 OTP,等待 emailId > %d 的新邮件...", fresh_email_id_before)
859
- otp = None
860
- otp_email_id = 0
861
- t0 = time.time()
862
- while time.time() - t0 < 120:
863
- for em in mail_client.search_emails_by_recipient(email, size=5):
864
- eid = em.get("emailId", 0)
865
- if eid <= fresh_email_id_before or eid in used_email_ids:
866
- continue
867
- sender = (em.get("sendEmail") or "").lower()
868
- if "openai" not in sender and "chatgpt" not in sender:
869
- continue
870
- subj = (em.get("subject") or "").lower()
871
- if "invited" in subj or "invitation" in subj:
872
- continue
873
- otp = mail_client.extract_verification_code(em)
874
- if otp:
875
- otp_email_id = eid
876
- break
877
- if otp:
878
- break
879
- time.sleep(3)
880
- if otp:
881
- used_email_ids.add(otp_email_id)
882
- logger.info("[Codex] fresh re-login: 获取到 OTP %s", otp)
883
- ci.fill(otp)
884
- time.sleep(0.5)
885
- page.locator(
886
- 'button[type="submit"], button:has-text("Continue"), button:has-text("继续")'
887
- ).first.click()
888
- time.sleep(5)
889
- _screenshot(page, "codex_relogin_04_after_otp.png")
890
- else:
891
- logger.warning("[Codex] fresh re-login: 未获取到 OTP")
892
  except Exception:
893
  pass
894
 
@@ -1033,9 +2020,11 @@ def login_codex_via_browser(
1033
  password,
1034
  mail_client=None,
1035
  *,
 
1036
  use_personal=False,
1037
  chatgpt_session_token=None,
1038
  prefetched_personal_uuid=None,
 
1039
  signup_profile: SignupProfile | None = None,
1040
  playwright_proxy_url: str | None = None,
1041
  ):
@@ -1050,9 +2039,12 @@ def login_codex_via_browser(
1050
  prefetched_personal_uuid: Round 11 V8 — accounts.json 持久化的 personal_workspace_id;
1051
  非空时直接拼到 OAuth `auth_url` 的 allowed_workspace_id 参数,
1052
  省一次 silent step-0 内的 POST /accounts/personal。
 
 
1053
  signup_profile: 注册 about-you 使用过的身份快照;OAuth about-you 必须复用它。
1054
  返回 auth bundle: {access_token, refresh_token, id_token, account_id, email, plan_type,
1055
  personal_workspace_id}
 
1056
 
1057
  Round 11 五轮 Option A — 两阶段 personal OAuth:
1058
  阶段 1(快路径):有 chatgpt_session_token → silent step-0 双域注入 + NextAuth refresh,
@@ -1126,6 +2118,30 @@ def login_codex_via_browser(
1126
  browser = p.chromium.launch(**launch_kwargs)
1127
  context = browser.new_context(**get_playwright_context_options())
1128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1129
  # Round 11 四轮 — Personal 模式 session_token 注入(silent step-0):
1130
  # 实测刚踢出 Team 的新号在 OAuth /log-in 页 fill email 后 Continue 按钮变灰禁用,
1131
  # login flow 永远卡在 email 步骤,bundle=None。根因疑似 Playwright fill() 的
@@ -1378,30 +2394,16 @@ def login_codex_via_browser(
1378
 
1379
  # 可能需要邮箱验证码
1380
  try:
1381
- ci = _page.locator('input[name="code"]').first
1382
- if ci.is_visible(timeout=5000) and mail_client:
1383
- logger.info("[Codex] ChatGPT 登录需要验证码,等待 emailId > %d 的新邮件...", _email_id_before_login)
1384
- otp = None
1385
- otp_email_id = 0
1386
- t0 = time.time()
1387
- while time.time() - t0 < 120:
1388
- for em in mail_client.search_emails_by_recipient(email, size=5):
1389
- email_id = em.get("emailId", 0)
1390
- if email_id <= _email_id_before_login or email_id in _used_email_ids:
1391
- continue
1392
- otp = mail_client.extract_verification_code(em)
1393
- if otp:
1394
- otp_email_id = email_id
1395
- break
1396
- if otp:
1397
- break
1398
- time.sleep(3)
1399
- if otp:
1400
- _used_email_ids.add(otp_email_id)
1401
- ci.fill(otp)
1402
- time.sleep(0.5)
1403
- _page.locator('button[type="submit"]').first.click()
1404
- time.sleep(5)
1405
  except Exception:
1406
  pass
1407
 
@@ -1673,6 +2675,19 @@ def login_codex_via_browser(
1673
 
1674
  _screenshot(page, f"codex_04_step{step + 1}_before.png")
1675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1676
  # 在任何页面中,如果有 workspace/组织选择,先选 Team(personal 模式下选个人)
1677
  try:
1678
  # Round 11 — 与 cnitlrt/AutoTeam upstream codex_auth.py:772-815 对齐:
@@ -1880,90 +2895,18 @@ def login_codex_via_browser(
1880
 
1881
  # 处理邮箱验证码页面(可能在 consent 流程中出现)
1882
  try:
1883
- otp_input = page.locator(_OTP_INPUT_SELECTORS).first
1884
- if otp_input.is_visible(timeout=2000) and mail_client:
1885
- logger.info(
1886
- "[Codex] 需要邮箱验证码 (step %d),等待 emailId > %d 的新邮件...",
1887
- step + 1,
1888
- _email_id_before_login,
 
 
 
1889
  )
1890
- otp = None
1891
- otp_email_id = 0
1892
- page_left_code = False
1893
- t0 = time.time()
1894
- while time.time() - t0 < 120:
1895
- if not _is_otp_input_visible(page, timeout=300):
1896
- page_left_code = True
1897
- logger.info("[Codex] 验证码页已退出,继续后续授权流程")
1898
- break
1899
- for em in mail_client.search_emails_by_recipient(email, size=5):
1900
- # 只接受比快照更新的邮件
1901
- email_id = em.get("emailId", 0)
1902
- if email_id <= _email_id_before_login or email_id in _used_email_ids:
1903
- continue
1904
- sender = (em.get("sendEmail") or "").lower()
1905
- if "openai" not in sender and "chatgpt" not in sender:
1906
- continue
1907
- subj = (em.get("subject") or "").lower()
1908
- if "invited" in subj or "invitation" in subj:
1909
- continue
1910
- otp = mail_client.extract_verification_code(em)
1911
- if otp:
1912
- otp_email_id = email_id
1913
- break
1914
- if otp:
1915
- break
1916
- time.sleep(3)
1917
- if otp:
1918
- submit_ok = False
1919
- for submit_attempt in range(1, 3):
1920
- otp_input = page.locator(_OTP_INPUT_SELECTORS).first
1921
- if not otp_input.is_visible(timeout=2000):
1922
- submit_ok = True
1923
- break
1924
-
1925
- otp_input.fill(otp)
1926
- time.sleep(0.5)
1927
- page.locator(
1928
- 'button[type="submit"], button:has-text("Continue"), button:has-text("继续")'
1929
- ).first.click()
1930
- logger.info("[Codex] 已输入验证码: %s", otp)
1931
-
1932
- submit_status, submit_detail = _wait_for_otp_submit_result(page, timeout=12)
1933
- if submit_status == "accepted":
1934
- submit_ok = True
1935
- break
1936
- if submit_status == "invalid":
1937
- _used_email_ids.add(otp_email_id)
1938
- detail_suffix = f",命中提示: {submit_detail}" if submit_detail else ""
1939
- logger.warning(
1940
- "[Codex] 验证码邮件 %s(code=%s)被页面判定无效%s,标记并跳过该邮件",
1941
- otp_email_id,
1942
- otp,
1943
- detail_suffix,
1944
- )
1945
- break
1946
-
1947
- if submit_attempt < 2:
1948
- logger.warning(
1949
- "[Codex] 验证码邮件 %s(code=%s)提交后未确认成功,准备重试第 %d/2 次",
1950
- otp_email_id,
1951
- otp,
1952
- submit_attempt + 1,
1953
- )
1954
- time.sleep(2)
1955
- else:
1956
- _used_email_ids.add(otp_email_id)
1957
- logger.warning(
1958
- "[Codex] 验证码邮件 %s(code=%s)提交后仍未确认成功,标记并跳过该邮件",
1959
- otp_email_id,
1960
- otp,
1961
- )
1962
-
1963
- if submit_ok:
1964
- _used_email_ids.add(otp_email_id)
1965
- continue
1966
- if page_left_code:
1967
  continue
1968
  except Exception:
1969
  pass
@@ -2309,6 +3252,8 @@ def login_codex_via_browser(
2309
 
2310
  if personal_uuid:
2311
  stage2_bundle["personal_workspace_id"] = personal_uuid
 
 
2312
  return stage2_bundle
2313
 
2314
  # 阶段 1 通过(非 personal 或 plan == free) — 走原退出路径
@@ -2316,9 +3261,25 @@ def login_codex_via_browser(
2316
 
2317
  if not auth_code:
2318
  logger.error("[Codex] OAuth 登录失败: 未获取到 authorization code")
 
 
 
 
 
 
 
 
2319
  return None
2320
 
2321
  if not stage1_bundle:
 
 
 
 
 
 
 
 
2322
  return None
2323
 
2324
  # Personal 模式强校验 plan_type:当子号还挂在 Team workspace(OpenAI 后端 kick 同步延迟 /
@@ -2342,10 +3303,20 @@ def login_codex_via_browser(
2342
  plan or "unknown",
2343
  stage1_bundle.get("account_id"),
2344
  )
 
 
 
 
 
 
 
 
2345
  return None
2346
 
2347
  if personal_uuid:
2348
  stage1_bundle["personal_workspace_id"] = personal_uuid
 
 
2349
  return stage1_bundle
2350
 
2351
 
@@ -2646,6 +3617,8 @@ class SessionCodexAuthFlow:
2646
  if step == "password_required":
2647
  if self._switch_password_to_otp():
2648
  continue
 
 
2649
  return {
2650
  "step": "unsupported_password",
2651
  "detail": "主号 Codex 当前停留在密码页,且未找到一次性验证码入口",
@@ -2738,21 +3711,34 @@ class SessionCodexAuthFlow:
2738
  self.page = None
2739
 
2740
 
2741
- class MainCodexSyncFlow(SessionCodexAuthFlow):
2742
  def __init__(self):
 
 
2743
  super().__init__(
2744
  email=get_admin_email(),
2745
  session_token=get_admin_session_token(),
2746
  account_id=get_chatgpt_account_id(),
2747
  workspace_name=get_chatgpt_workspace_name(),
2748
- password="",
2749
  password_callback=None,
2750
  auth_file_callback=save_main_auth_file,
2751
  )
2752
 
 
 
 
 
 
 
 
 
 
 
2753
  def complete(self):
2754
  info = super().complete()
2755
  from autoteam.sync_targets import sync_main_codex_to_configured_targets as sync_main_codex_to_cpa
 
2756
  sync_main_codex_to_cpa(info["auth_file"])
2757
  return {
2758
  "email": info.get("email"),
 
13
  from playwright.sync_api import sync_playwright
14
 
15
  import autoteam.display # noqa: F401
16
+ from autoteam import oauth_workspace as _oauth_workspace
17
  from autoteam.accounts import is_supported_plan, normalize_plan_type
18
  from autoteam.admin_state import (
19
  get_admin_email,
 
29
  )
30
  from autoteam.playwright_lifecycle import close_playwright_objects
31
  from autoteam.signup_profile import SignupProfile, generate_signup_profile
32
+ from autoteam.textio import read_text, write_text
33
 
34
  logger = logging.getLogger(__name__)
35
 
36
  PROJECT_ROOT = Path(__file__).parent.parent.parent
37
  SCREENSHOT_DIR = PROJECT_ROOT / "screenshots"
38
+ _OAUTH_TRACE_LIMIT = 40
39
+ _OAUTH_TRACE_KEYWORDS = (
40
+ "/oauth/authorize",
41
+ "/sign-in-with-chatgpt/codex",
42
+ "choose-an-account",
43
+ "consent",
44
+ "organization",
45
+ "email-verification",
46
+ "log-in",
47
+ "/auth/callback",
48
+ "no_valid_organizations",
49
+ )
50
 
51
  # Codex OAuth 配置
52
  CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
 
54
  CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
55
  CODEX_CALLBACK_PORT = 1455
56
  CODEX_REDIRECT_URI = f"http://localhost:{CODEX_CALLBACK_PORT}/auth/callback"
57
+ _OTP_REJECTION_FILE = AUTH_DIR / "otp_rejections.json"
58
+ _OTP_REJECTION_TTL_SECONDS = 2 * 60 * 60
59
+ _OTP_REJECTION_MAX_PER_EMAIL = 100
60
 
61
  # SPEC-2 shared/quota-classification §4.4 I5 — Codex backend 最小推理端点(用于 uninitialized_seat 二次验证)
62
  _CODEX_SMOKE_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
 
95
  return {}
96
 
97
 
98
+ def _otp_rejection_email_key(email: str | None) -> str:
99
+ return str(email or "").strip().lower()
100
+
101
+
102
+ def _otp_rejection_hash(email: str | None, code: str | None) -> str:
103
+ key = f"{_otp_rejection_email_key(email)}:{str(code or '').strip()}"
104
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
105
+
106
+
107
+ def _load_otp_rejection_store() -> dict:
108
+ try:
109
+ if not _OTP_REJECTION_FILE.exists():
110
+ return {}
111
+ data = json.loads(read_text(_OTP_REJECTION_FILE))
112
+ return data if isinstance(data, dict) else {}
113
+ except Exception as exc:
114
+ logger.debug("[Codex] failed to load OTP rejection cache: %s", exc)
115
+ return {}
116
+
117
+
118
+ def _write_otp_rejection_store(data: dict) -> None:
119
+ try:
120
+ ensure_auth_dir()
121
+ write_text(_OTP_REJECTION_FILE, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
122
+ ensure_auth_file_permissions(_OTP_REJECTION_FILE)
123
+ except Exception as exc:
124
+ logger.debug("[Codex] failed to write OTP rejection cache: %s", exc)
125
+
126
+
127
+ def _coerce_int(value):
128
+ try:
129
+ return int(value)
130
+ except Exception:
131
+ return None
132
+
133
+
134
+ def _load_recent_otp_rejections(email: str | None, *, now: float | None = None):
135
+ now = time.time() if now is None else float(now)
136
+ cutoff = now - _OTP_REJECTION_TTL_SECONDS
137
+ key = _otp_rejection_email_key(email)
138
+ data = _load_otp_rejection_store()
139
+ records = data.get(key, [])
140
+ if not isinstance(records, list):
141
+ return set(), set()
142
+
143
+ code_hashes = set()
144
+ email_ids = set()
145
+ kept = []
146
+ changed = False
147
+ for record in records:
148
+ if not isinstance(record, dict):
149
+ changed = True
150
+ continue
151
+ try:
152
+ created_at = float(record.get("ts") or 0)
153
+ except Exception:
154
+ created_at = 0
155
+ if created_at and created_at < cutoff:
156
+ changed = True
157
+ continue
158
+ code_hash = str(record.get("code_hash") or "").strip()
159
+ if code_hash:
160
+ code_hashes.add(code_hash)
161
+ email_id = _coerce_int(record.get("email_id"))
162
+ if email_id is not None:
163
+ email_ids.add(email_id)
164
+ kept.append(record)
165
+
166
+ if changed:
167
+ if kept:
168
+ data[key] = kept[-_OTP_REJECTION_MAX_PER_EMAIL:]
169
+ else:
170
+ data.pop(key, None)
171
+ _write_otp_rejection_store(data)
172
+
173
+ return code_hashes, email_ids
174
+
175
+
176
+ def _record_otp_rejection(email: str | None, code: str | None, email_id, *, now: float | None = None):
177
+ code = str(code or "").strip()
178
+ if not code:
179
+ return None
180
+
181
+ now = time.time() if now is None else float(now)
182
+ cutoff = now - _OTP_REJECTION_TTL_SECONDS
183
+ key = _otp_rejection_email_key(email)
184
+ code_hash = _otp_rejection_hash(email, code)
185
+ email_id_int = _coerce_int(email_id)
186
+
187
+ data = _load_otp_rejection_store()
188
+ records = data.get(key, [])
189
+ if not isinstance(records, list):
190
+ records = []
191
+
192
+ kept = []
193
+ for record in records:
194
+ if not isinstance(record, dict):
195
+ continue
196
+ try:
197
+ created_at = float(record.get("ts") or 0)
198
+ except Exception:
199
+ created_at = 0
200
+ if created_at and created_at < cutoff:
201
+ continue
202
+ if str(record.get("code_hash") or "") == code_hash:
203
+ continue
204
+ if email_id_int is not None and _coerce_int(record.get("email_id")) == email_id_int:
205
+ continue
206
+ kept.append(record)
207
+
208
+ kept.append({"code_hash": code_hash, "email_id": email_id_int, "ts": now})
209
+ data[key] = kept[-_OTP_REJECTION_MAX_PER_EMAIL:]
210
+ _write_otp_rejection_store(data)
211
+ return code_hash
212
+
213
+
214
+ def _page_excerpt(page, limit=240) -> str:
215
+ try:
216
+ text = page.locator("body").inner_text(timeout=1500)
217
+ text = re.sub(r"\s+", " ", text).strip()
218
+ return text[:limit]
219
+ except Exception:
220
+ return ""
221
+
222
+
223
+ def _normalize_trace_text(value, limit=240) -> str:
224
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
225
+ return text[:limit]
226
+
227
+
228
+ def _should_trace_oauth_network(url: str | None) -> bool:
229
+ lowered = (url or "").lower()
230
+ if "auth.openai.com" not in lowered and f"localhost:{CODEX_CALLBACK_PORT}" not in lowered:
231
+ return False
232
+ return any(keyword in lowered for keyword in _OAUTH_TRACE_KEYWORDS)
233
+
234
+
235
+ def _append_oauth_trace(trace_events: list[dict], *, kind: str, url: str, **fields) -> None:
236
+ if not _should_trace_oauth_network(url):
237
+ return
238
+
239
+ entry = {"kind": kind, "url": url}
240
+ for key, value in fields.items():
241
+ if value is None:
242
+ continue
243
+ if key in {"body_excerpt", "location", "failure", "resource_type"}:
244
+ entry[key] = _normalize_trace_text(value, limit=320)
245
+ else:
246
+ entry[key] = value
247
+
248
+ trace_events.append(entry)
249
+ if len(trace_events) > _OAUTH_TRACE_LIMIT:
250
+ del trace_events[:-_OAUTH_TRACE_LIMIT]
251
+
252
+
253
+ def _oauth_trace_has_login_challenge(trace_events: list[dict]) -> bool:
254
+ for entry in trace_events[-12:]:
255
+ for key in ("url", "location"):
256
+ value = str(entry.get(key) or "").lower()
257
+ if "/api/accounts/login" in value or "auth.openai.com/log-in" in value:
258
+ return True
259
+ return False
260
+
261
+
262
+ def _classify_oauth_failure(url: str | None, body_excerpt: str = ""):
263
+ lowered_url = (url or "").lower()
264
+ body = (body_excerpt or "").lower()
265
+
266
+ if "add-phone" in lowered_url:
267
+ return "add_phone", "需要手机号验证", False
268
+ if "verify you are human" in body or "captcha" in body:
269
+ return "human_verification", "命中人机验证", False
270
+ if "operation timed out" in body:
271
+ return "oauth_timeout", "OAuth 授权页操作超时", True
272
+ if "unsupported_country_region_territory" in body or "country, region, or territory not supported" in body:
273
+ return "unsupported_region", "OAuth 授权接口返回不支持当前地区/出口", True
274
+ if "choose-an-account" in lowered_url:
275
+ return "account_selection", "停留在账号选择页", True
276
+ if "no_valid_organizations" in body or "no valid organizations" in body:
277
+ return "no_valid_organizations", "OAuth 授权页未选中可用 organization", True
278
+ if "unable to load site" in body or "try again later" in body or "status page" in body:
279
+ return "site_unavailable", "站点暂时不可用或代理异常", True
280
+ if "email-verification" in lowered_url:
281
+ return "email_verification", "卡在邮箱验证码页", True
282
+ if "workspace" in lowered_url:
283
+ return "workspace_selection", "卡在 workspace 选择页", True
284
+ if "/auth/login" in lowered_url or "/log-in" in lowered_url or "log-in-or-create-account" in lowered_url:
285
+ return "login_state_lost", "登录态丢失或回到了登录页", True
286
+ return "auth_code_missing", f"未获取到 auth code(停留在 {lowered_url or 'unknown'})", True
287
+
288
+
289
  def _screenshot(page, name):
290
  SCREENSHOT_DIR.mkdir(exist_ok=True)
291
  page.screenshot(path=str(SCREENSHOT_DIR / name), full_page=True)
 
305
  return f"{CODEX_AUTH_URL}?{urllib.parse.urlencode(params)}"
306
 
307
 
308
+ def _is_workspace_selection_page(page) -> bool:
309
+ """Compatibility wrapper around the shared OAuth workspace detector."""
310
+ return _oauth_workspace._is_workspace_selection_page(page)
311
+
312
+
313
+ def _workspace_label_candidates(page):
314
+ """Compatibility wrapper around shared workspace label extraction."""
315
+ return _oauth_workspace._workspace_label_candidates(page)
316
+
317
+
318
+ def _select_team_workspace(page, workspace_name: str) -> bool:
319
+ """Compatibility wrapper around shared Team workspace selection."""
320
+ return _oauth_workspace._select_team_workspace(page, workspace_name)
321
+
322
+
323
  def _exchange_auth_code(auth_code, code_verifier, fallback_email=None):
324
  logger.info("[Codex] 获取到 auth code,交换 token...")
325
 
 
514
  return access_token
515
 
516
 
517
+ def _bundle_from_access_token(access_token, email, account_id):
518
+ claims = _parse_jwt_payload(access_token)
519
+ auth_claims = claims.get("https://api.openai.com/auth", {})
520
+ raw_plan = auth_claims.get("chatgpt_plan_type", "unknown")
521
+ resolved_email = claims.get("email", email or "")
522
+ resolved_account_id = account_id or auth_claims.get("chatgpt_account_id", "")
523
+ return {
524
+ "access_token": access_token,
525
+ "refresh_token": "",
526
+ "id_token": access_token,
527
+ "account_id": resolved_account_id,
528
+ "email": resolved_email,
529
+ "plan_type": normalize_plan_type(raw_plan),
530
+ "plan_type_raw": raw_plan,
531
+ "plan_supported": is_supported_plan(raw_plan),
532
+ "expired": time.time() + 3600,
533
+ }
534
+
535
+
536
+ def _bundle_from_session_token(session_token, email, account_id):
537
+ return _bundle_from_access_token(session_token, email, account_id)
538
+
539
+
540
+ def _oauth_result_from_bundle(bundle):
541
+ return {
542
+ "ok": True,
543
+ "bundle": bundle,
544
+ "error_type": None,
545
+ "error_detail": None,
546
+ "retryable": False,
547
+ }
548
+
549
+
550
+ def _inject_team_account_cookies(context, account_id: str | None) -> None:
551
+ account_id = (account_id or "").strip()
552
+ if not account_id:
553
+ return
554
+ try:
555
+ context.add_cookies(
556
+ [
557
+ {
558
+ "name": "_account",
559
+ "value": account_id,
560
+ "domain": "chatgpt.com",
561
+ "path": "/",
562
+ "secure": True,
563
+ "sameSite": "Lax",
564
+ },
565
+ {
566
+ "name": "_account",
567
+ "value": account_id,
568
+ "domain": "auth.openai.com",
569
+ "path": "/",
570
+ "secure": True,
571
+ "sameSite": "Lax",
572
+ },
573
+ ]
574
+ )
575
+ except Exception as exc:
576
+ logger.debug("[Codex-Fallback] 注入 Team account cookie 失败: %s", exc)
577
+
578
+
579
+ def _is_valid_team_access_token(access_token: str | None, account_id: str | None) -> bool:
580
+ if not access_token:
581
+ return False
582
+ claims = _parse_jwt_payload(access_token)
583
+ auth_claims = claims.get("https://api.openai.com/auth", {})
584
+ plan_type = normalize_plan_type(auth_claims.get("chatgpt_plan_type", ""))
585
+ token_account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
586
+ expected_account_id = str(account_id or "").strip()
587
+ if expected_account_id and token_account_id and token_account_id != expected_account_id:
588
+ return False
589
+ return plan_type == "team"
590
+
591
+
592
+ def _session_token_supports_team_quota(page, session_token, account_id):
593
+ try:
594
+ result = page.evaluate(
595
+ """async ([teamAccountId, token]) => {
596
+ try {
597
+ const resp = await fetch("/backend-api/wham/usage", {
598
+ credentials: "include",
599
+ headers: {
600
+ "Accept": "application/json",
601
+ "Authorization": `Bearer ${token || ""}`,
602
+ },
603
+ });
604
+ const text = await resp.text().catch(() => "");
605
+ return { status: resp.status, body: text };
606
+ } catch (e) {
607
+ return { status: 0, body: String(e && e.message || e) };
608
+ }
609
+ }""",
610
+ [account_id, session_token],
611
+ )
612
+ except Exception as exc:
613
+ logger.info("[Codex-Fallback] Team quota probe exception: %s", exc)
614
+ return False
615
+
616
+ status = result.get("status") if isinstance(result, dict) else None
617
+ if status == 200:
618
+ return True
619
+ logger.info(
620
+ "[Codex-Fallback] Team quota probe rejected session token: status=%s body=%s",
621
+ status,
622
+ (result or {}).get("body", "") if isinstance(result, dict) else "",
623
+ )
624
+ return False
625
+
626
+
627
+ def _fetch_team_session_bundle_from_context(
628
+ context,
629
+ email: str,
630
+ account_id: str | None,
631
+ *,
632
+ stage_label: str,
633
+ attempts: int = 3,
634
+ ) -> dict | None:
635
+ """Use an already logged-in ChatGPT session as Codex auth material."""
636
+
637
+ account_id = (account_id or "").strip()
638
+ page = None
639
+ try:
640
+ _inject_team_account_cookies(context, account_id)
641
+ page = context.new_page()
642
+ target_url = f"https://chatgpt.com/admin/workspace/{account_id}" if account_id else "https://chatgpt.com/"
643
+ page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
644
+ time.sleep(2)
645
+
646
+ for attempt in range(1, max(1, attempts) + 1):
647
+ session = page.evaluate(
648
+ """async () => {
649
+ try {
650
+ const resp = await fetch("/api/auth/session");
651
+ return await resp.json();
652
+ } catch (e) {
653
+ return { error: String(e && e.message || e) };
654
+ }
655
+ }"""
656
+ )
657
+ token = session.get("accessToken") if isinstance(session, dict) else None
658
+ if _is_valid_team_access_token(token, account_id):
659
+ bundle = _bundle_from_access_token(token, email, account_id)
660
+ logger.info(
661
+ "[Codex-Fallback] %s 获取到 ChatGPT Team session token: email=%s plan=%s",
662
+ stage_label,
663
+ bundle.get("email") or email,
664
+ bundle.get("plan_type"),
665
+ )
666
+ return bundle
667
+
668
+ if token and _session_token_supports_team_quota(page, token, account_id):
669
+ bundle = _bundle_from_session_token(token, email, account_id)
670
+ bundle["plan_type"] = "team"
671
+ bundle["plan_supported"] = True
672
+ logger.info(
673
+ "[Codex-Fallback] %s JWT claims 未切到 Team,但 wham/usage 已验证目标 Team 可用: email=%s account=%s",
674
+ stage_label,
675
+ bundle.get("email") or email,
676
+ account_id,
677
+ )
678
+ return bundle
679
+
680
+ if token:
681
+ claims = _parse_jwt_payload(token)
682
+ auth_claims = claims.get("https://api.openai.com/auth", {})
683
+ logger.info(
684
+ "[Codex-Fallback] %s session token 不匹配 Team workspace (attempt %d/%d): account=%s plan=%s",
685
+ stage_label,
686
+ attempt,
687
+ attempts,
688
+ auth_claims.get("chatgpt_account_id"),
689
+ auth_claims.get("chatgpt_plan_type"),
690
+ )
691
+
692
+ if attempt < attempts:
693
+ page.reload(wait_until="domcontentloaded", timeout=30000)
694
+ time.sleep(1)
695
+ return None
696
+ except Exception as exc:
697
+ logger.warning("[Codex-Fallback] %s session bundle 提取失败: %s", stage_label, exc)
698
+ return None
699
+ finally:
700
+ if page is not None:
701
+ try:
702
+ page.close()
703
+ except Exception:
704
+ pass
705
+
706
+
707
  def fetch_personal_uuid(access_token):
708
  """Round 11 V8 — POST https://chatgpt.com/backend-api/accounts/personal idempotent getOrCreate.
709
 
 
1058
  return False
1059
 
1060
 
1061
+ def _select_existing_api_organization(page) -> bool:
1062
+ trigger_selectors = (
1063
+ 'button:has-text("New organization")',
1064
+ '[role="button"]:has-text("New organization")',
1065
+ 'button:has-text("新组织")',
1066
+ '[role="button"]:has-text("新组织")',
1067
+ )
1068
+
1069
+ for selector in trigger_selectors:
1070
+ try:
1071
+ trigger = page.locator(selector).first
1072
+ if not trigger.is_visible(timeout=800):
1073
+ continue
1074
+
1075
+ trigger.click()
1076
+ time.sleep(0.5)
1077
+ for option in page.locator('[role="option"]').all():
1078
+ try:
1079
+ label = option.inner_text(timeout=500).strip()
1080
+ except Exception:
1081
+ continue
1082
+ lowered = label.lower()
1083
+ if not label or "new organization" in lowered or "新组织" in label:
1084
+ continue
1085
+ option.click()
1086
+ logger.info("[Codex] 已切换到已有 API organization: %s", label)
1087
+ time.sleep(0.5)
1088
+ return True
1089
+
1090
+ logger.warning("[Codex] API organization 下拉中未找到可用的已有组织,保留当前选择")
1091
+ return False
1092
+ except Exception:
1093
+ continue
1094
+
1095
+ return False
1096
+
1097
+
1098
+ _CHOOSE_ACCOUNT_PAGE_HINTS = (
1099
+ "choose an account",
1100
+ "select an account",
1101
+ "continue as",
1102
+ "choose a chatgpt account",
1103
+ "选择一个账号",
1104
+ "选择账号",
1105
+ )
1106
+ _CHOOSE_ACCOUNT_IGNORE_LABELS = {
1107
+ "choose an account",
1108
+ "select an account",
1109
+ "choose a chatgpt account",
1110
+ "continue as",
1111
+ "continue",
1112
+ "继续",
1113
+ "allow",
1114
+ "log in",
1115
+ "cancel",
1116
+ "back",
1117
+ "terms of use",
1118
+ "privacy policy",
1119
+ }
1120
+ _CHOOSE_ACCOUNT_IGNORE_SUBSTRINGS = (
1121
+ "terms of use",
1122
+ "privacy policy",
1123
+ "continue with",
1124
+ )
1125
+
1126
+
1127
+ def _is_choose_account_page(page) -> bool:
1128
+ url = (getattr(page, "url", "") or "").lower()
1129
+ if "choose-an-account" in url:
1130
+ return True
1131
+
1132
+ try:
1133
+ body = page.locator("body").inner_text(timeout=1200).lower()
1134
+ except Exception:
1135
+ body = ""
1136
+
1137
+ return any(hint in body for hint in _CHOOSE_ACCOUNT_PAGE_HINTS)
1138
+
1139
+
1140
+ def _is_choose_account_ignored_label(text: str) -> bool:
1141
+ lowered = str(text or "").strip().lower()
1142
+ if lowered in _CHOOSE_ACCOUNT_IGNORE_LABELS:
1143
+ return True
1144
+ return any(token in lowered for token in _CHOOSE_ACCOUNT_IGNORE_SUBSTRINGS)
1145
+
1146
+
1147
+ def _click_oauth_locator(loc) -> bool:
1148
+ try:
1149
+ loc.click(timeout=3000)
1150
+ return True
1151
+ except Exception:
1152
+ try:
1153
+ loc.click(force=True)
1154
+ return True
1155
+ except Exception:
1156
+ return False
1157
+
1158
+
1159
+ def _choose_account_label_candidates(page):
1160
+ if not _is_choose_account_page(page):
1161
+ return []
1162
+
1163
+ selectors = (
1164
+ "button",
1165
+ "a",
1166
+ '[role="button"]',
1167
+ '[role="option"]',
1168
+ '[aria-selected="true"]',
1169
+ '[aria-selected="false"]',
1170
+ "[data-state]",
1171
+ "li",
1172
+ "label",
1173
+ "div",
1174
+ )
1175
+ seen = set()
1176
+ candidates = []
1177
+ for selector in selectors:
1178
+ try:
1179
+ for loc in page.locator(selector).all():
1180
+ try:
1181
+ if not loc.is_visible(timeout=100):
1182
+ continue
1183
+ text = re.sub(r"\s+", " ", loc.inner_text(timeout=200)).strip()
1184
+ except Exception:
1185
+ continue
1186
+ lowered = text.lower()
1187
+ if not text or lowered in seen or len(text) > 160 or _is_choose_account_ignored_label(lowered):
1188
+ continue
1189
+ seen.add(lowered)
1190
+ candidates.append((text, loc))
1191
+ except Exception:
1192
+ continue
1193
+ return candidates
1194
+
1195
+
1196
+ def _select_oauth_account(page, email: str | None) -> bool:
1197
+ preferred_email = str(email or "").strip().lower()
1198
+ if not preferred_email:
1199
+ return False
1200
+ local_part = preferred_email.split("@", 1)[0] if "@" in preferred_email else preferred_email
1201
+
1202
+ for text, loc in _choose_account_label_candidates(page):
1203
+ lowered = text.strip().lower()
1204
+ if preferred_email not in lowered and (not local_part or local_part not in lowered):
1205
+ continue
1206
+ if not _click_oauth_locator(loc):
1207
+ continue
1208
+ logger.info("[Codex] 选择 OAuth 账号: %s", text)
1209
+ time.sleep(2)
1210
+ try:
1211
+ confirm = page.locator(
1212
+ 'button:has-text("Continue"), button:has-text("继续"), button:has-text("Allow")'
1213
+ ).first
1214
+ if confirm.is_visible(timeout=800):
1215
+ confirm.click()
1216
+ logger.info("[Codex] 已确认 OAuth 账号选择")
1217
+ time.sleep(3)
1218
+ except Exception:
1219
+ pass
1220
+ return True
1221
+
1222
+ for selector in (
1223
+ f'text="{preferred_email}"',
1224
+ f"text=/{re.escape(preferred_email)}/i",
1225
+ ):
1226
+ try:
1227
+ loc = page.locator(selector).first
1228
+ if not loc.is_visible(timeout=500):
1229
+ continue
1230
+ if not _click_oauth_locator(loc):
1231
+ continue
1232
+ logger.info("[Codex] 选择 OAuth 账号: %s", preferred_email)
1233
+ time.sleep(2)
1234
+ return True
1235
+ except Exception:
1236
+ continue
1237
+
1238
+ return False
1239
+
1240
+
1241
+ def _select_choose_account(page, email: str | None) -> bool:
1242
+ return _select_oauth_account(page, email)
1243
+
1244
+
1245
+ def _oauth_page_has_terminal_error(page) -> bool:
1246
+ body_excerpt = _page_excerpt(page, limit=400).lower()
1247
+ return (
1248
+ "no_valid_organizations" in body_excerpt
1249
+ or "an error occurred during authentication" in body_excerpt
1250
+ or "operation timed out" in body_excerpt
1251
+ or "unsupported_country_region_territory" in body_excerpt
1252
+ or "country, region, or territory not supported" in body_excerpt
1253
+ )
1254
+
1255
+
1256
+ def _recover_oauth_timeout_page(page) -> bool:
1257
+ body_excerpt = _page_excerpt(page, limit=400).lower()
1258
+ if "operation timed out" not in body_excerpt:
1259
+ return False
1260
+
1261
+ for selector in (
1262
+ 'button:has-text("Try again")',
1263
+ '[role="button"]:has-text("Try again")',
1264
+ 'a:has-text("Try again")',
1265
+ 'button:has-text("Retry")',
1266
+ ):
1267
+ try:
1268
+ btn = page.locator(selector).first
1269
+ if not btn.is_visible(timeout=800):
1270
+ continue
1271
+ logger.info("[Codex] 命中 OAuth 超时错误页,尝试点击 Try again 继续当前流程...")
1272
+ btn.click()
1273
+ time.sleep(3)
1274
+ return True
1275
+ except Exception:
1276
+ continue
1277
+
1278
+ return False
1279
+
1280
+
1281
+ def _recover_oauth_no_valid_organizations_page(page) -> bool:
1282
+ body_excerpt = _page_excerpt(page, limit=500).lower()
1283
+ if "no_valid_organizations" not in body_excerpt and "no valid organizations" not in body_excerpt:
1284
+ return False
1285
+
1286
+ for selector in (
1287
+ 'button:has-text("Try again")',
1288
+ '[role="button"]:has-text("Try again")',
1289
+ 'a:has-text("Try again")',
1290
+ 'button:has-text("Retry")',
1291
+ '[role="button"]:has-text("Retry")',
1292
+ ):
1293
+ try:
1294
+ btn = page.locator(selector).first
1295
+ if not btn.is_visible(timeout=800):
1296
+ continue
1297
+ logger.info("[Codex] 命中 no_valid_organizations 错误页,尝试点击 Try again 继续当前 OAuth 流程...")
1298
+ btn.click()
1299
+ time.sleep(4)
1300
+ return True
1301
+ except Exception:
1302
+ continue
1303
+
1304
+ return False
1305
+
1306
+
1307
+ def _is_oauth_login_challenge_page(page, trace_events=None) -> bool:
1308
+ try:
1309
+ url = (page.url or "").lower()
1310
+ except Exception:
1311
+ url = ""
1312
+ if "/api/accounts/login" in url or "auth.openai.com/log-in" in url:
1313
+ return True
1314
+ return bool(trace_events and _oauth_trace_has_login_challenge(trace_events) and "/oauth" not in url)
1315
+
1316
+
1317
+ def _is_login_page_url(url: str | None) -> bool:
1318
+ url = (url or "").lower()
1319
+ return "/api/accounts/login" in url or "auth.openai.com/log-in" in url or "log-in-or-create-account" in url
1320
+
1321
+
1322
+ def _wait_for_oauth_challenge_progress(page, timeout=20) -> bool:
1323
+ deadline = time.time() + timeout
1324
+ last_url = ""
1325
+ while time.time() < deadline:
1326
+ try:
1327
+ url = page.url or ""
1328
+ last_url = url
1329
+ except Exception:
1330
+ url = ""
1331
+
1332
+ lower_url = url.lower()
1333
+ if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in lower_url:
1334
+ return True
1335
+ if (
1336
+ lower_url
1337
+ and "/api/accounts/login" not in lower_url
1338
+ and "auth.openai.com/log-in" not in lower_url
1339
+ and "email-verification" not in lower_url
1340
+ ):
1341
+ if any(marker in lower_url for marker in ("/oauth", "consent", "organization", "choose-an-account")):
1342
+ return True
1343
+
1344
+ try:
1345
+ if _is_otp_input_visible(page, timeout=300):
1346
+ return False
1347
+ except Exception:
1348
+ pass
1349
+
1350
+ time.sleep(0.5)
1351
+
1352
+ logger.info("[Codex] OAuth login challenge progress wait timed out, last_url=%s", last_url)
1353
+ return False
1354
+
1355
+
1356
+ def _complete_oauth_login_challenge(page, email, password, mail_client, min_email_id, used_email_ids) -> bool:
1357
+ acted = False
1358
+
1359
+ try:
1360
+ for attempt in range(2):
1361
+ email_input = page.locator('input[name="email"], input[id="email-input"], input[id="email"]').first
1362
+ if not email_input.is_visible(timeout=3000):
1363
+ break
1364
+ email_input.fill(email)
1365
+ acted = True
1366
+ time.sleep(0.5)
1367
+ _click_primary_auth_button(page, email_input, ["Continue", "继续"])
1368
+ time.sleep(3)
1369
+ if not _is_google_redirect(page):
1370
+ break
1371
+ _screenshot(page, f"codex_login_challenge_google_email_{attempt + 1}.png")
1372
+ logger.warning("[Codex] OAuth login challenge email step redirected to Google; retrying")
1373
+ page.go_back(wait_until="domcontentloaded", timeout=30000)
1374
+ time.sleep(2)
1375
+ except Exception:
1376
+ pass
1377
+
1378
+ try:
1379
+ for attempt in range(2):
1380
+ pwd_input = page.locator('input[name="password"], input[type="password"]').first
1381
+ if not pwd_input.is_visible(timeout=3000):
1382
+ break
1383
+ acted = True
1384
+ if password:
1385
+ pwd_input.fill(password)
1386
+ time.sleep(0.5)
1387
+ _click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"])
1388
+ else:
1389
+ otp_btn = page.locator(
1390
+ 'button:has-text("一次性验证码"), button:has-text("one-time"), button:has-text("email login")'
1391
+ ).first
1392
+ if otp_btn.is_visible(timeout=3000):
1393
+ otp_btn.click()
1394
+ else:
1395
+ _click_primary_auth_button(page, pwd_input, ["Continue", "继续", "Log in"])
1396
+ time.sleep(5)
1397
+ if not _is_google_redirect(page):
1398
+ break
1399
+ _screenshot(page, f"codex_login_challenge_google_password_{attempt + 1}.png")
1400
+ logger.warning("[Codex] OAuth login challenge password step redirected to Google; retrying")
1401
+ page.go_back(wait_until="domcontentloaded", timeout=30000)
1402
+ time.sleep(2)
1403
+ except Exception:
1404
+ pass
1405
+
1406
+ try:
1407
+ if _is_otp_input_visible(page, timeout=3000):
1408
+ if not mail_client:
1409
+ logger.warning("[Codex] OAuth login challenge needs OTP but no mail_client is available")
1410
+ return acted
1411
+ submit_status = _resolve_email_verification(
1412
+ page,
1413
+ mail_client=mail_client,
1414
+ email=email,
1415
+ after_email_id=min_email_id,
1416
+ used_email_ids=used_email_ids,
1417
+ wait_log="[Codex] OAuth login challenge needs OTP, waiting for emailId > %d",
1418
+ wait_timeout=90,
1419
+ )
1420
+ if submit_status != "no_code":
1421
+ acted = True
1422
+ _wait_for_oauth_challenge_progress(page, timeout=20)
1423
+ except Exception:
1424
+ pass
1425
+
1426
+ if "about-you" in (page.url or ""):
1427
+ _complete_oauth_about_you(page)
1428
+ acted = True
1429
+
1430
+ return acted
1431
+
1432
+
1433
  def _is_google_redirect(page):
1434
  url = (page.url or "").lower()
1435
  if "accounts.google.com" in url:
 
1446
  'input[name="code"], input[inputmode="numeric"], input[autocomplete="one-time-code"], '
1447
  'input[placeholder*="验证码"], input[placeholder*="code" i]'
1448
  )
1449
+ _OTP_SINGLE_INPUT_SELECTORS = 'input[maxlength="1"], input[data-input-otp], input[aria-label*="digit" i]'
1450
  _OTP_INVALID_HINTS = (
1451
  "invalid code",
1452
  "incorrect code",
 
1478
  return None
1479
 
1480
 
1481
+ def _is_otp_progress_url(url: str | None) -> bool:
1482
+ url = (url or "").lower()
1483
+ if not url:
1484
+ return False
1485
+ if f"localhost:{CODEX_CALLBACK_PORT}/auth/callback" in url:
1486
+ return True
1487
+ return any(
1488
+ marker in url
1489
+ for marker in (
1490
+ "consent",
1491
+ "organization",
1492
+ "choose-an-account",
1493
+ "about-you",
1494
+ "/oauth/oauth2/auth",
1495
+ "/sign-in-with-chatgpt/codex",
1496
+ )
1497
+ ) and "email-verification" not in url
1498
+
1499
+
1500
  def _wait_for_otp_submit_result(page, timeout=12):
1501
  """
1502
  等待验证码提交结果:
 
1507
  deadline = time.time() + timeout
1508
 
1509
  while time.time() < deadline:
1510
+ try:
1511
+ if _is_otp_progress_url(page.url):
1512
+ return "accepted", None
1513
+ except Exception:
1514
+ pass
1515
  err = _detect_otp_error(page)
1516
  if err:
1517
  return "invalid", err
 
1519
  return "accepted", None
1520
  time.sleep(0.5)
1521
 
1522
+ try:
1523
+ if _is_otp_progress_url(page.url):
1524
+ return "accepted", None
1525
+ except Exception:
1526
+ pass
1527
  err = _detect_otp_error(page)
1528
  if err:
1529
  return "invalid", err
1530
  return "pending", None
1531
 
1532
 
1533
+ def _visible_otp_slot_inputs(page, timeout=300):
1534
+ try:
1535
+ candidates = page.locator(_OTP_SINGLE_INPUT_SELECTORS).all()
1536
+ except Exception:
1537
+ return []
1538
+
1539
+ visible = []
1540
+ for loc in candidates:
1541
+ try:
1542
+ if loc.is_visible(timeout=timeout):
1543
+ visible.append(loc)
1544
+ except Exception:
1545
+ continue
1546
+ return visible
1547
+
1548
+
1549
+ def _fill_otp_code(page, code: str) -> bool:
1550
+ value = str(code or "").strip()
1551
+ if not value:
1552
+ return False
1553
+
1554
+ slot_inputs = _visible_otp_slot_inputs(page, timeout=200)
1555
+ if len(slot_inputs) >= min(4, len(value)):
1556
+ logger.info("[Codex] 检测到 %d 个单字符验证码输入框", len(slot_inputs))
1557
+ for index, char in enumerate(value):
1558
+ if index >= len(slot_inputs):
1559
+ break
1560
+ loc = slot_inputs[index]
1561
+ try:
1562
+ loc.click(force=True)
1563
+ except Exception:
1564
+ pass
1565
+ try:
1566
+ loc.fill("")
1567
+ except Exception:
1568
+ pass
1569
+ try:
1570
+ loc.fill(char)
1571
+ except Exception:
1572
+ try:
1573
+ loc.type(char, delay=50)
1574
+ except Exception:
1575
+ try:
1576
+ page.keyboard.type(char, delay=50)
1577
+ except Exception:
1578
+ return False
1579
+ time.sleep(0.1)
1580
+ return True
1581
+
1582
+ try:
1583
+ otp_input = page.locator(_OTP_INPUT_SELECTORS).first
1584
+ if otp_input.is_visible(timeout=2000):
1585
+ otp_input.fill(value)
1586
+ return True
1587
+ except Exception:
1588
+ pass
1589
+
1590
+ return False
1591
+
1592
+
1593
+ def _click_otp_submit_button(page) -> bool:
1594
+ for selector in (
1595
+ 'button[type="submit"]',
1596
+ 'button:has-text("Continue")',
1597
+ 'button:has-text("继续")',
1598
+ 'button:has-text("Verify")',
1599
+ ):
1600
+ try:
1601
+ button = page.locator(selector).first
1602
+ if button.is_visible(timeout=800):
1603
+ button.click()
1604
+ return True
1605
+ except Exception:
1606
+ continue
1607
+ return False
1608
+
1609
+
1610
+ def _poll_mail_verification_code(
1611
+ mail_client,
1612
+ email: str,
1613
+ *,
1614
+ after_email_id: int,
1615
+ used_email_ids: set[int],
1616
+ timeout: int = 120,
1617
+ require_sender: bool = False,
1618
+ ):
1619
+ deadline = time.time() + timeout
1620
+ while time.time() < deadline:
1621
+ for em in mail_client.search_emails_by_recipient(email, size=5):
1622
+ email_id = em.get("emailId", 0)
1623
+ if email_id <= after_email_id or email_id in used_email_ids:
1624
+ continue
1625
+
1626
+ if require_sender:
1627
+ sender = (em.get("sendEmail") or "").lower()
1628
+ if "openai" not in sender and "chatgpt" not in sender:
1629
+ continue
1630
+
1631
+ subject = (em.get("subject") or "").lower()
1632
+ if "invited" in subject or "invitation" in subject:
1633
+ continue
1634
+
1635
+ code = mail_client.extract_verification_code(em)
1636
+ if code:
1637
+ return code, email_id
1638
+ time.sleep(3)
1639
+
1640
+ return None, 0
1641
+
1642
+
1643
+ def _resolve_email_verification(
1644
+ page,
1645
+ *,
1646
+ mail_client,
1647
+ email: str,
1648
+ after_email_id: int,
1649
+ used_email_ids: set[int],
1650
+ wait_log: str,
1651
+ require_sender: bool = False,
1652
+ wait_timeout: int = 120,
1653
+ submit_timeout: int = 15,
1654
+ ) -> str:
1655
+ logger.info(wait_log, after_email_id)
1656
+
1657
+ otp, otp_email_id = _poll_mail_verification_code(
1658
+ mail_client,
1659
+ email,
1660
+ after_email_id=after_email_id,
1661
+ used_email_ids=used_email_ids,
1662
+ timeout=wait_timeout,
1663
+ require_sender=require_sender,
1664
+ )
1665
+ if not otp:
1666
+ logger.warning("[Codex] 未获取到验证码")
1667
+ return "no_code"
1668
+
1669
+ logger.info("[Codex] 获取到验���码: %s", otp)
1670
+
1671
+ for submit_attempt in range(1, 3):
1672
+ current_url = (getattr(page, "url", "") or "").lower()
1673
+ if current_url and "email-verification" not in current_url:
1674
+ used_email_ids.add(otp_email_id)
1675
+ return "accepted"
1676
+
1677
+ if not _fill_otp_code(page, otp):
1678
+ if current_url and "email-verification" not in current_url:
1679
+ used_email_ids.add(otp_email_id)
1680
+ return "accepted"
1681
+ if submit_attempt < 2:
1682
+ logger.warning("[Codex] 验证码输入框不可用,准备重试第 %d/2 次", submit_attempt + 1)
1683
+ time.sleep(2)
1684
+ continue
1685
+ used_email_ids.add(otp_email_id)
1686
+ logger.warning("[Codex] 验证码输入框不可用,标记并跳过邮件 %s", otp_email_id)
1687
+ return "input_unavailable"
1688
+
1689
+ time.sleep(0.5)
1690
+ _click_otp_submit_button(page)
1691
+ logger.info("[Codex] 已输入验证码: %s", otp)
1692
+
1693
+ submit_status, submit_detail = _wait_for_otp_submit_result(page, timeout=submit_timeout)
1694
+ if submit_status == "accepted":
1695
+ used_email_ids.add(otp_email_id)
1696
+ return "accepted"
1697
+ if submit_status == "invalid":
1698
+ used_email_ids.add(otp_email_id)
1699
+ detail_suffix = f",命中提示: {submit_detail}" if submit_detail else ""
1700
+ logger.warning(
1701
+ "[Codex] 验证码邮件 %s(code=%s)被页面判定无效%s,标记并跳过该邮件",
1702
+ otp_email_id,
1703
+ otp,
1704
+ detail_suffix,
1705
+ )
1706
+ return "invalid"
1707
+
1708
+ if submit_attempt < 2:
1709
+ logger.warning(
1710
+ "[Codex] 验证码邮件 %s(code=%s)提交后未确认成功,准备重试第 %d/2 次",
1711
+ otp_email_id,
1712
+ otp,
1713
+ submit_attempt + 1,
1714
+ )
1715
+ time.sleep(2)
1716
+ else:
1717
+ used_email_ids.add(otp_email_id)
1718
+ logger.warning(
1719
+ "[Codex] 验证码邮件 %s(code=%s)提交后仍未确认成功,标记并跳过该邮件",
1720
+ otp_email_id,
1721
+ otp,
1722
+ )
1723
+ return "pending"
1724
+
1725
+ return "pending"
1726
+
1727
+
1728
  def _typewrite_credential(page, locator, value, *, delay_ms=50, post_sleep=1.0):
1729
  """逐字符 keyboard.type 填入 credential,触发 React onChange 事件链.
1730
 
 
1862
 
1863
  # === 可能 OTP ===
1864
  try:
1865
+ if _is_otp_input_visible(page, timeout=5000) and mail_client:
1866
+ _resolve_email_verification(
1867
+ page,
1868
+ mail_client=mail_client,
1869
+ email=email,
1870
+ after_email_id=fresh_email_id_before,
1871
+ used_email_ids=used_email_ids,
1872
+ wait_log="[Codex] fresh re-login: 需要 OTP,等待 emailId > %d 的新邮件...",
1873
+ require_sender=True,
1874
+ )
1875
+ time.sleep(5)
1876
+ _screenshot(page, "codex_relogin_04_after_otp.png")
1877
+ elif _is_otp_input_visible(page, timeout=500):
1878
+ logger.warning("[Codex] fresh re-login: 需要 OTP 但无 mail_client")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1879
  except Exception:
1880
  pass
1881
 
 
2020
  password,
2021
  mail_client=None,
2022
  *,
2023
+ return_result=False,
2024
  use_personal=False,
2025
  chatgpt_session_token=None,
2026
  prefetched_personal_uuid=None,
2027
+ pre_signed_in_cookies: list | None = None,
2028
  signup_profile: SignupProfile | None = None,
2029
  playwright_proxy_url: str | None = None,
2030
  ):
 
2039
  prefetched_personal_uuid: Round 11 V8 — accounts.json 持久化的 personal_workspace_id;
2040
  非空时直接拼到 OAuth `auth_url` 的 allowed_workspace_id 参数,
2041
  省一次 silent step-0 内的 POST /accounts/personal。
2042
+ pre_signed_in_cookies: 直接注册后传入的已登录 chatgpt.com/auth.openai.com cookies。
2043
+ 提供时优先尝试 ChatGPT session fallback,避免新号二次 OAuth 登录挑战。
2044
  signup_profile: 注册 about-you 使用过的身份快照;OAuth about-you 必须复用它。
2045
  返回 auth bundle: {access_token, refresh_token, id_token, account_id, email, plan_type,
2046
  personal_workspace_id}
2047
+ return_result=True 时返回 {ok, bundle, error_type, error_detail, retryable}。
2048
 
2049
  Round 11 五轮 Option A — 两阶段 personal OAuth:
2050
  阶段 1(快路径):有 chatgpt_session_token → silent step-0 双域注入 + NextAuth refresh,
 
2118
  browser = p.chromium.launch(**launch_kwargs)
2119
  context = browser.new_context(**get_playwright_context_options())
2120
 
2121
+ if pre_signed_in_cookies:
2122
+ try:
2123
+ context.add_cookies(pre_signed_in_cookies)
2124
+ logger.info(
2125
+ "[Codex] 已注入 %d 个 session cookie,优先尝试 ChatGPT session fallback",
2126
+ len(pre_signed_in_cookies),
2127
+ )
2128
+ except Exception as exc:
2129
+ logger.warning("[Codex] 注入 session cookie 失败,回退到完整登录: %s", exc)
2130
+ pre_signed_in_cookies = None
2131
+
2132
+ if pre_signed_in_cookies and not use_personal:
2133
+ session_fallback_bundle = _fetch_team_session_bundle_from_context(
2134
+ context,
2135
+ email,
2136
+ chatgpt_account_id,
2137
+ stage_label="pre-oauth",
2138
+ )
2139
+ if session_fallback_bundle:
2140
+ close_playwright_objects(context=context, browser=browser, logger=logger, label="codex-session-fallback")
2141
+ if return_result:
2142
+ return _oauth_result_from_bundle(session_fallback_bundle)
2143
+ return session_fallback_bundle
2144
+
2145
  # Round 11 四轮 — Personal 模式 session_token 注入(silent step-0):
2146
  # 实测刚踢出 Team 的新号在 OAuth /log-in 页 fill email 后 Continue 按钮变灰禁用,
2147
  # login flow 永远卡在 email 步骤,bundle=None。根因疑似 Playwright fill() 的
 
2394
 
2395
  # 可能需要邮箱验证码
2396
  try:
2397
+ if mail_client and _is_otp_input_visible(_page, timeout=5000):
2398
+ _resolve_email_verification(
2399
+ _page,
2400
+ mail_client=mail_client,
2401
+ email=email,
2402
+ after_email_id=_email_id_before_login,
2403
+ used_email_ids=_used_email_ids,
2404
+ wait_log="[Codex] ChatGPT 登录需要验证码,等待 emailId > %d ���新邮件...",
2405
+ )
2406
+ time.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2407
  except Exception:
2408
  pass
2409
 
 
2675
 
2676
  _screenshot(page, f"codex_04_step{step + 1}_before.png")
2677
 
2678
+ try:
2679
+ if _is_choose_account_page(page):
2680
+ _screenshot(page, f"codex_04_choose_account_{step + 1}_before.png")
2681
+ logger.info("[Codex] 检测到账号选择页 (step %d),尝试选择: %s", step + 1, email)
2682
+ selected = _select_oauth_account(page, email)
2683
+ _screenshot(page, f"codex_04_choose_account_{step + 1}_after.png")
2684
+ if selected and not _is_choose_account_page(page):
2685
+ continue
2686
+ if not selected:
2687
+ logger.warning("[Codex] 无法自动选择 OAuth 账号: %s (step %d)", email, step + 1)
2688
+ except Exception:
2689
+ pass
2690
+
2691
  # 在任何页面中,如果有 workspace/组织选择,先选 Team(personal 模式下选个人)
2692
  try:
2693
  # Round 11 — 与 cnitlrt/AutoTeam upstream codex_auth.py:772-815 对齐:
 
2895
 
2896
  # 处理邮箱验证码页面(可能在 consent 流程中出现)
2897
  try:
2898
+ if _is_otp_input_visible(page, timeout=2000) and mail_client:
2899
+ submit_status = _resolve_email_verification(
2900
+ page,
2901
+ mail_client=mail_client,
2902
+ email=email,
2903
+ after_email_id=_email_id_before_login,
2904
+ used_email_ids=_used_email_ids,
2905
+ wait_log=f"[Codex] 需要邮箱验证码 (step {step + 1}),等待 emailId > %d 的新邮件...",
2906
+ require_sender=True,
2907
  )
2908
+ if submit_status == "accepted":
2909
+ logger.info("[Codex] 验证码页已退出,继续后续授权流程")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2910
  continue
2911
  except Exception:
2912
  pass
 
3252
 
3253
  if personal_uuid:
3254
  stage2_bundle["personal_workspace_id"] = personal_uuid
3255
+ if return_result:
3256
+ return _oauth_result_from_bundle(stage2_bundle)
3257
  return stage2_bundle
3258
 
3259
  # 阶段 1 通过(非 personal 或 plan == free) — 走原退出路径
 
3261
 
3262
  if not auth_code:
3263
  logger.error("[Codex] OAuth 登录失败: 未获取到 authorization code")
3264
+ if return_result:
3265
+ return {
3266
+ "ok": False,
3267
+ "bundle": None,
3268
+ "error_type": "auth_code_missing",
3269
+ "error_detail": "未获取到 authorization code",
3270
+ "retryable": True,
3271
+ }
3272
  return None
3273
 
3274
  if not stage1_bundle:
3275
+ if return_result:
3276
+ return {
3277
+ "ok": False,
3278
+ "bundle": None,
3279
+ "error_type": "token_exchange_failed",
3280
+ "error_detail": "Token 交换失败",
3281
+ "retryable": True,
3282
+ }
3283
  return None
3284
 
3285
  # Personal 模式强校验 plan_type:当子号还挂在 Team workspace(OpenAI 后端 kick 同步延迟 /
 
3303
  plan or "unknown",
3304
  stage1_bundle.get("account_id"),
3305
  )
3306
+ if return_result:
3307
+ return {
3308
+ "ok": False,
3309
+ "bundle": None,
3310
+ "error_type": "non_free_plan",
3311
+ "error_detail": f"personal OAuth plan={plan or 'unknown'}",
3312
+ "retryable": True,
3313
+ }
3314
  return None
3315
 
3316
  if personal_uuid:
3317
  stage1_bundle["personal_workspace_id"] = personal_uuid
3318
+ if return_result:
3319
+ return _oauth_result_from_bundle(stage1_bundle)
3320
  return stage1_bundle
3321
 
3322
 
 
3617
  if step == "password_required":
3618
  if self._switch_password_to_otp():
3619
  continue
3620
+ if self._auto_fill_password():
3621
+ continue
3622
  return {
3623
  "step": "unsupported_password",
3624
  "detail": "主号 Codex 当前停留在密码页,且未找到一次性验证码入口",
 
3711
  self.page = None
3712
 
3713
 
3714
+ class MainCodexLoginFlow(SessionCodexAuthFlow):
3715
  def __init__(self):
3716
+ from autoteam.admin_state import get_admin_password
3717
+
3718
  super().__init__(
3719
  email=get_admin_email(),
3720
  session_token=get_admin_session_token(),
3721
  account_id=get_chatgpt_account_id(),
3722
  workspace_name=get_chatgpt_workspace_name(),
3723
+ password=get_admin_password(),
3724
  password_callback=None,
3725
  auth_file_callback=save_main_auth_file,
3726
  )
3727
 
3728
+ def complete(self):
3729
+ info = super().complete()
3730
+ return {
3731
+ "email": info.get("email"),
3732
+ "auth_file": info.get("auth_file"),
3733
+ "plan_type": info.get("plan_type"),
3734
+ }
3735
+
3736
+
3737
+ class MainCodexSyncFlow(MainCodexLoginFlow):
3738
  def complete(self):
3739
  info = super().complete()
3740
  from autoteam.sync_targets import sync_main_codex_to_configured_targets as sync_main_codex_to_cpa
3741
+
3742
  sync_main_codex_to_cpa(info["auth_file"])
3743
  return {
3744
  "email": info.get("email"),
src/autoteam/cpa_sync.py CHANGED
@@ -423,6 +423,7 @@ def sync_from_cpa():
423
  save_accounts,
424
  update_account,
425
  )
 
426
 
427
  AUTH_DIR.mkdir(exist_ok=True)
428
 
@@ -572,9 +573,16 @@ def sync_from_cpa():
572
 
573
  acc = find_account(accounts, email)
574
  resolved_path = str(normalized_path.resolve())
 
575
  if acc:
 
576
  if acc.get("auth_file") != resolved_path:
577
  acc["auth_file"] = resolved_path
 
 
 
 
 
578
  changed_accounts = True
579
  updated_accounts += 1
580
  else:
@@ -582,7 +590,7 @@ def sync_from_cpa():
582
  # 紧接着 update_account → STANDBY 一次性带上 auth_file. 这样状态机能落每条
583
  # state_log.jsonl + F2 SSE 推送, 不再静默直 append 旁路.
584
  try:
585
- add_account(email, "", cloudmail_account_id=None)
586
  update_account(
587
  email,
588
  status=STATUS_STANDBY,
@@ -602,6 +610,7 @@ def sync_from_cpa():
602
  "email": email,
603
  "password": "",
604
  "cloudmail_account_id": None,
 
605
  "status": STATUS_STANDBY,
606
  "auth_file": resolved_path,
607
  "quota_exhausted_at": None,
@@ -682,6 +691,9 @@ def sync_to_cpa():
682
  synced_active = 0
683
  synced_personal = 0
684
  disabled_skipped = 0
 
 
 
685
  for acc in accounts:
686
  if is_account_disabled(acc):
687
  disabled_skipped += 1
@@ -695,6 +707,19 @@ def sync_to_cpa():
695
  path = Path(auth_path)
696
  if not path.exists():
697
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  _refresh_account_proxy_url_for_upload(acc, path)
699
  files_to_sync[path.name] = path
700
  if status == STATUS_ACTIVE:
@@ -798,6 +823,11 @@ def sync_to_cpa():
798
  "synced_active": synced_active,
799
  "synced_personal": synced_personal,
800
  "disabled_skipped": disabled_skipped,
 
 
 
 
 
801
  "local_duplicates_deleted": local_duplicates_deleted,
802
  "delete_guard": {
803
  "allow_remote_delete": allow_remote_delete,
 
423
  save_accounts,
424
  update_account,
425
  )
426
+ from autoteam.mail import infer_mail_provider_from_email
427
 
428
  AUTH_DIR.mkdir(exist_ok=True)
429
 
 
573
 
574
  acc = find_account(accounts, email)
575
  resolved_path = str(normalized_path.resolve())
576
+ inferred_provider = infer_mail_provider_from_email(email)
577
  if acc:
578
+ acc_changed = False
579
  if acc.get("auth_file") != resolved_path:
580
  acc["auth_file"] = resolved_path
581
+ acc_changed = True
582
+ if inferred_provider and not acc.get("mail_provider"):
583
+ acc["mail_provider"] = inferred_provider
584
+ acc_changed = True
585
+ if acc_changed:
586
  changed_accounts = True
587
  updated_accounts += 1
588
  else:
 
590
  # 紧接着 update_account → STANDBY 一次性带上 auth_file. 这样状态机能落每条
591
  # state_log.jsonl + F2 SSE 推送, 不再静默直 append 旁路.
592
  try:
593
+ add_account(email, "", cloudmail_account_id=None, mail_provider=inferred_provider or None)
594
  update_account(
595
  email,
596
  status=STATUS_STANDBY,
 
610
  "email": email,
611
  "password": "",
612
  "cloudmail_account_id": None,
613
+ "mail_provider": inferred_provider or "",
614
  "status": STATUS_STANDBY,
615
  "auth_file": resolved_path,
616
  "quota_exhausted_at": None,
 
691
  synced_active = 0
692
  synced_personal = 0
693
  disabled_skipped = 0
694
+ active_publish_skipped = 0
695
+ active_publish_kept_remote = 0
696
+ active_publish_delete_remote = 0
697
  for acc in accounts:
698
  if is_account_disabled(acc):
699
  disabled_skipped += 1
 
707
  path = Path(auth_path)
708
  if not path.exists():
709
  continue
710
+
711
+ if status == STATUS_ACTIVE:
712
+ decision = _active_auth_publish_decision(acc, path)
713
+ if decision == "keep_remote":
714
+ active_publish_kept_remote += 1
715
+ continue
716
+ if decision == "delete_remote":
717
+ active_publish_delete_remote += 1
718
+ continue
719
+ if decision != "publish":
720
+ active_publish_skipped += 1
721
+ continue
722
+
723
  _refresh_account_proxy_url_for_upload(acc, path)
724
  files_to_sync[path.name] = path
725
  if status == STATUS_ACTIVE:
 
823
  "synced_active": synced_active,
824
  "synced_personal": synced_personal,
825
  "disabled_skipped": disabled_skipped,
826
+ "active_publish": {
827
+ "skipped_unknown": active_publish_skipped,
828
+ "kept_remote": active_publish_kept_remote,
829
+ "delete_remote": active_publish_delete_remote,
830
+ },
831
  "local_duplicates_deleted": local_duplicates_deleted,
832
  "delete_guard": {
833
  "allow_remote_delete": allow_remote_delete,
src/autoteam/mail/__init__.py CHANGED
@@ -30,6 +30,7 @@ __all__ = [
30
  "Email",
31
  "MailProvider",
32
  "get_mail_client",
 
33
  ]
34
 
35
 
@@ -59,6 +60,39 @@ def _resolve_provider_factory(name: str) -> Callable[[], MailProvider]:
59
  )
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def _build_chain_from_env(chain_env: str) -> MailProvider:
63
  """解析 MAIL_PROVIDER_CHAIN 字符串,返回 FallbackMailProvider。"""
64
  from autoteam.mail.fallback import FallbackMailProvider
 
30
  "Email",
31
  "MailProvider",
32
  "get_mail_client",
33
+ "infer_mail_provider_from_email",
34
  ]
35
 
36
 
 
60
  )
61
 
62
 
63
+ def _email_domain(value: object | None) -> str:
64
+ text = str(value or "").strip().lower()
65
+ if "@" not in text:
66
+ return ""
67
+ return text.rsplit("@", 1)[-1].lstrip("@").strip()
68
+
69
+
70
+ def _normalized_domain(value: object | None) -> str:
71
+ return str(value or "").strip().lower().lstrip("@")
72
+
73
+
74
+ def infer_mail_provider_from_email(email: object | None, env: dict[str, object] | None = None) -> str:
75
+ """Infer the configured mail provider from an account email domain.
76
+
77
+ The result is conservative: ambiguous or unknown domains return an empty
78
+ string so account routing can fall back to the default provider.
79
+ """
80
+ domain = _email_domain(email)
81
+ if not domain:
82
+ return ""
83
+
84
+ source = env or os.environ
85
+ candidates: list[tuple[str, str]] = [
86
+ ("cf_temp_email", _normalized_domain(source.get("CLOUDMAIL_DOMAIN"))),
87
+ ("maillab", _normalized_domain(source.get("MAILLAB_DOMAIN") or source.get("CLOUDMAIL_DOMAIN"))),
88
+ ("addy_io", _normalized_domain(source.get("ADDY_IO_DOMAIN"))),
89
+ ]
90
+ matches = [provider for provider, provider_domain in candidates if provider_domain and provider_domain == domain]
91
+ if len(set(matches)) == 1:
92
+ return matches[0]
93
+ return ""
94
+
95
+
96
  def _build_chain_from_env(chain_env: str) -> MailProvider:
97
  """解析 MAIL_PROVIDER_CHAIN 字符串,返回 FallbackMailProvider。"""
98
  from autoteam.mail.fallback import FallbackMailProvider
src/autoteam/manager.py CHANGED
@@ -33,6 +33,7 @@ from autoteam.account_ops import delete_managed_account, fetch_team_state
33
  from autoteam.accounts import (
34
  STATUS_ACTIVE,
35
  STATUS_AUTH_INVALID,
 
36
  STATUS_EXHAUSTED,
37
  STATUS_ORPHAN,
38
  STATUS_PENDING,
@@ -175,6 +176,26 @@ def _auto_reuse_skip_reason(acc: dict | None) -> str | None:
175
 
176
  # 失败类型常量(上游 `.upstream/manager.py:96`)。Hard failure → 立即暂停 + 释放席位。
177
  AUTH_REPAIR_HARD_FAILURE_TYPES = frozenset({"human_verification"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
 
180
  def _chatgpt_session_ready(chatgpt_api) -> bool:
@@ -232,7 +253,86 @@ def _has_auth_file(acc: dict | None) -> bool:
232
  """本地 acc 是否有可用 auth_file(上游 `.upstream/manager.py:153`)。"""
233
  acc = acc or {}
234
  auth_file = (acc.get("auth_file") or "").strip()
235
- return bool(auth_file) and Path(auth_file).exists()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
 
238
  def _has_account_mail_binding(acc: dict | None) -> bool:
@@ -255,6 +355,23 @@ def _is_protected_local_credential_seat(acc: dict | None) -> bool:
255
  return not _has_account_mail_binding(acc)
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def _sync_ready_credential_to_targets(email: str, auth_file: str | None, *, stage_label: str) -> dict:
259
  if not auth_file:
260
  logger.warning("%s 新凭证已就绪但缺少 auth_file,跳过即时同步: %s", stage_label, email)
@@ -314,6 +431,25 @@ def _release_account_ipv6_proxy(email: str | None) -> None:
314
  logger.warning("[IPv6Pool] release failed for %s: %s", email, exc)
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  def _attach_account_proxy_to_bundle(email: str | None, bundle: dict | None, proxy_url: str | None = None) -> None:
318
  if not isinstance(bundle, dict):
319
  return
@@ -559,7 +695,12 @@ def _auth_repair_error_label(error_type: str | None) -> str:
559
  "human_verification": "人机验证",
560
  "email_verification": "邮箱验证码页卡住",
561
  "workspace_selection": "workspace 选择未完成",
 
562
  "login_state_lost": "登录态丢失",
 
 
 
 
563
  "site_unavailable": "站点不可用/代理异常",
564
  "token_exchange_failed": "token 交换失败",
565
  "non_team_plan": "未进入 Team workspace",
@@ -570,6 +711,17 @@ def _auth_repair_error_label(error_type: str | None) -> str:
570
  return mapping.get(error_type or "", error_type or "未知错误")
571
 
572
 
 
 
 
 
 
 
 
 
 
 
 
573
  def _auth_repair_state_suffix(state: dict | None) -> str:
574
  """根据 auth_retry_after / auth_retry_paused 字段拼后缀(上游 `.upstream/manager.py:285`)."""
575
  state = state or {}
@@ -676,6 +828,13 @@ def _record_auth_repair_failure(
676
  error_detail = error_detail or _auth_repair_error_label(error_type)
677
  retry_delays = _auth_repair_retry_delays()
678
  release_team_seat = False
 
 
 
 
 
 
 
679
 
680
  if error_type == "add_phone" and _auth_repair_retry_add_phone_enabled():
681
  prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0
@@ -713,6 +872,19 @@ def _record_auth_repair_failure(
713
  "auth_retry_paused": True,
714
  }
715
  release_team_seat = True
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  else:
717
  prev_count = int(acc.get("auth_retry_count") or 0)
718
  next_count = min(prev_count + 1, len(retry_delays))
@@ -726,6 +898,14 @@ def _record_auth_repair_failure(
726
  "auth_retry_after": retry_after,
727
  "auth_retry_paused": False,
728
  }
 
 
 
 
 
 
 
 
729
 
730
  update_account(email, **state)
731
 
@@ -745,6 +925,13 @@ def _record_auth_repair_failure(
745
  # 走 default_machine.transition 时映射到 AccountState.AUTH_PENDING.
746
  final_status = STATUS_STANDBY if seat_released or not is_team_member else STATUS_AUTH_INVALID
747
  update_account(email, status=final_status, _reason=f"auth_repair:{error_type}")
 
 
 
 
 
 
 
748
 
749
  return {
750
  **state,
@@ -752,6 +939,130 @@ def _record_auth_repair_failure(
752
  "seat_released": seat_released,
753
  "release_attempted": release_attempted,
754
  "remove_status": remove_status,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  }
756
 
757
 
@@ -796,13 +1107,10 @@ def _find_team_auth_file(email):
796
  严格只接 -team-*.json:personal/plus/free 席位 auth 不能用于 Team 子号,
797
  用错 plan 的 bundle 会被 OAuth 拒收(参考 codex-oauth personal 模式回退)。
798
  """
799
- try:
800
- from autoteam.auth_storage import AUTH_DIR
801
- except Exception:
802
- return None
803
- if not AUTH_DIR.exists():
804
- return None
805
- candidates = sorted(AUTH_DIR.glob(f"codex-{email}-team-*.json"))
806
  return str(candidates[0]) if candidates else None
807
 
808
 
@@ -1264,16 +1572,22 @@ def sync_account_states(chatgpt_api=None):
1264
  email = acc["email"].lower()
1265
  in_team = email in team_emails
1266
 
1267
- if in_team and acc["status"] in (STATUS_STANDBY, STATUS_PENDING):
1268
  # Round 12 wire-up M1 — go through state machine (default_machine.transition).
1269
  ws_id = account_id or None
 
1270
  _transition_status(
1271
  acc["email"], STATUS_ACTIVE,
1272
  workspace_account_id=ws_id,
 
 
1273
  _reason="sync_account_states:in_team",
1274
  )
1275
  # 内存里也同步,后续 need_probe / domain_suffix 分支基于刷新后的状态判断
1276
  acc["status"] = STATUS_ACTIVE
 
 
 
1277
  if account_id:
1278
  acc["workspace_account_id"] = account_id
1279
  changed = True
@@ -1416,19 +1730,22 @@ def sync_account_states(chatgpt_api=None):
1416
  # 判断是否在 Team 中
1417
  in_team = email in team_emails
1418
  status = STATUS_ACTIVE if in_team else STATUS_STANDBY
1419
- accounts.append(
1420
- {
1421
- "email": email,
1422
- "password": "",
1423
- "cloudmail_account_id": None,
1424
- "status": status,
1425
- "auth_file": str(auth_file),
1426
- "quota_exhausted_at": None,
1427
- "quota_resets_at": None,
1428
- "created_at": time.time(),
1429
- "last_active_at": None,
1430
- }
1431
- )
 
 
 
1432
  local_email_set.add(email)
1433
  changed = True
1434
  logger.info("[同步] 从 auths 目录恢复账号: %s(%s)", email, status)
@@ -1623,14 +1940,20 @@ STANDBY_PROBE_INTERVAL_SEC = 1.5 # 每个 standby 账号探测间隔,限速避
1623
  STANDBY_PROBE_DEDUP_SEC = 24 * 3600 # 24h 内已探测过的 standby 跳过
1624
 
1625
 
1626
- def cmd_check(include_standby: bool = False):
 
 
 
 
 
 
1627
  """检查 active 账号的额度,无认证文件或 auth_error 的自动重新登录 Codex。
1628
 
1629
  参数:
1630
  include_standby: True 时额外探测 standby 池每个账号的 quota(限速 + 24h 去重),
1631
  401/403 的标记为 STATUS_AUTH_INVALID。默认 False 保持向后兼容。
1632
  """
1633
- from autoteam.config import AUTO_CHECK_THRESHOLD, CLOUDMAIL_DOMAIN
1634
 
1635
  # API 运行时配置优先(前端可修改)
1636
  try:
@@ -1765,21 +2088,52 @@ def cmd_check(include_standby: bool = False):
1765
 
1766
  accounts = load_accounts()
1767
 
1768
- all_active = [a for a in accounts if a["status"] == STATUS_ACTIVE and not _is_main_account_email(a.get("email"))]
 
 
 
 
 
 
 
 
 
 
 
1769
 
1770
  # 区分:有认证文���的 vs 无认证文件的
1771
  active_with_auth = []
1772
  no_auth_list = []
 
 
 
1773
  for a in all_active:
1774
- if a.get("auth_file") and Path(a["auth_file"]).exists():
1775
  active_with_auth.append(a)
1776
  else:
1777
- # 只管我们域名的账号
1778
- if CLOUDMAIL_DOMAIN and CLOUDMAIL_DOMAIN.lstrip("@") in a["email"]:
 
 
 
1779
  no_auth_list.append(a)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1780
 
1781
  if not active_with_auth and not no_auth_list:
1782
- logger.info("[检查] 没有可检查的 active 账号")
1783
  return []
1784
 
1785
  # 检查有认证文件的账号额度
@@ -1790,6 +2144,7 @@ def cmd_check(include_standby: bool = False):
1790
  logger.info("[检查] 检查 %d 个 active 账号的额度...", len(active_with_auth))
1791
  for acc in active_with_auth:
1792
  email = acc["email"]
 
1793
  status_str, info = _check_and_refresh(acc)
1794
 
1795
  if status_str == "ok":
@@ -1804,6 +2159,23 @@ def cmd_check(include_standby: bool = False):
1804
  update_account(email, last_quota=info)
1805
  # 低于阈值视为用完
1806
  if p_remain < threshold:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1807
  resets_at = p_reset or (time.time() + 18000)
1808
  logger.warning(
1809
  "[%s] 5h剩余 %d%% < %d%%,标记为 exhausted (重置 %s)", email, p_remain, threshold, p_time
@@ -1816,6 +2188,18 @@ def cmd_check(include_standby: bool = False):
1816
  )
1817
  exhausted_list.append(acc)
1818
  else:
 
 
 
 
 
 
 
 
 
 
 
 
1819
  logger.info(
1820
  "[%s] 额度可用 - 5h剩余: %d%% (重置 %s) | 周剩余: %d%% (重置 %s)",
1821
  email,
@@ -1825,6 +2209,7 @@ def cmd_check(include_standby: bool = False):
1825
  w_time,
1826
  )
1827
  else:
 
1828
  logger.info("[%s] 额度可用", email)
1829
  elif status_str == "exhausted":
1830
  quota_info = quota_result_quota_info(info) or {}
@@ -1887,15 +2272,62 @@ def cmd_check(include_standby: bool = False):
1887
  else:
1888
  logger.info("[%s] token 失效但 5h 重置时间已过,需重新登录验证", email)
1889
  logger.warning("[%s] 认证失败,需要重新登录 Codex", email)
1890
- auth_error_list.append(acc)
 
 
 
 
 
 
 
 
 
 
1891
  elif status_str == "network_error":
1892
- # 网络抖动/5xx/429:不算"额度用完",也不算"token 失效"。本轮跳过,不动 status,
1893
- # 不进 exhausted_list,也不进 auth_error_list(避免触发昂贵的重登流程)。
1894
- logger.warning("[%s] 额度查询遇到临时网络错误,本轮跳过,等待下一轮重试", email)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1895
 
1896
- # 无认证文件的 active 账号也需要重新登录
1897
  if no_auth_list:
1898
- logger.info("[检查] 发现 %d 个 active 账号无认证文件,需要登录 Codex:", len(no_auth_list))
1899
  for a in no_auth_list:
1900
  logger.info("[检查] %s", a["email"])
1901
  auth_error_list.extend(no_auth_list)
@@ -1929,24 +2361,28 @@ def cmd_check(include_standby: bool = False):
1929
  auth_error_list = survivable_auth_errors
1930
 
1931
  if auth_error_list:
1932
- logger.info("[检查] 重新登录 %d 个 token 失效的账号...", len(auth_error_list))
1933
- mail_client = CloudMailClient()
1934
- mail_client.login()
1935
  for acc in auth_error_list:
1936
  email = acc["email"]
1937
  password = acc.get("password", "")
1938
  logger.info("[%s] 重新 Codex 登录...", email)
 
 
 
 
 
1939
  auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
1940
- bundle = _login_codex_via_browser_with_proxy(
1941
- email,
1942
- password,
1943
- mail_client=mail_client,
1944
- playwright_proxy_url=playwright_proxy_url,
1945
- )
1946
- if bundle:
1947
  _attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
1948
  auth_file = save_auth_file(bundle)
1949
  update_account(email, auth_file=auth_file)
 
1950
  logger.info("[%s] token 已更新", email)
1951
  # 重新检查额度
1952
  status_str, info = _check_and_refresh(find_account(load_accounts(), email))
@@ -1976,15 +2412,44 @@ def cmd_check(include_standby: bool = False):
1976
  )
1977
  exhausted_list.append(acc)
1978
  else:
 
 
1979
  logger.info("[%s] 额度可用 (%d%%)", email, p_remain)
1980
  elif status_str == "ok":
 
 
1981
  logger.info("[%s] 额度可用", email)
1982
  elif status_str == "auth_error":
1983
- logger.warning("[%s] 重新登录后仍无法查询额度(可能未选中 Team workspace),标记为 standby", email)
1984
- update_account(email, status=STATUS_STANDBY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1985
  else:
1986
- logger.error("[%s] Codex 登录失败,标记为 standby", email)
1987
- update_account(email, status=STATUS_STANDBY)
 
 
 
 
 
 
 
 
 
 
 
1988
 
1989
  # 已 exhausted 但 5h 重置时间已过 → 复测,真的回血则 promote 回 active,避免轮转
1990
  # 多走一遍"kick → standby → re-invite"。token 仍在 Team 里时这个直接 promote 路径
@@ -2969,6 +3434,11 @@ def _get_mail_client_for_account(acc):
2969
  return client
2970
 
2971
 
 
 
 
 
 
2972
  def _resolve_mail_client_or_default(mail_client, acc=None):
2973
  """统一处理 mail_client=None 入参:按 acc 路由或走全局默认。
2974
 
@@ -3118,7 +3588,12 @@ def _check_pending_invites(chatgpt_api, mail_client, *, leave_workspace=False, o
3118
  password = acc.get("password") or random_password()
3119
  else:
3120
  password = random_password()
3121
- add_account(inv_email, password, workspace_account_id=get_chatgpt_account_id() or None)
 
 
 
 
 
3122
 
3123
  # 关闭 ChatGPT 浏览器再注册
3124
  chatgpt_api.stop()
@@ -3160,6 +3635,8 @@ _DIRECT_EMAIL_SELECTORS = (
3160
  )
3161
  _DIRECT_PASSWORD_SELECTORS = 'input[name="password"], input[type="password"]'
3162
  _DIRECT_CODE_SELECTORS = 'input[name="code"], input[placeholder*="验证码"], input[placeholder*="code" i]'
 
 
3163
 
3164
 
3165
  def _safe_invite_screenshot(page, name):
@@ -3202,18 +3679,125 @@ def _pending_historical_exhausted_info(quota_info, now=None):
3202
  return exhausted_info
3203
 
3204
 
3205
- def _first_visible_editable_locator(page, selectors, timeout=800):
 
 
 
 
 
 
 
 
 
 
 
 
 
3206
  try:
3207
- locator = page.locator(selectors).first
3208
- if not locator.is_visible(timeout=timeout):
3209
- return None
3210
- if locator.is_editable(timeout=timeout):
3211
- return locator
 
 
 
 
 
 
3212
  except Exception:
 
 
3213
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3214
  return None
3215
 
3216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3217
  def _collect_date_spinbutton_meta(page):
3218
  try:
3219
  return page.evaluate(
@@ -3335,6 +3919,8 @@ def _detect_direct_register_step(page):
3335
  url = (page.url or "").lower()
3336
  if _is_google_redirect(page):
3337
  return "google"
 
 
3338
 
3339
  if "email-verification" in url:
3340
  return "code"
@@ -3380,6 +3966,8 @@ def _wait_for_direct_register_step(page, allowed_steps, timeout=15):
3380
  deadline = time.time() + timeout
3381
  while time.time() < deadline:
3382
  step = _detect_direct_register_step(page)
 
 
3383
  if step in allowed_steps:
3384
  return step
3385
  time.sleep(0.5)
@@ -3677,6 +4265,14 @@ def _register_direct_once(
3677
  logger.warning("[直接注册] 邮箱步骤仍停留在 Google 登录页")
3678
  cleanup_direct_register()
3679
  return False, None
 
 
 
 
 
 
 
 
3680
  if current_step == "email":
3681
  logger.warning("[直接注册] 邮箱步骤未推进 | URL: %s | body=%s", page.url, _page_excerpt(page))
3682
  cleanup_direct_register()
@@ -3691,11 +4287,19 @@ def _register_direct_once(
3691
  # 等待页面跳转完成(可能跳到 create-account/password)
3692
  password_step = _wait_for_direct_register_step(
3693
  page,
3694
- {"password", "code", "profile", "completed", "google", "email"},
3695
  timeout=15,
3696
  )
3697
  logger.info("[直接注册] 密码页检测状态: %s | URL: %s", password_step, page.url)
3698
  _safe_invite_screenshot(page, "direct_03b_before_password.png")
 
 
 
 
 
 
 
 
3699
 
3700
  try:
3701
  for attempt in range(2):
@@ -3744,6 +4348,14 @@ def _register_direct_once(
3744
  logger.warning("[直接注册] 密码步骤仍停留在 Google 登录页")
3745
  cleanup_direct_register()
3746
  return False, None
 
 
 
 
 
 
 
 
3747
  if current_step == "email":
3748
  logger.warning("[直接注册] 提交密码前流程回退到邮箱页 | URL: %s | body=%s", page.url, _page_excerpt(page))
3749
  cleanup_direct_register()
@@ -3755,15 +4367,29 @@ def _register_direct_once(
3755
  cleanup_direct_register()
3756
  raise
3757
 
3758
- code_input = None
3759
- try:
3760
- code_input = page.locator(_DIRECT_CODE_SELECTORS).first
3761
- if not code_input.is_visible(timeout=5000):
3762
- code_input = None
3763
- except Exception:
3764
- code_input = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3765
 
3766
- if code_input:
3767
  logger.info("[直接注册] 等待验证码...")
3768
  verification_code = None
3769
  start_t = time.time()
@@ -3781,10 +4407,8 @@ def _register_direct_once(
3781
 
3782
  if verification_code:
3783
  logger.info("[直接注册] 输入验证码: %s", verification_code)
3784
- code_input.fill(verification_code)
3785
- time.sleep(0.5)
3786
- _click_primary_auth_button(page, code_input, ["Continue", "继续"])
3787
- time.sleep(8)
3788
  else:
3789
  logger.error("[直接注册] 未收到验证码")
3790
  cleanup_direct_register()
@@ -4304,7 +4928,14 @@ def create_account_direct(
4304
  playwright_proxy_url = signup.get("playwright_proxy_url") or ""
4305
  winner_mail_client = signup.get("mail_client") or mail_client
4306
 
4307
- add_account(email, password, cloudmail_account_id=account_id, workspace_account_id=get_chatgpt_account_id() or None)
 
 
 
 
 
 
 
4308
 
4309
  post_result = _run_post_register_oauth(
4310
  email,
@@ -6525,6 +7156,69 @@ def cmd_pull_cpa():
6525
  return result
6526
 
6527
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6528
  def _reconcile_master_degraded_subaccounts(*, dry_run: bool = False, chatgpt_api=None):
6529
  """Round 8 — SPEC-2 v1.5 §3.5.3:reconcile retroactive 清理。
6530
 
@@ -6647,6 +7341,7 @@ def main():
6647
  cleanup_p = sub.add_parser("cleanup", help="清理多余成员(只移除本地管理的)")
6648
  cleanup_p.add_argument("max_seats", type=int, nargs="?", default=None, help="最大席位数")
6649
 
 
6650
  sub.add_parser("sync", help="手动同步认证文件到 CPA")
6651
  sub.add_parser("pull-cpa", help="从 CPA 反向同步认证文件到本地")
6652
 
@@ -6703,6 +7398,8 @@ def main():
6703
  cmd_fill(args.target)
6704
  elif args.command == "cleanup":
6705
  cmd_cleanup(args.max_seats)
 
 
6706
  elif args.command == "sync":
6707
  sync_to_cpa()
6708
  elif args.command == "pull-cpa":
 
33
  from autoteam.accounts import (
34
  STATUS_ACTIVE,
35
  STATUS_AUTH_INVALID,
36
+ STATUS_AUTH_PENDING,
37
  STATUS_EXHAUSTED,
38
  STATUS_ORPHAN,
39
  STATUS_PENDING,
 
176
 
177
  # 失败类型常量(上游 `.upstream/manager.py:96`)。Hard failure → 立即暂停 + 释放席位。
178
  AUTH_REPAIR_HARD_FAILURE_TYPES = frozenset({"human_verification"})
179
+ AUTH_REPAIR_SINGLE_ATTEMPT_FAILURE_TYPES = frozenset(
180
+ {
181
+ "add_phone",
182
+ "human_verification",
183
+ "email_verification",
184
+ "login_state_lost",
185
+ "account_selection",
186
+ "no_valid_organizations",
187
+ }
188
+ )
189
+ AUTH_REPAIR_RELEASE_AFTER_RETRY_TYPES = frozenset({"email_verification"})
190
+ AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES = frozenset(
191
+ {
192
+ "login_state_lost",
193
+ "account_selection",
194
+ "no_valid_organizations",
195
+ "missing_auth_file",
196
+ "auth_error_discard",
197
+ }
198
+ )
199
 
200
 
201
  def _chatgpt_session_ready(chatgpt_api) -> bool:
 
253
  """本地 acc 是否有可用 auth_file(上游 `.upstream/manager.py:153`)。"""
254
  acc = acc or {}
255
  auth_file = (acc.get("auth_file") or "").strip()
256
+ return bool(auth_file) and _resolve_auth_file_path(auth_file).exists()
257
+
258
+
259
+ def get_mail_domain() -> str:
260
+ """Return the configured mail domain used by registration/auth repair flows."""
261
+ from autoteam.runtime_config import get_register_domain
262
+
263
+ return get_register_domain()
264
+
265
+
266
+ def get_mail_provider_name() -> str:
267
+ """Return the configured mail provider name used by registration/auth repair flows."""
268
+ return (os.environ.get("MAIL_PROVIDER") or "cf_temp_email").strip().lower()
269
+
270
+
271
+ def _mail_provider_name_for_client(mail_client) -> str:
272
+ provider_name = str(getattr(mail_client, "provider_name", "") or "").strip().lower()
273
+ if not provider_name:
274
+ try:
275
+ current = getattr(mail_client, "current_provider_name", None)
276
+ if callable(current):
277
+ provider_name = str(current() or "").strip().lower()
278
+ except Exception:
279
+ provider_name = ""
280
+ if provider_name in {"maillab", "addy_io", "simplelogin"}:
281
+ return provider_name
282
+ if provider_name in {"cf_temp_email", "cloudflare_temp_email", "cloudmail"}:
283
+ return "cf_temp_email"
284
+ return get_mail_provider_name()
285
+
286
+
287
+ def _auth_search_dirs() -> tuple[Path, ...]:
288
+ """Candidate auth directories for host and container path compatibility."""
289
+ candidates: list[Path] = []
290
+ try:
291
+ from autoteam.auth_storage import AUTH_DIR
292
+
293
+ candidates.append(AUTH_DIR)
294
+ except Exception:
295
+ pass
296
+
297
+ project_root = Path(__file__).resolve().parents[2]
298
+ candidates.extend((project_root / "data" / "auths", project_root / "auths"))
299
+
300
+ seen: set[str] = set()
301
+ unique: list[Path] = []
302
+ for path in candidates:
303
+ key = str(path)
304
+ if key not in seen:
305
+ seen.add(key)
306
+ unique.append(path)
307
+ return tuple(unique)
308
+
309
+
310
+ def _resolve_auth_file_path(value: str | None) -> Path:
311
+ """Resolve auth paths saved as host paths, `/app/...`, `data/auths/...`, or bare names."""
312
+ text = (value or "").strip()
313
+ if not text:
314
+ return Path("")
315
+
316
+ direct = Path(text)
317
+ if direct.exists():
318
+ return direct
319
+
320
+ project_root = Path(__file__).resolve().parents[2]
321
+ candidates: list[Path] = []
322
+ normalized = text.replace("\\", "/")
323
+ if normalized.startswith("/app/"):
324
+ candidates.append(project_root / normalized.removeprefix("/app/"))
325
+ elif normalized.startswith("data/") or normalized.startswith("auths/"):
326
+ candidates.append(project_root / normalized)
327
+
328
+ name = Path(normalized).name
329
+ if name:
330
+ candidates.extend(auth_dir / name for auth_dir in _auth_search_dirs())
331
+
332
+ for candidate in candidates:
333
+ if candidate.exists():
334
+ return candidate
335
+ return direct
336
 
337
 
338
  def _has_account_mail_binding(acc: dict | None) -> bool:
 
355
  return not _has_account_mail_binding(acc)
356
 
357
 
358
+ def _is_auth_repair_pending_status(status: str | None) -> bool:
359
+ """Accept current persisted auth-invalid alias and legacy target auth_pending literal."""
360
+ return status in {STATUS_AUTH_PENDING, "auth_pending"}
361
+
362
+
363
+ def _can_attempt_auth_repair(acc: dict | None, mail_domain_suffix: str = "") -> bool:
364
+ acc = acc or {}
365
+ if (
366
+ bool(acc.get("mail_provider"))
367
+ or acc.get("mail_account_id") is not None
368
+ or acc.get("cloudmail_account_id") is not None
369
+ ):
370
+ return True
371
+ email = _normalized_email(acc.get("email"))
372
+ return bool(mail_domain_suffix and mail_domain_suffix in email)
373
+
374
+
375
  def _sync_ready_credential_to_targets(email: str, auth_file: str | None, *, stage_label: str) -> dict:
376
  if not auth_file:
377
  logger.warning("%s 新凭证已就绪但缺少 auth_file,跳过即时同步: %s", stage_label, email)
 
431
  logger.warning("[IPv6Pool] release failed for %s: %s", email, exc)
432
 
433
 
434
+ def _discard_auth_repair_failed_account_record(
435
+ email: str,
436
+ reason: str,
437
+ *,
438
+ status: str = STATUS_STANDBY,
439
+ now: float | None = None,
440
+ ) -> None:
441
+ """Mark a released auth-repair failure as non-reusable while keeping local evidence."""
442
+ update_account(
443
+ email,
444
+ status=status,
445
+ disabled=True,
446
+ reuse_disabled=True,
447
+ retired_at=time.time() if now is None else now,
448
+ retired_reason=reason,
449
+ )
450
+ _release_account_ipv6_proxy(email)
451
+
452
+
453
  def _attach_account_proxy_to_bundle(email: str | None, bundle: dict | None, proxy_url: str | None = None) -> None:
454
  if not isinstance(bundle, dict):
455
  return
 
695
  "human_verification": "人机验证",
696
  "email_verification": "邮箱验证码页卡住",
697
  "workspace_selection": "workspace 选择未完成",
698
+ "account_selection": "账号选择页未完成",
699
  "login_state_lost": "登录态丢失",
700
+ "missing_auth_file": "缺少本地 Codex 凭证",
701
+ "auth_error_discard": "认证失效后一次性丢弃",
702
+ "unsupported_region": "出口地区不被 OAuth 接受",
703
+ "oauth_timeout": "OAuth 授权页超时",
704
  "site_unavailable": "站点不可用/代理异常",
705
  "token_exchange_failed": "token 交换失败",
706
  "non_team_plan": "未进入 Team workspace",
 
711
  return mapping.get(error_type or "", error_type or "未知错误")
712
 
713
 
714
+ def _oauth_retry_delay_seconds(error_type: str | None) -> int:
715
+ """Short same-round retry delays for transient OAuth organization/region pages."""
716
+ mapping = {
717
+ "unsupported_region": 20,
718
+ "account_selection": 6,
719
+ "no_valid_organizations": 10,
720
+ "oauth_timeout": 8,
721
+ }
722
+ return mapping.get(str(error_type or "").strip(), 0)
723
+
724
+
725
  def _auth_repair_state_suffix(state: dict | None) -> str:
726
  """根据 auth_retry_after / auth_retry_paused 字段拼后缀(上游 `.upstream/manager.py:285`)."""
727
  state = state or {}
 
828
  error_detail = error_detail or _auth_repair_error_label(error_type)
829
  retry_delays = _auth_repair_retry_delays()
830
  release_team_seat = False
831
+ missing_auth_file = not _has_auth_file(acc)
832
+ team_blocking_status = acc.get("status") in (STATUS_STANDBY, STATUS_AUTH_INVALID)
833
+ protected_local_credential = _is_protected_local_credential_seat(acc)
834
+ try:
835
+ from autoteam.config import ROTATE_SKIP_REUSE as discard_failed_repair
836
+ except Exception:
837
+ discard_failed_repair = True
838
 
839
  if error_type == "add_phone" and _auth_repair_retry_add_phone_enabled():
840
  prev_count = int(acc.get("auth_retry_count") or 0) if acc.get("auth_last_error") == "add_phone" else 0
 
872
  "auth_retry_paused": True,
873
  }
874
  release_team_seat = True
875
+ elif error_type in AUTH_REPAIR_RELEASE_TEAM_BLOCKER_TYPES and (
876
+ missing_auth_file or team_blocking_status or discard_failed_repair
877
+ ):
878
+ prev_count = int(acc.get("auth_retry_count") or 0)
879
+ state = {
880
+ "auth_retry_count": prev_count + 1,
881
+ "auth_last_error": error_type,
882
+ "auth_last_error_detail": error_detail,
883
+ "auth_last_failed_at": now,
884
+ "auth_retry_after": None,
885
+ "auth_retry_paused": True,
886
+ }
887
+ release_team_seat = True
888
  else:
889
  prev_count = int(acc.get("auth_retry_count") or 0)
890
  next_count = min(prev_count + 1, len(retry_delays))
 
898
  "auth_retry_after": retry_after,
899
  "auth_retry_paused": False,
900
  }
901
+ if error_type in AUTH_REPAIR_RELEASE_AFTER_RETRY_TYPES and next_count >= len(retry_delays):
902
+ state["auth_retry_after"] = None
903
+ state["auth_retry_paused"] = True
904
+ release_team_seat = True
905
+
906
+ if release_team_seat and protected_local_credential:
907
+ logger.warning("[认证修复] 保留受保护的本地凭证席位: %s", email)
908
+ release_team_seat = False
909
 
910
  update_account(email, **state)
911
 
 
925
  # 走 default_machine.transition 时映射到 AccountState.AUTH_PENDING.
926
  final_status = STATUS_STANDBY if seat_released or not is_team_member else STATUS_AUTH_INVALID
927
  update_account(email, status=final_status, _reason=f"auth_repair:{error_type}")
928
+ if discard_failed_repair and seat_released and not protected_local_credential:
929
+ _discard_auth_repair_failed_account_record(
930
+ email,
931
+ f"auth_repair_failed:{error_type}",
932
+ status=STATUS_STANDBY,
933
+ now=now,
934
+ )
935
 
936
  return {
937
  **state,
 
939
  "seat_released": seat_released,
940
  "release_attempted": release_attempted,
941
  "remove_status": remove_status,
942
+ "protected_local_credential": protected_local_credential,
943
+ }
944
+
945
+
946
+ def _login_codex_with_result(
947
+ email: str,
948
+ password: str,
949
+ *,
950
+ mail_client=None,
951
+ max_attempts: int = 3,
952
+ signup_profile: SignupProfile | None = None,
953
+ pre_signed_in_cookies: list | None = None,
954
+ playwright_proxy_url: str | None = None,
955
+ ) -> dict:
956
+ """Run Codex OAuth with the explicit result wrapper used by auth repair.
957
+
958
+ This helper is intentionally isolated from `cmd_check` seat-release policy.
959
+ It normalizes old bundle/None callers, current `return_result=True` callers,
960
+ and rejected non-Team bundles into one retryable result shape.
961
+ """
962
+ max_attempts = max(1, int(max_attempts))
963
+
964
+ def _reject_non_team(bundle: dict | None) -> dict | None:
965
+ if not isinstance(bundle, dict) or not bundle:
966
+ return None
967
+ plan_type = str(bundle.get("plan_type") or "").lower()
968
+ if plan_type == "team":
969
+ return None
970
+ return {
971
+ "ok": False,
972
+ "bundle": None,
973
+ "error_type": "non_team_plan",
974
+ "error_detail": f"登录后 plan={plan_type or 'unknown'},未进入 Team workspace",
975
+ "retryable": True,
976
+ }
977
+
978
+ def _result_from_legacy_bundle(bundle) -> dict:
979
+ non_team = _reject_non_team(bundle if isinstance(bundle, dict) else None)
980
+ if non_team:
981
+ return non_team
982
+ return {
983
+ "ok": bool(bundle),
984
+ "bundle": bundle if bundle else None,
985
+ "error_type": None if bundle else "login_failed",
986
+ "error_detail": None if bundle else "登录失败",
987
+ "retryable": False if bundle else True,
988
+ }
989
+
990
+ def _call_login(use_cookies: list | None) -> dict:
991
+ kwargs = {"mail_client": mail_client, "return_result": True}
992
+ if signup_profile is not None:
993
+ kwargs["signup_profile"] = signup_profile
994
+ if use_cookies is not None:
995
+ kwargs["pre_signed_in_cookies"] = use_cookies
996
+ if playwright_proxy_url is not None:
997
+ kwargs["playwright_proxy_url"] = playwright_proxy_url
998
+
999
+ try:
1000
+ result = login_codex_via_browser(email, password, **kwargs)
1001
+ except TypeError:
1002
+ # Keep test doubles and older call signatures usable while current
1003
+ # production code accepts the extended kwargs above.
1004
+ result = login_codex_via_browser(
1005
+ email,
1006
+ password,
1007
+ mail_client=mail_client,
1008
+ return_result=True,
1009
+ )
1010
+ except Exception as exc:
1011
+ return {
1012
+ "ok": False,
1013
+ "bundle": None,
1014
+ "error_type": "exception",
1015
+ "error_detail": str(exc),
1016
+ "retryable": True,
1017
+ }
1018
+
1019
+ if isinstance(result, dict) and "ok" in result:
1020
+ non_team = _reject_non_team(result.get("bundle"))
1021
+ if result.get("ok") and non_team:
1022
+ return non_team
1023
+ return result
1024
+ return _result_from_legacy_bundle(result)
1025
+
1026
+ last_result = None
1027
+ for attempt in range(1, max_attempts + 1):
1028
+ # Captured ChatGPT/Auth cookies are a first-attempt shortcut. If they
1029
+ # fail, later attempts fall back to the full login/OAuth path.
1030
+ use_cookies = pre_signed_in_cookies if attempt == 1 else None
1031
+ result = _call_login(use_cookies)
1032
+ result["attempts"] = attempt
1033
+ if result.get("ok"):
1034
+ return result
1035
+
1036
+ last_result = result
1037
+ error_type = result.get("error_type")
1038
+ retryable = bool(result.get("retryable"))
1039
+ if attempt >= max_attempts or not retryable or error_type in AUTH_REPAIR_SINGLE_ATTEMPT_FAILURE_TYPES:
1040
+ return result
1041
+
1042
+ logger.warning(
1043
+ "[Codex] %s 登录未完成(%s),准备在本轮重试第 %d/%d 次",
1044
+ email,
1045
+ _auth_repair_error_label(error_type),
1046
+ attempt + 1,
1047
+ max_attempts,
1048
+ )
1049
+ retry_delay = _oauth_retry_delay_seconds(error_type)
1050
+ if retry_delay > 0:
1051
+ logger.info(
1052
+ "[Codex] %s 命中 %s,等待 %d 秒后重试以规避瞬时组织/出口抖动",
1053
+ email,
1054
+ _auth_repair_error_label(error_type),
1055
+ retry_delay,
1056
+ )
1057
+ time.sleep(retry_delay)
1058
+
1059
+ return last_result or {
1060
+ "ok": False,
1061
+ "bundle": None,
1062
+ "error_type": "login_failed",
1063
+ "error_detail": "登录失败",
1064
+ "retryable": True,
1065
+ "attempts": max_attempts,
1066
  }
1067
 
1068
 
 
1107
  严格只接 -team-*.json:personal/plus/free 席位 auth 不能用于 Team 子号,
1108
  用错 plan 的 bundle 会被 OAuth 拒收(参考 codex-oauth personal 模式回退)。
1109
  """
1110
+ candidates: list[Path] = []
1111
+ for auth_dir in _auth_search_dirs():
1112
+ if auth_dir.exists():
1113
+ candidates.extend(sorted(auth_dir.glob(f"codex-{email}-team-*.json")))
 
 
 
1114
  return str(candidates[0]) if candidates else None
1115
 
1116
 
 
1572
  email = acc["email"].lower()
1573
  in_team = email in team_emails
1574
 
1575
+ if in_team and acc["status"] in (STATUS_STANDBY, STATUS_PENDING, "auth_pending"):
1576
  # Round 12 wire-up M1 — go through state machine (default_machine.transition).
1577
  ws_id = account_id or None
1578
+ protect_team_seat = _has_auth_file(acc)
1579
  _transition_status(
1580
  acc["email"], STATUS_ACTIVE,
1581
  workspace_account_id=ws_id,
1582
+ protect_team_seat=protect_team_seat,
1583
+ remote_seen_at=now_ts,
1584
  _reason="sync_account_states:in_team",
1585
  )
1586
  # 内存里也同步,后续 need_probe / domain_suffix 分支基于刷新后的状态判断
1587
  acc["status"] = STATUS_ACTIVE
1588
+ acc["remote_seen_at"] = now_ts
1589
+ if protect_team_seat:
1590
+ acc["protect_team_seat"] = True
1591
  if account_id:
1592
  acc["workspace_account_id"] = account_id
1593
  changed = True
 
1730
  # 判断是否在 Team 中
1731
  in_team = email in team_emails
1732
  status = STATUS_ACTIVE if in_team else STATUS_STANDBY
1733
+ recovered = {
1734
+ "email": email,
1735
+ "password": "",
1736
+ "cloudmail_account_id": None,
1737
+ "status": status,
1738
+ "auth_file": str(auth_file),
1739
+ "quota_exhausted_at": None,
1740
+ "quota_resets_at": None,
1741
+ "created_at": time.time(),
1742
+ "last_active_at": None,
1743
+ }
1744
+ if in_team:
1745
+ recovered["protect_team_seat"] = True
1746
+ if account_id:
1747
+ recovered["workspace_account_id"] = account_id
1748
+ accounts.append(recovered)
1749
  local_email_set.add(email)
1750
  changed = True
1751
  logger.info("[同步] 从 auths 目录恢复账号: %s(%s)", email, status)
 
1940
  STANDBY_PROBE_DEDUP_SEC = 24 * 3600 # 24h 内已探测过的 standby 跳过
1941
 
1942
 
1943
+ def cmd_check(
1944
+ include_standby: bool = False,
1945
+ *,
1946
+ force_auth_repair: bool = False,
1947
+ preserve_low_active: bool = False,
1948
+ preserved_low_accounts=None,
1949
+ ):
1950
  """检查 active 账号的额度,无认证文件或 auth_error 的自动重新登录 Codex。
1951
 
1952
  参数:
1953
  include_standby: True 时额外探测 standby 池每个账号的 quota(限速 + 24h 去重),
1954
  401/403 的标记为 STATUS_AUTH_INVALID。默认 False 保持向后兼容。
1955
  """
1956
+ from autoteam.config import AUTO_CHECK_THRESHOLD
1957
 
1958
  # API 运行时配置优先(前端可修改)
1959
  try:
 
2088
 
2089
  accounts = load_accounts()
2090
 
2091
+ all_active = [
2092
+ a
2093
+ for a in accounts
2094
+ if a["status"] == STATUS_ACTIVE and not _is_main_account_email(a.get("email")) and not is_account_disabled(a)
2095
+ ]
2096
+ auth_pending_accounts = [
2097
+ a
2098
+ for a in accounts
2099
+ if _is_auth_repair_pending_status(a.get("status"))
2100
+ and not _is_main_account_email(a.get("email"))
2101
+ and not is_account_disabled(a)
2102
+ ]
2103
 
2104
  # 区分:有认证文���的 vs 无认证文件的
2105
  active_with_auth = []
2106
  no_auth_list = []
2107
+ skipped_repairs = []
2108
+ mail_domain = get_mail_domain()
2109
+ mail_domain_suffix = mail_domain.lstrip("@") if mail_domain else ""
2110
  for a in all_active:
2111
+ if _has_auth_file(a):
2112
  active_with_auth.append(a)
2113
  else:
2114
+ if _can_attempt_auth_repair(a, mail_domain_suffix):
2115
+ skip_reason = _auth_repair_skip_reason(a, force=force_auth_repair)
2116
+ if skip_reason:
2117
+ skipped_repairs.append((a["email"], skip_reason))
2118
+ continue
2119
  no_auth_list.append(a)
2120
+ for a in auth_pending_accounts:
2121
+ if _has_auth_file(a):
2122
+ active_with_auth.append(a)
2123
+ elif _can_attempt_auth_repair(a, mail_domain_suffix):
2124
+ skip_reason = _auth_repair_skip_reason(a, force=force_auth_repair)
2125
+ if skip_reason:
2126
+ skipped_repairs.append((a["email"], skip_reason))
2127
+ continue
2128
+ no_auth_list.append(a)
2129
+
2130
+ if skipped_repairs:
2131
+ logger.info("[检查] 跳过 %d 个处于冷却/暂停中的认证修复账号:", len(skipped_repairs))
2132
+ for email, reason in skipped_repairs:
2133
+ logger.info("[检查] %s(%s)", email, reason)
2134
 
2135
  if not active_with_auth and not no_auth_list:
2136
+ logger.info("[检查] 没有可检查或可修复的账号")
2137
  return []
2138
 
2139
  # 检查有认证文件的账号额度
 
2144
  logger.info("[检查] 检查 %d 个 active 账号的额度...", len(active_with_auth))
2145
  for acc in active_with_auth:
2146
  email = acc["email"]
2147
+ was_auth_pending = _is_auth_repair_pending_status(acc.get("status"))
2148
  status_str, info = _check_and_refresh(acc)
2149
 
2150
  if status_str == "ok":
 
2159
  update_account(email, last_quota=info)
2160
  # 低于阈值视为用完
2161
  if p_remain < threshold:
2162
+ if preserve_low_active and not was_auth_pending:
2163
+ if preserved_low_accounts is not None:
2164
+ preserved_low_accounts.append(
2165
+ {
2166
+ "email": email,
2167
+ "remaining": p_remain,
2168
+ "quota": info,
2169
+ }
2170
+ )
2171
+ logger.warning(
2172
+ "[%s] 5h剩余 %d%% < %d%%,先移后补模式暂不标记 exhausted (重置 %s)",
2173
+ email,
2174
+ p_remain,
2175
+ threshold,
2176
+ p_time,
2177
+ )
2178
+ continue
2179
  resets_at = p_reset or (time.time() + 18000)
2180
  logger.warning(
2181
  "[%s] 5h剩余 %d%% < %d%%,标记为 exhausted (重置 %s)", email, p_remain, threshold, p_time
 
2188
  )
2189
  exhausted_list.append(acc)
2190
  else:
2191
+ _auth_repair_reset(email)
2192
+ if was_auth_pending:
2193
+ update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
2194
+ logger.info(
2195
+ "[%s] 认证已恢复 - 5h剩余: %d%% (重置 %s) | 周剩余: %d%% (重置 %s)",
2196
+ email,
2197
+ p_remain,
2198
+ p_time,
2199
+ w_remain,
2200
+ w_time,
2201
+ )
2202
+ continue
2203
  logger.info(
2204
  "[%s] 额度可用 - 5h剩余: %d%% (重置 %s) | 周剩余: %d%% (重置 %s)",
2205
  email,
 
2209
  w_time,
2210
  )
2211
  else:
2212
+ _auth_repair_reset(email)
2213
  logger.info("[%s] 额度可用", email)
2214
  elif status_str == "exhausted":
2215
  quota_info = quota_result_quota_info(info) or {}
 
2272
  else:
2273
  logger.info("[%s] token 失效但 5h 重置时间已过,需重新登录验证", email)
2274
  logger.warning("[%s] 认证失败,需要重新登录 Codex", email)
2275
+ skip_reason = _auth_repair_skip_reason(acc, force=force_auth_repair)
2276
+ if skip_reason:
2277
+ skipped_repairs.append((email, skip_reason))
2278
+ else:
2279
+ auth_error_list.append(acc)
2280
+ elif status_str == "no_auth":
2281
+ skip_reason = _auth_repair_skip_reason(acc, force=force_auth_repair)
2282
+ if skip_reason:
2283
+ skipped_repairs.append((email, skip_reason))
2284
+ else:
2285
+ auth_error_list.append(acc)
2286
  elif status_str == "network_error":
2287
+ historical_low = _historical_low_quota_info(acc, threshold)
2288
+ if historical_low:
2289
+ quota_info = quota_result_quota_info(historical_low) or acc.get("last_quota")
2290
+ remaining = int(historical_low.get("remaining", 0) or 0)
2291
+ resets_at = quota_result_resets_at(historical_low) or int(time.time() + 18000)
2292
+ reset_time = time.strftime("%m-%d %H:%M", time.localtime(resets_at)) if resets_at else "?"
2293
+ if preserve_low_active and not was_auth_pending:
2294
+ if preserved_low_accounts is not None:
2295
+ preserved_low_accounts.append(
2296
+ {
2297
+ "email": email,
2298
+ "remaining": remaining,
2299
+ "quota": quota_info,
2300
+ }
2301
+ )
2302
+ logger.warning(
2303
+ "[%s] 额度接口暂时不可达,但历史 5h 剩余 %d%% < %d%%,先移后补模式纳入轮换候选 (重置 %s)",
2304
+ email,
2305
+ remaining,
2306
+ threshold,
2307
+ reset_time,
2308
+ )
2309
+ continue
2310
+ logger.warning(
2311
+ "[%s] 额度接口暂时不可达,但历史 5h 剩余 %d%% < %d%%,标记为 exhausted (重置 %s)",
2312
+ email,
2313
+ remaining,
2314
+ threshold,
2315
+ reset_time,
2316
+ )
2317
+ update_account(
2318
+ email,
2319
+ status=STATUS_EXHAUSTED,
2320
+ last_quota=quota_info,
2321
+ quota_exhausted_at=time.time(),
2322
+ quota_resets_at=resets_at,
2323
+ )
2324
+ exhausted_list.append(acc)
2325
+ continue
2326
+ logger.warning("[%s] 额度接口暂时不可达,保留当前凭证和席位状态,等待下一轮复查", email)
2327
 
2328
+ # 无认证文件的 Team 账号也需要重新登录
2329
  if no_auth_list:
2330
+ logger.info("[检查] 发现 %d 个 Team 账号无认证文件,需要登录 Codex:", len(no_auth_list))
2331
  for a in no_auth_list:
2332
  logger.info("[检查] %s", a["email"])
2333
  auth_error_list.extend(no_auth_list)
 
2361
  auth_error_list = survivable_auth_errors
2362
 
2363
  if auth_error_list:
2364
+ logger.info("[检查] 重新登录 %d 个认证失效/待修复的账号...", len(auth_error_list))
2365
+ mail_clients = {}
 
2366
  for acc in auth_error_list:
2367
  email = acc["email"]
2368
  password = acc.get("password", "")
2369
  logger.info("[%s] 重新 Codex 登录...", email)
2370
+ provider = (acc.get("mail_provider") or get_mail_provider_name()).strip().lower()
2371
+ mail_client = mail_clients.get(provider)
2372
+ if mail_client is None:
2373
+ mail_client = _get_account_mail_client(acc)
2374
+ mail_clients[provider] = mail_client
2375
  auth_proxy_url, playwright_proxy_url = _ensure_account_ipv6_proxy(email)
2376
+ login_kwargs = {"mail_client": mail_client}
2377
+ if playwright_proxy_url:
2378
+ login_kwargs["playwright_proxy_url"] = playwright_proxy_url
2379
+ login_result = _login_codex_with_result(email, password, **login_kwargs)
2380
+ bundle = login_result.get("bundle")
2381
+ if login_result.get("ok") and bundle:
 
2382
  _attach_account_proxy_to_bundle(email, bundle, auth_proxy_url)
2383
  auth_file = save_auth_file(bundle)
2384
  update_account(email, auth_file=auth_file)
2385
+ _auth_repair_reset(email)
2386
  logger.info("[%s] token 已更新", email)
2387
  # 重新检查额度
2388
  status_str, info = _check_and_refresh(find_account(load_accounts(), email))
 
2412
  )
2413
  exhausted_list.append(acc)
2414
  else:
2415
+ _auth_repair_reset(email)
2416
+ update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
2417
  logger.info("[%s] 额度可用 (%d%%)", email, p_remain)
2418
  elif status_str == "ok":
2419
+ _auth_repair_reset(email)
2420
+ update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
2421
  logger.info("[%s] 额度可用", email)
2422
  elif status_str == "auth_error":
2423
+ result = _record_auth_repair_failure(
2424
+ email,
2425
+ login_result.get("error_type") or "non_team_plan",
2426
+ login_result.get("error_detail") or "重新登录后仍无法查询额度",
2427
+ )
2428
+ extra = _auth_repair_result_suffix(result)
2429
+ logger.warning(
2430
+ "[%s] 重新登录后仍无法查询额度(可能未选中 Team workspace),标记为 %s%s",
2431
+ email,
2432
+ result.get("status"),
2433
+ extra,
2434
+ )
2435
+ elif status_str == "network_error":
2436
+ _auth_repair_reset(email)
2437
+ update_account(email, status=STATUS_ACTIVE, last_active_at=time.time())
2438
+ logger.warning("[%s] 重新登录成功,但额度接口暂时不可达;保留 active 并等待下一轮复查", email)
2439
  else:
2440
+ result = _record_auth_repair_failure(
2441
+ email,
2442
+ login_result.get("error_type"),
2443
+ login_result.get("error_detail"),
2444
+ )
2445
+ extra = _auth_repair_result_suffix(result)
2446
+ logger.error(
2447
+ "[%s] Codex 登录失败,标记为 %s(%s%s)",
2448
+ email,
2449
+ result.get("status"),
2450
+ _auth_repair_error_label(result.get("auth_last_error")),
2451
+ extra,
2452
+ )
2453
 
2454
  # 已 exhausted 但 5h 重置时间已过 → 复测,真的回血则 promote 回 active,避免轮转
2455
  # 多走一遍"kick → standby → re-invite"。token 仍在 Team 里时这个直接 promote 路径
 
3434
  return client
3435
 
3436
 
3437
+ def _get_account_mail_client(acc: dict | None):
3438
+ """Compatibility wrapper for auth-repair paths that route mail per account."""
3439
+ return _get_mail_client_for_account(acc)
3440
+
3441
+
3442
  def _resolve_mail_client_or_default(mail_client, acc=None):
3443
  """统一处理 mail_client=None 入参:按 acc 路由或走全局默认。
3444
 
 
3588
  password = acc.get("password") or random_password()
3589
  else:
3590
  password = random_password()
3591
+ add_account(
3592
+ inv_email,
3593
+ password,
3594
+ workspace_account_id=get_chatgpt_account_id() or None,
3595
+ mail_provider=_mail_provider_name_for_client(mail_client),
3596
+ )
3597
 
3598
  # 关闭 ChatGPT 浏览器再注册
3599
  chatgpt_api.stop()
 
3635
  )
3636
  _DIRECT_PASSWORD_SELECTORS = 'input[name="password"], input[type="password"]'
3637
  _DIRECT_CODE_SELECTORS = 'input[name="code"], input[placeholder*="验证码"], input[placeholder*="code" i]'
3638
+ _DIRECT_MULTI_CODE_SELECTOR = 'input[maxlength="1"]'
3639
+ _DIRECT_CODE_RENDER_TIMEOUT = 25
3640
 
3641
 
3642
  def _safe_invite_screenshot(page, name):
 
3679
  return exhausted_info
3680
 
3681
 
3682
+ def _historical_low_quota_info(acc, threshold, now=None):
3683
+ """Return a low-quota decision from saved quota when live quota is unavailable."""
3684
+ quota_info = acc.get("last_quota") if isinstance(acc, dict) else None
3685
+ if not isinstance(quota_info, dict):
3686
+ return None
3687
+
3688
+ current_ts = time.time() if now is None else now
3689
+ exhausted_info = _pending_historical_exhausted_info(quota_info, now=current_ts)
3690
+ if exhausted_info:
3691
+ result = dict(exhausted_info)
3692
+ result["remaining"] = 0
3693
+ result["historical_low"] = True
3694
+ return result
3695
+
3696
  try:
3697
+ primary_pct = int(quota_info.get("primary_pct", 0) or 0)
3698
+ threshold_int = int(threshold)
3699
+ except Exception:
3700
+ return None
3701
+
3702
+ remaining = max(0, 100 - primary_pct)
3703
+ if remaining >= threshold_int:
3704
+ return None
3705
+
3706
+ try:
3707
+ primary_resets_at = int(quota_info.get("primary_resets_at", 0) or 0)
3708
  except Exception:
3709
+ primary_resets_at = 0
3710
+ if primary_resets_at and current_ts >= primary_resets_at:
3711
  return None
3712
+
3713
+ return {
3714
+ "window": "primary",
3715
+ "resets_at": primary_resets_at or int(current_ts + 18000),
3716
+ "quota_info": quota_info,
3717
+ "remaining": remaining,
3718
+ "historical_low": True,
3719
+ }
3720
+
3721
+
3722
+ def _first_visible_editable_locator(page, selectors, timeout=800):
3723
+ candidates = selectors if isinstance(selectors, (list, tuple)) else [selectors]
3724
+ for selector in candidates:
3725
+ try:
3726
+ locator = page.locator(selector).first
3727
+ if not locator.is_visible(timeout=timeout):
3728
+ continue
3729
+ if locator.is_editable(timeout=timeout):
3730
+ return locator
3731
+ except Exception:
3732
+ continue
3733
  return None
3734
 
3735
 
3736
+ def _visible_single_char_code_inputs(page, timeout=300):
3737
+ try:
3738
+ visible_inputs = []
3739
+ for locator in page.locator(_DIRECT_MULTI_CODE_SELECTOR).all():
3740
+ try:
3741
+ if locator.is_visible(timeout=timeout) and locator.is_editable(timeout=timeout):
3742
+ visible_inputs.append(locator)
3743
+ except Exception:
3744
+ continue
3745
+ if len(visible_inputs) >= 4:
3746
+ return visible_inputs
3747
+ except Exception:
3748
+ pass
3749
+ return []
3750
+
3751
+
3752
+ def _wait_for_direct_code_target(page, timeout=_DIRECT_CODE_RENDER_TIMEOUT):
3753
+ deadline = time.time() + max(0.0, float(timeout))
3754
+
3755
+ while time.time() < deadline:
3756
+ split_inputs = _visible_single_char_code_inputs(page, timeout=300)
3757
+ if split_inputs:
3758
+ return {"mode": "split", "target": split_inputs}
3759
+
3760
+ code_input = _first_visible_editable_locator(page, _DIRECT_CODE_SELECTORS, timeout=300)
3761
+ if code_input:
3762
+ return {"mode": "single", "target": code_input}
3763
+
3764
+ step = _detect_direct_register_step(page)
3765
+ if step != "code":
3766
+ return {"mode": "advanced", "step": step}
3767
+ time.sleep(0.5)
3768
+
3769
+ step = _detect_direct_register_step(page)
3770
+ if step != "code":
3771
+ return {"mode": "advanced", "step": step}
3772
+ return {"mode": "timeout", "step": "code"}
3773
+
3774
+
3775
+ def _submit_direct_verification_code(page, code_target, verification_code):
3776
+ mode = (code_target or {}).get("mode")
3777
+ target = (code_target or {}).get("target")
3778
+ submit_field = None
3779
+ current_step = _detect_direct_register_step(page)
3780
+
3781
+ if mode == "split" and isinstance(target, list):
3782
+ for index, char in enumerate(verification_code):
3783
+ if index >= len(target):
3784
+ break
3785
+ target[index].fill(char)
3786
+ time.sleep(0.1)
3787
+ if target:
3788
+ submit_field = target[0]
3789
+ elif mode == "single" and target:
3790
+ target.fill(verification_code)
3791
+ submit_field = target
3792
+ else:
3793
+ return _detect_direct_register_step(page)
3794
+
3795
+ time.sleep(0.5)
3796
+ if submit_field is not None:
3797
+ _click_primary_auth_button(page, submit_field, ["Continue", "继续", "Verify"])
3798
+ return _wait_for_direct_step_change(page, current_step, timeout=20)
3799
+
3800
+
3801
  def _collect_date_spinbutton_meta(page):
3802
  try:
3803
  return page.evaluate(
 
3919
  url = (page.url or "").lower()
3920
  if _is_google_redirect(page):
3921
  return "google"
3922
+ if "/api/auth/error" in url or url.endswith("/auth/error"):
3923
+ return "error"
3924
 
3925
  if "email-verification" in url:
3926
  return "code"
 
3966
  deadline = time.time() + timeout
3967
  while time.time() < deadline:
3968
  step = _detect_direct_register_step(page)
3969
+ if step == "error":
3970
+ return step
3971
  if step in allowed_steps:
3972
  return step
3973
  time.sleep(0.5)
 
4265
  logger.warning("[直接注册] 邮箱步骤仍停留在 Google 登录页")
4266
  cleanup_direct_register()
4267
  return False, None
4268
+ if current_step == "error":
4269
+ logger.warning("[直接注册] 邮箱步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
4270
+ cleanup_direct_register()
4271
+ return False, None
4272
+ if current_step == "unknown":
4273
+ logger.warning("[直接注册] 邮箱步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page))
4274
+ cleanup_direct_register()
4275
+ return False, None
4276
  if current_step == "email":
4277
  logger.warning("[直接注册] 邮箱步骤未推进 | URL: %s | body=%s", page.url, _page_excerpt(page))
4278
  cleanup_direct_register()
 
4287
  # 等待页面跳转完成(可能跳到 create-account/password)
4288
  password_step = _wait_for_direct_register_step(
4289
  page,
4290
+ {"password", "code", "profile", "completed", "google", "email", "error"},
4291
  timeout=15,
4292
  )
4293
  logger.info("[直接注册] 密码页检测状态: %s | URL: %s", password_step, page.url)
4294
  _safe_invite_screenshot(page, "direct_03b_before_password.png")
4295
+ if password_step == "error":
4296
+ logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
4297
+ cleanup_direct_register()
4298
+ return False, None
4299
+ if password_step == "unknown":
4300
+ logger.warning("[直接注册] 无法识别密码/验证码步骤 | URL: %s | body=%s", page.url, _page_excerpt(page))
4301
+ cleanup_direct_register()
4302
+ return False, None
4303
 
4304
  try:
4305
  for attempt in range(2):
 
4348
  logger.warning("[直接注册] 密码步骤仍停留在 Google 登录页")
4349
  cleanup_direct_register()
4350
  return False, None
4351
+ if current_step == "error":
4352
+ logger.warning("[直接注册] 密码步骤进入认证错误页 | URL: %s | body=%s", page.url, _page_excerpt(page))
4353
+ cleanup_direct_register()
4354
+ return False, None
4355
+ if current_step == "unknown":
4356
+ logger.warning("[直接注册] 密码步骤进入未知状态 | URL: %s | body=%s", page.url, _page_excerpt(page))
4357
+ cleanup_direct_register()
4358
+ return False, None
4359
  if current_step == "email":
4360
  logger.warning("[直接注册] 提交密码前流程回退到邮箱页 | URL: %s | body=%s", page.url, _page_excerpt(page))
4361
  cleanup_direct_register()
 
4367
  cleanup_direct_register()
4368
  raise
4369
 
4370
+ code_step = _detect_direct_register_step(page)
4371
+ code_target = None
4372
+ if code_step == "code":
4373
+ logger.info("[直接注册] 等待验证码输入框渲染...")
4374
+ code_target_result = _wait_for_direct_code_target(page, timeout=_DIRECT_CODE_RENDER_TIMEOUT)
4375
+ mode = code_target_result.get("mode")
4376
+ if mode in {"single", "split"}:
4377
+ code_target = code_target_result
4378
+ logger.info("[直接注册] 验证码输入框已就绪(mode=%s)", mode)
4379
+ elif mode == "advanced":
4380
+ logger.info(
4381
+ "[直接注册] 验证码页等待期间流程已推进到 %s | URL: %s",
4382
+ code_target_result.get("step"),
4383
+ page.url,
4384
+ )
4385
+ else:
4386
+ logger.warning(
4387
+ "[直接注册] 已停留在 email-verification 但 %ss 内未就绪验证码输入框 | URL: %s",
4388
+ _DIRECT_CODE_RENDER_TIMEOUT,
4389
+ page.url,
4390
+ )
4391
 
4392
+ if code_target:
4393
  logger.info("[直接注册] 等待验证码...")
4394
  verification_code = None
4395
  start_t = time.time()
 
4407
 
4408
  if verification_code:
4409
  logger.info("[直接注册] 输入验证码: %s", verification_code)
4410
+ next_step = _submit_direct_verification_code(page, code_target, verification_code)
4411
+ logger.info("[直接注册] 验证码提交后状态: %s | URL: %s", next_step, page.url)
 
 
4412
  else:
4413
  logger.error("[直接注册] 未收到验证码")
4414
  cleanup_direct_register()
 
4928
  playwright_proxy_url = signup.get("playwright_proxy_url") or ""
4929
  winner_mail_client = signup.get("mail_client") or mail_client
4930
 
4931
+ add_account(
4932
+ email,
4933
+ password,
4934
+ cloudmail_account_id=account_id,
4935
+ workspace_account_id=get_chatgpt_account_id() or None,
4936
+ mail_provider=_mail_provider_name_for_client(winner_mail_client),
4937
+ mail_account_id=account_id,
4938
+ )
4939
 
4940
  post_result = _run_post_register_oauth(
4941
  email,
 
7156
  return result
7157
 
7158
 
7159
+ def cmd_reset_quota_recovery():
7160
+ """清空所有托管非主号账号的本地额度恢复记录。"""
7161
+ accounts = load_accounts()
7162
+ if not accounts:
7163
+ summary = {
7164
+ "total_accounts": 0,
7165
+ "updated_accounts": 0,
7166
+ "rearmed_exhausted_to_active": 0,
7167
+ "rearmed_exhausted_to_auth_pending": 0,
7168
+ }
7169
+ logger.info("[额度重置] 本地无账号记录")
7170
+ return summary
7171
+
7172
+ total_accounts = 0
7173
+ updated_accounts = 0
7174
+ rearmed_to_active = 0
7175
+ rearmed_to_auth_pending = 0
7176
+
7177
+ for acc in accounts:
7178
+ email = acc.get("email", "")
7179
+ if _is_main_account_email(email):
7180
+ continue
7181
+
7182
+ total_accounts += 1
7183
+ changed = False
7184
+
7185
+ for key in ("last_quota", "quota_resets_at", "quota_exhausted_at", "quota_window"):
7186
+ if acc.get(key) is not None:
7187
+ acc[key] = None
7188
+ changed = True
7189
+
7190
+ if acc.get("status") == STATUS_EXHAUSTED:
7191
+ desired_status = STATUS_ACTIVE if _has_auth_file(acc) else STATUS_AUTH_PENDING
7192
+ if acc.get("status") != desired_status:
7193
+ acc["status"] = desired_status
7194
+ changed = True
7195
+ if desired_status == STATUS_ACTIVE:
7196
+ rearmed_to_active += 1
7197
+ else:
7198
+ rearmed_to_auth_pending += 1
7199
+
7200
+ if changed:
7201
+ updated_accounts += 1
7202
+
7203
+ if updated_accounts:
7204
+ save_accounts(accounts)
7205
+
7206
+ summary = {
7207
+ "total_accounts": total_accounts,
7208
+ "updated_accounts": updated_accounts,
7209
+ "rearmed_exhausted_to_active": rearmed_to_active,
7210
+ "rearmed_exhausted_to_auth_pending": rearmed_to_auth_pending,
7211
+ }
7212
+ logger.info(
7213
+ "[额度重置] 完成: 扫描 %d 个账号,更新 %d 个,恢复 exhausted -> active %d 个,exhausted -> auth_pending %d 个",
7214
+ total_accounts,
7215
+ updated_accounts,
7216
+ rearmed_to_active,
7217
+ rearmed_to_auth_pending,
7218
+ )
7219
+ return summary
7220
+
7221
+
7222
  def _reconcile_master_degraded_subaccounts(*, dry_run: bool = False, chatgpt_api=None):
7223
  """Round 8 — SPEC-2 v1.5 §3.5.3:reconcile retroactive 清理。
7224
 
 
7341
  cleanup_p = sub.add_parser("cleanup", help="清理多余成员(只移除本地管理的)")
7342
  cleanup_p.add_argument("max_seats", type=int, nargs="?", default=None, help="最大席位数")
7343
 
7344
+ sub.add_parser("reset-quota", help="清空本地额度恢复记录,并把 exhausted 账号恢复为可检查状态")
7345
  sub.add_parser("sync", help="手动同步认证文件到 CPA")
7346
  sub.add_parser("pull-cpa", help="从 CPA 反向同步认证文件到本地")
7347
 
 
7398
  cmd_fill(args.target)
7399
  elif args.command == "cleanup":
7400
  cmd_cleanup(args.max_seats)
7401
+ elif args.command == "reset-quota":
7402
+ cmd_reset_quota_recovery()
7403
  elif args.command == "sync":
7404
  sync_to_cpa()
7405
  elif args.command == "pull-cpa":
tests/unit/test_cpa_sync.py CHANGED
@@ -3,6 +3,7 @@ from pathlib import Path
3
  import pytest
4
 
5
  from autoteam import cpa_sync
 
6
 
7
 
8
  def test_list_cpa_files_raises_on_non_200(monkeypatch):
@@ -33,6 +34,17 @@ def test_list_cpa_files_raises_on_non_json(monkeypatch):
33
  cpa_sync.list_cpa_files()
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  def test_sync_to_cpa_skips_disabled_accounts_and_keeps_protected_remote(monkeypatch, tmp_path):
37
  enabled_auth = tmp_path / "codex-enabled@example.com-team-a.json"
38
  disabled_auth = tmp_path / "codex-disabled@example.com-team-b.json"
@@ -69,6 +81,7 @@ def test_sync_to_cpa_skips_disabled_accounts_and_keeps_protected_remote(monkeypa
69
 
70
  uploaded = []
71
  deleted = []
 
72
  monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
73
  monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
74
 
@@ -204,6 +217,102 @@ def test_sync_to_cpa_preserves_credential_seat_when_remote_delete_is_allowed(mon
204
  assert result["delete_guard"]["skipped_protected"] == 1
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def test_sync_to_cpa_refreshes_proxy_url_before_upload(monkeypatch, tmp_path):
208
  auth_file = tmp_path / "codex-enabled@example.com-team-a.json"
209
  auth_file.write_text('{"email":"enabled@example.com","access_token":"token-enabled"}', encoding="utf-8")
@@ -222,6 +331,7 @@ def test_sync_to_cpa_refreshes_proxy_url_before_upload(monkeypatch, tmp_path):
222
  monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
223
  monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
224
  monkeypatch.setattr(cpa_sync, "list_cpa_files", lambda: [])
 
225
  monkeypatch.setattr(
226
  "autoteam.ipv6_pool.ipv6_pool.ensure",
227
  lambda _email: "socks5://proxy.example:30000",
 
3
  import pytest
4
 
5
  from autoteam import cpa_sync
6
+ from autoteam import mail as mail_module
7
 
8
 
9
  def test_list_cpa_files_raises_on_non_200(monkeypatch):
 
34
  cpa_sync.list_cpa_files()
35
 
36
 
37
+ def test_infer_mail_provider_from_email_uses_matching_domain(monkeypatch):
38
+ monkeypatch.setenv("CLOUDMAIL_DOMAIN", "mail.example.com")
39
+ monkeypatch.setenv("MAILLAB_DOMAIN", "lab.example.com")
40
+ monkeypatch.setenv("ADDY_IO_DOMAIN", "alias.example.com")
41
+
42
+ assert mail_module.infer_mail_provider_from_email("user@mail.example.com") == "cf_temp_email"
43
+ assert mail_module.infer_mail_provider_from_email("user@lab.example.com") == "maillab"
44
+ assert mail_module.infer_mail_provider_from_email("user@alias.example.com") == "addy_io"
45
+ assert mail_module.infer_mail_provider_from_email("user@unknown.example.com") == ""
46
+
47
+
48
  def test_sync_to_cpa_skips_disabled_accounts_and_keeps_protected_remote(monkeypatch, tmp_path):
49
  enabled_auth = tmp_path / "codex-enabled@example.com-team-a.json"
50
  disabled_auth = tmp_path / "codex-disabled@example.com-team-b.json"
 
81
 
82
  uploaded = []
83
  deleted = []
84
+ monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("ok", {}))
85
  monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
86
  monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
87
 
 
217
  assert result["delete_guard"]["skipped_protected"] == 1
218
 
219
 
220
+ def test_sync_to_cpa_skips_exhausted_active_credential_before_upload(monkeypatch, tmp_path):
221
+ first_auth = tmp_path / "codex-first@example.com-team-a.json"
222
+ second_auth = tmp_path / "codex-second@example.com-team-b.json"
223
+ exhausted_auth = tmp_path / "codex-exhausted@example.com-team-c.json"
224
+ first_auth.write_text('{"email":"first@example.com","access_token":"token-first"}', encoding="utf-8")
225
+ second_auth.write_text('{"email":"second@example.com","access_token":"token-second"}', encoding="utf-8")
226
+ exhausted_auth.write_text('{"email":"exhausted@example.com","access_token":"token-exhausted"}', encoding="utf-8")
227
+
228
+ monkeypatch.setattr(
229
+ "autoteam.accounts.load_accounts",
230
+ lambda: [
231
+ {
232
+ "email": "first@example.com",
233
+ "status": "active",
234
+ "auth_file": str(first_auth),
235
+ "disabled": False,
236
+ "mail_provider": "cf_temp_email",
237
+ "mail_account_id": "mail-1",
238
+ },
239
+ {
240
+ "email": "second@example.com",
241
+ "status": "active",
242
+ "auth_file": str(second_auth),
243
+ "disabled": False,
244
+ "mail_provider": "cf_temp_email",
245
+ "mail_account_id": "mail-2",
246
+ },
247
+ {
248
+ "email": "exhausted@example.com",
249
+ "status": "active",
250
+ "auth_file": str(exhausted_auth),
251
+ "disabled": False,
252
+ "mail_provider": "cf_temp_email",
253
+ "mail_account_id": "mail-3",
254
+ },
255
+ ],
256
+ )
257
+ monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
258
+ monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
259
+ monkeypatch.setattr(
260
+ cpa_sync,
261
+ "list_cpa_files",
262
+ lambda: [
263
+ {"name": first_auth.name, "email": "first@example.com"},
264
+ {"name": second_auth.name, "email": "second@example.com"},
265
+ {"name": exhausted_auth.name, "email": "exhausted@example.com"},
266
+ ],
267
+ )
268
+
269
+ def fake_quota(token, **_kwargs):
270
+ if token == "token-exhausted":
271
+ return "exhausted", {"primary_pct": 0}
272
+ return "ok", {"primary_pct": 50}
273
+
274
+ uploaded = []
275
+ deleted = []
276
+ monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", fake_quota)
277
+ monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
278
+ monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
279
+
280
+ result = cpa_sync.sync_to_cpa()
281
+
282
+ assert uploaded == [first_auth.name, second_auth.name]
283
+ assert deleted == [exhausted_auth.name]
284
+ assert result["synced_active"] == 2
285
+ assert result["active_publish"]["delete_remote"] == 1
286
+ assert result["delete_guard"]["allow_remote_delete"] is True
287
+
288
+
289
+ def test_sync_to_cpa_keeps_remote_on_active_quota_network_error(monkeypatch, tmp_path):
290
+ auth_file = tmp_path / "codex-active@example.com-team-a.json"
291
+ auth_file.write_text('{"email":"active@example.com","access_token":"token-active"}', encoding="utf-8")
292
+
293
+ monkeypatch.setattr(
294
+ "autoteam.accounts.load_accounts",
295
+ lambda: [{"email": "active@example.com", "status": "active", "auth_file": str(auth_file), "disabled": False}],
296
+ )
297
+ monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
298
+ monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
299
+ monkeypatch.setattr(cpa_sync, "list_cpa_files", lambda: [{"name": auth_file.name, "email": "active@example.com"}])
300
+
301
+ uploaded = []
302
+ deleted = []
303
+ monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("network_error", {}))
304
+ monkeypatch.setattr(cpa_sync, "upload_to_cpa", lambda path: uploaded.append(Path(path).name) or True)
305
+ monkeypatch.setattr(cpa_sync, "delete_from_cpa", lambda name: deleted.append(name) or True)
306
+
307
+ result = cpa_sync.sync_to_cpa()
308
+
309
+ assert uploaded == []
310
+ assert deleted == []
311
+ assert result["synced_active"] == 0
312
+ assert result["active_publish"]["kept_remote"] == 1
313
+ assert result["delete_guard"]["allow_remote_delete"] is False
314
+
315
+
316
  def test_sync_to_cpa_refreshes_proxy_url_before_upload(monkeypatch, tmp_path):
317
  auth_file = tmp_path / "codex-enabled@example.com-team-a.json"
318
  auth_file.write_text('{"email":"enabled@example.com","access_token":"token-enabled"}', encoding="utf-8")
 
331
  monkeypatch.setattr("autoteam.accounts.save_accounts", lambda _accounts: None)
332
  monkeypatch.setattr(cpa_sync, "_cleanup_local_duplicates", lambda _accounts: (0, False))
333
  monkeypatch.setattr(cpa_sync, "list_cpa_files", lambda: [])
334
+ monkeypatch.setattr("autoteam.codex_auth.check_codex_quota", lambda *_args, **_kwargs: ("ok", {}))
335
  monkeypatch.setattr(
336
  "autoteam.ipv6_pool.ipv6_pool.ensure",
337
  lambda _email: "socks5://proxy.example:30000",
tests/unit/test_free_registration_hardening.py CHANGED
@@ -74,6 +74,152 @@ class _FakeKeyboard:
74
  self.page.active_element.typed.append(value)
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  class _FakeOAuthAboutYouPage:
78
  def __init__(self, *, spinbuttons=True, expected_order=None):
79
  self.url = "https://auth.openai.com/about-you"
@@ -226,6 +372,14 @@ def test_create_account_direct_reuses_signup_profile_for_register_and_oauth(monk
226
  assert captured["oauth"]["leave_workspace"] is True
227
 
228
 
 
 
 
 
 
 
 
 
229
  def test_create_account_direct_races_signup_workers_and_uses_winner(monkeypatch):
230
  calls = []
231
  added = []
 
74
  self.page.active_element.typed.append(value)
75
 
76
 
77
+ class _FakeOtpInput:
78
+ def __init__(self, *, visible=True, text=""):
79
+ self.visible = visible
80
+ self.text = text
81
+ self.filled_values = []
82
+ self.clicked = False
83
+
84
+ def is_visible(self, timeout=0):
85
+ return self.visible
86
+
87
+ def fill(self, value):
88
+ self.filled_values.append(value)
89
+
90
+ def click(self, timeout=0, force=False):
91
+ self.clicked = True
92
+
93
+ def type(self, value, delay=0):
94
+ self.filled_values.append(value)
95
+
96
+ def inner_text(self, timeout=0):
97
+ return self.text
98
+
99
+
100
+ class _FakeOtpCollection:
101
+ def __init__(self, items=None, text=None):
102
+ self._items = list(items or [])
103
+ self._text = text
104
+
105
+ @property
106
+ def first(self):
107
+ if self._items:
108
+ return self._items[0]
109
+ return _FakeOtpInput(visible=False)
110
+
111
+ def all(self):
112
+ return list(self._items)
113
+
114
+ def inner_text(self, timeout=0):
115
+ if self._text is None:
116
+ raise AssertionError("unexpected inner_text call")
117
+ return self._text
118
+
119
+
120
+ class _FakeOtpPage:
121
+ def __init__(self, *, url="https://auth.openai.com/email-verification", body="", slot_inputs=None, otp_input=None):
122
+ self.url = url
123
+ self._body = body
124
+ self._slot_inputs = list(slot_inputs or [])
125
+ self._otp_input = otp_input or _FakeOtpInput(visible=False)
126
+ self.submit_button = _FakeOtpInput(visible=True)
127
+ self.keyboard = type("_Keyboard", (), {"type": lambda _self, *_args, **_kwargs: None})()
128
+
129
+ def locator(self, selector):
130
+ if selector == "body":
131
+ return _FakeOtpCollection(text=self._body)
132
+ if selector == codex_auth._OTP_SINGLE_INPUT_SELECTORS:
133
+ return _FakeOtpCollection(items=self._slot_inputs)
134
+ if selector == codex_auth._OTP_INPUT_SELECTORS:
135
+ return _FakeOtpCollection(items=[self._otp_input])
136
+ if selector in {
137
+ 'button[type="submit"]',
138
+ 'button:has-text("Continue")',
139
+ 'button:has-text("继续")',
140
+ 'button:has-text("Verify")',
141
+ }:
142
+ return _FakeOtpCollection(items=[self.submit_button])
143
+ return _FakeOtpCollection(items=[])
144
+
145
+
146
+ class _FakeChooseAccountPage:
147
+ _GENERIC_SELECTORS = {
148
+ "button",
149
+ "a",
150
+ '[role="button"]',
151
+ '[role="option"]',
152
+ '[aria-selected="true"]',
153
+ '[aria-selected="false"]',
154
+ "[data-state]",
155
+ "li",
156
+ "label",
157
+ "div",
158
+ }
159
+
160
+ def __init__(self, *, account_elements, continue_button=None):
161
+ self.url = "https://auth.openai.com/choose-an-account"
162
+ self._account_elements = list(account_elements)
163
+ self._continue_button = continue_button or _FakeOtpInput(visible=False)
164
+
165
+ def locator(self, selector):
166
+ if selector == "body":
167
+ return _FakeOtpCollection(text="Choose an account Continue as user@example.com")
168
+ if selector == 'button:has-text("Continue"), button:has-text("继续"), button:has-text("Allow")':
169
+ return _FakeOtpCollection(items=[self._continue_button])
170
+ if selector in self._GENERIC_SELECTORS:
171
+ return _FakeOtpCollection(items=self._account_elements)
172
+ return _FakeOtpCollection(items=[])
173
+
174
+
175
+ def test_codex_otp_helper_fills_segmented_inputs():
176
+ slots = [_FakeOtpInput() for _ in range(6)]
177
+ page = _FakeOtpPage(slot_inputs=slots)
178
+
179
+ assert codex_auth._fill_otp_code(page, "481556") is True
180
+
181
+ assert [slot.filled_values[-1] for slot in slots] == list("481556")
182
+
183
+
184
+ def test_codex_resolve_email_verification_marks_used_after_success(monkeypatch):
185
+ slots = [_FakeOtpInput() for _ in range(6)]
186
+ page = _FakeOtpPage(slot_inputs=slots)
187
+ used_email_ids = set()
188
+
189
+ monkeypatch.setattr(codex_auth, "_poll_mail_verification_code", lambda *args, **kwargs: ("481556", 1888))
190
+ monkeypatch.setattr(codex_auth, "_wait_for_otp_submit_result", lambda *args, **kwargs: ("accepted", None))
191
+ monkeypatch.setattr(codex_auth.time, "sleep", lambda *_args, **_kwargs: None)
192
+
193
+ status = codex_auth._resolve_email_verification(
194
+ page,
195
+ mail_client=object(),
196
+ email="user@example.com",
197
+ after_email_id=1000,
198
+ used_email_ids=used_email_ids,
199
+ wait_log="[Codex] test wait emailId > %d",
200
+ )
201
+
202
+ assert status == "accepted"
203
+ assert used_email_ids == {1888}
204
+ assert [slot.filled_values[-1] for slot in slots] == list("481556")
205
+ assert page.submit_button.clicked is True
206
+
207
+
208
+ def test_codex_select_oauth_account_clicks_matching_email(monkeypatch):
209
+ other = _FakeOtpInput(text="other@example.com")
210
+ target = _FakeOtpInput(text="user@example.com")
211
+ confirm = _FakeOtpInput(text="Continue")
212
+ page = _FakeChooseAccountPage(account_elements=[other, target], continue_button=confirm)
213
+
214
+ monkeypatch.setattr(codex_auth.time, "sleep", lambda *_args, **_kwargs: None)
215
+
216
+ assert codex_auth._is_choose_account_page(page) is True
217
+ assert codex_auth._select_oauth_account(page, "user@example.com") is True
218
+ assert other.clicked is False
219
+ assert target.clicked is True
220
+ assert confirm.clicked is True
221
+
222
+
223
  class _FakeOAuthAboutYouPage:
224
  def __init__(self, *, spinbuttons=True, expected_order=None):
225
  self.url = "https://auth.openai.com/about-you"
 
372
  assert captured["oauth"]["leave_workspace"] is True
373
 
374
 
375
+ def test_direct_register_step_recognizes_auth_error_page(monkeypatch):
376
+ page = type("_Page", (), {"url": "https://chatgpt.com/api/auth/error"})()
377
+
378
+ monkeypatch.setattr(manager_mod, "_is_google_redirect", lambda _page: False)
379
+
380
+ assert manager_mod._detect_direct_register_step(page) == "error"
381
+
382
+
383
  def test_create_account_direct_races_signup_workers_and_uses_winner(monkeypatch):
384
  calls = []
385
  added = []
tests/unit/test_round10_master_codex_session.py CHANGED
@@ -238,6 +238,84 @@ def test_session_codex_auth_flow_has_required_methods():
238
  assert not missing, f"SessionCodexAuthFlow 缺少必需方法: {missing}"
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  def test_inject_auth_cookies_guards_account_id():
242
  """_inject_auth_cookies 必须用 `if self.account_id:` 守护,避免空字符串污染 _account cookie.
243
 
 
238
  assert not missing, f"SessionCodexAuthFlow 缺少必需方法: {missing}"
239
 
240
 
241
+ def test_main_codex_login_flow_saves_without_remote_sync(monkeypatch):
242
+ """MainCodexLoginFlow 只保存本地主号 auth,不调用远端同步。"""
243
+ from autoteam import codex_auth
244
+
245
+ sync_calls = []
246
+
247
+ monkeypatch.setattr(codex_auth, "get_admin_email", lambda: "admin@example.com")
248
+ monkeypatch.setattr(codex_auth, "get_admin_session_token", lambda: "fake-session-token-abc123")
249
+ monkeypatch.setattr(codex_auth, "get_chatgpt_account_id", lambda: "ws-account-uuid-xyz")
250
+ monkeypatch.setattr(codex_auth, "get_chatgpt_workspace_name", lambda: "Master Team")
251
+ monkeypatch.setattr("autoteam.admin_state.get_admin_password", lambda: "admin-password")
252
+ monkeypatch.setattr(
253
+ codex_auth.SessionCodexAuthFlow,
254
+ "complete",
255
+ lambda self: {
256
+ "email": "admin@example.com",
257
+ "auth_file": "/tmp/codex-main-ws-account-uuid-xyz.json",
258
+ "plan_type": "team",
259
+ "bundle": _make_complete_bundle(),
260
+ },
261
+ )
262
+ monkeypatch.setattr(
263
+ "autoteam.sync_targets.sync_main_codex_to_configured_targets",
264
+ lambda filepath: sync_calls.append(filepath),
265
+ )
266
+
267
+ result = codex_auth.MainCodexLoginFlow().complete()
268
+
269
+ assert result == {
270
+ "email": "admin@example.com",
271
+ "auth_file": "/tmp/codex-main-ws-account-uuid-xyz.json",
272
+ "plan_type": "team",
273
+ }
274
+ assert sync_calls == []
275
+
276
+
277
+ def test_main_codex_login_flow_passes_saved_admin_password(monkeypatch):
278
+ """主号 Codex 登录在 OTP 入口不可用时可回退保存的管理员密码。"""
279
+ from autoteam import codex_auth
280
+
281
+ captured = {}
282
+
283
+ def fake_init(self, **kwargs):
284
+ captured.update(kwargs)
285
+
286
+ monkeypatch.setattr(codex_auth, "get_admin_email", lambda: "admin@example.com")
287
+ monkeypatch.setattr(codex_auth, "get_admin_session_token", lambda: "fake-session-token-abc123")
288
+ monkeypatch.setattr(codex_auth, "get_chatgpt_account_id", lambda: "ws-account-uuid-xyz")
289
+ monkeypatch.setattr(codex_auth, "get_chatgpt_workspace_name", lambda: "Master Team")
290
+ monkeypatch.setattr("autoteam.admin_state.get_admin_password", lambda: "admin-password")
291
+ monkeypatch.setattr(codex_auth.SessionCodexAuthFlow, "__init__", fake_init)
292
+
293
+ codex_auth.MainCodexLoginFlow()
294
+
295
+ assert captured["password"] == "admin-password"
296
+
297
+
298
+ def test_session_codex_auth_flow_uses_password_when_otp_switch_unavailable(monkeypatch):
299
+ from autoteam import codex_auth
300
+
301
+ flow = codex_auth.SessionCodexAuthFlow(
302
+ email="admin@example.com",
303
+ session_token="session-token",
304
+ account_id="account-id",
305
+ workspace_name="workspace",
306
+ password="admin-password",
307
+ )
308
+ steps = iter([("password_required", None), ("completed", None)])
309
+ calls = []
310
+
311
+ monkeypatch.setattr(flow, "_detect_step", lambda: next(steps))
312
+ monkeypatch.setattr(flow, "_switch_password_to_otp", lambda: False)
313
+ monkeypatch.setattr(flow, "_auto_fill_password", lambda: calls.append("password") or True)
314
+
315
+ assert flow._advance(attempts=2) == {"step": "completed", "detail": None}
316
+ assert calls == ["password"]
317
+
318
+
319
  def test_inject_auth_cookies_guards_account_id():
320
  """_inject_auth_cookies 必须用 `if self.account_id:` 守护,避免空字符串污染 _account cookie.
321