Spaces:
Paused
Paused
fix: absorb autoteam-1 auth and CPA quota parity
Browse files- .trellis/spec/backend/account-disable-cpa-sync.md +290 -0
- .trellis/spec/backend/index.md +2 -1
- .trellis/tasks/05-19-autoteam1-full-parity-completion-audit/prd.md +178 -0
- .trellis/tasks/05-19-autoteam1-full-parity-completion-audit/research/autoteam1-full-parity-matrix.md +624 -0
- src/autoteam/accounts.py +38 -1
- src/autoteam/codex_auth.py +1132 -146
- src/autoteam/cpa_sync.py +31 -1
- src/autoteam/mail/__init__.py +34 -0
- src/autoteam/manager.py +767 -70
- tests/unit/test_cpa_sync.py +110 -0
- tests/unit/test_free_registration_hardening.py +154 -0
- tests/unit/test_round10_master_codex_session.py +78 -0
.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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 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 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 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 |
-
|
| 1884 |
-
|
| 1885 |
-
|
| 1886 |
-
|
| 1887 |
-
|
| 1888 |
-
_email_id_before_login,
|
|
|
|
|
|
|
|
|
|
| 1889 |
)
|
| 1890 |
-
|
| 1891 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 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 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1769 |
|
| 1770 |
# 区分:有认证文���的 vs 无认证文件的
|
| 1771 |
active_with_auth = []
|
| 1772 |
no_auth_list = []
|
|
|
|
|
|
|
|
|
|
| 1773 |
for a in all_active:
|
| 1774 |
-
if
|
| 1775 |
active_with_auth.append(a)
|
| 1776 |
else:
|
| 1777 |
-
|
| 1778 |
-
|
|
|
|
|
|
|
|
|
|
| 1779 |
no_auth_list.append(a)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1780 |
|
| 1781 |
if not active_with_auth and not no_auth_list:
|
| 1782 |
-
logger.info("[检查] 没有可检查的
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1891 |
elif status_str == "network_error":
|
| 1892 |
-
|
| 1893 |
-
|
| 1894 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1895 |
|
| 1896 |
-
# 无认证文件的
|
| 1897 |
if no_auth_list:
|
| 1898 |
-
logger.info("[检查] 发现 %d 个
|
| 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 个
|
| 1933 |
-
|
| 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 |
-
|
| 1941 |
-
|
| 1942 |
-
|
| 1943 |
-
|
| 1944 |
-
|
| 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 |
-
|
| 1984 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1985 |
else:
|
| 1986 |
-
|
| 1987 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3206 |
try:
|
| 3207 |
-
|
| 3208 |
-
|
| 3209 |
-
|
| 3210 |
-
|
| 3211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3759 |
-
|
| 3760 |
-
|
| 3761 |
-
|
| 3762 |
-
|
| 3763 |
-
|
| 3764 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3765 |
|
| 3766 |
-
if
|
| 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 |
-
|
| 3785 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|