docs: learn official phone verification update

This commit is contained in:
2026-05-10 15:44:19 +08:00
parent ca30d4c5cd
commit 02121412e3
5 changed files with 506 additions and 0 deletions
+223
View File
@@ -0,0 +1,223 @@
import json, pathlib, re, subprocess, sys, time, urllib.request, urllib.parse
ENV = pathlib.Path('/Users/chick/.hermes/env/codex_oauth_onboarding.env').read_text()
def get(k, d=''):
m = re.search(r'^' + re.escape(k) + r'=(.*)$', ENV, re.M)
return (m.group(1).strip().strip('"\'') if m else d)
TOKEN = get('FIVE_SIM_API_KEY') or get('FIVESIM_API_KEY')
HEAD = {'Authorization': 'Bearer ' + TOKEN, 'Accept': 'application/json'}
ORDER = [x.strip() for x in get('FIVE_SIM_COUNTRY_ORDER', '').split(',') if x.strip()] or ['indonesia']
LIMIT = int(get('PHONE_VERIFICATION_REPLACEMENT_LIMIT', '10') or '10')
PRODUCT = get('FIVE_SIM_PRODUCT', 'openai') or 'openai'
OPERATOR = get('FIVE_SIM_OPERATOR', 'any') or 'any'
META = {
'argentina': {'iso': 'AR', 'visible': '阿根廷 (+54)', 'prefix': '54'},
'netherlands': {'iso': 'NL', 'visible': '荷兰 (+31)', 'prefix': '31'},
'indonesia': {'iso': 'ID', 'visible': '印度尼西亚 (+62)', 'prefix': '62'},
'england': {'iso': 'GB', 'visible': '英国 (+44)', 'prefix': '44'},
'vietnam': {'iso': 'VN', 'visible': '越南 (+84)', 'prefix': '84'},
}
def emit(**kw):
print(json.dumps(kw, ensure_ascii=False), flush=True)
def req_json(url):
req = urllib.request.Request(url, headers=HEAD)
with urllib.request.urlopen(req, timeout=45) as r:
return json.loads(r.read().decode())
def cancel(aid):
try:
return req_json(f'https://5sim.net/v1/user/cancel/{aid}')
except Exception as e:
return {'error': str(e)}
def finish(aid):
try:
return req_json(f'https://5sim.net/v1/user/finish/{aid}')
except Exception as e:
return {'error': str(e)}
def check(aid):
return req_json(f'https://5sim.net/v1/user/check/{aid}')
def cdp(js, match='auth.openai.com', timeout=45):
p = subprocess.run(['bash', '-lc', f"CDP_MATCH='{match}' python3 /tmp/cdp_async_eval.py"], input=js, text=True, capture_output=True, timeout=timeout)
return (p.stdout or '') + (p.stderr or '')
def current_state():
return cdp("""
return {url: location.href, title: document.title,
country: [...document.querySelectorAll('button')].map(b=>b.innerText).find(t=>/\(\+\d+\)/.test(t)),
selectValue: document.querySelector('select')?.value,
tel: document.querySelector('input[type=tel]')?.value,
text: document.body.innerText.slice(0, 900)};
""", timeout=25)
def restore_add_phone():
st = current_state()
if 'auth.openai.com/add-phone' in st:
return True
# Generate fresh OAuth URL and login with existing account. This account is already email-verified.
raw = get('CODEX2API_URL')
u = urllib.parse.urlsplit(raw)
origin = f'{u.scheme}://{u.netloc}' if u.scheme and u.netloc else raw.rstrip('/')
req = urllib.request.Request(origin + '/api/admin/oauth/generate-auth-url', data=b'{}', headers={'X-Admin-Key': get('CODEX2API_ADMIN_KEY'), 'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read().decode())
url = data.get('auth_url') or data.get('url')
emit(phase='oauth_recover', session_id=data.get('session_id') or data.get('id'))
urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:9223/json/new?' + urllib.parse.quote(url, safe=''), method='PUT'), timeout=10).read()
time.sleep(6)
email = json.loads(pathlib.Path('/tmp/current_codex_run.json').read_text())['email']
pw = get('CUSTOM_PASSWORD')
js = f"""
const email={json.dumps(email)}, pw={json.dumps(pw)};
const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
let emailI=document.querySelector('input[type=email], input[name=email], input[autocomplete=username], input');
if(emailI && location.href.includes('/log-in')){{emailI.focus(); setter.call(emailI,email); emailI.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:email}})); emailI.dispatchEvent(new Event('change',{{bubbles:true}})); [...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续')?.click();}}
await new Promise(r=>setTimeout(r,6000));
let pwI=document.querySelector('input[type=password]');
if(pwI){{pwI.focus(); setter.call(pwI,pw); pwI.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:pw}})); pwI.dispatchEvent(new Event('change',{{bubbles:true}})); [...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续')?.click();}}
await new Promise(r=>setTimeout(r,12000));
return {{url:location.href,title:document.title,text:document.body.innerText.slice(0,600)}};
"""
out = cdp(js, timeout=40)
emit(phase='recover_state', state=out[-800:])
return 'auth.openai.com/add-phone' in current_state()
def set_country_gate(country):
meta = META[country]
js = f"""
const iso={json.dumps(meta['iso'])}, visible={json.dumps(meta['visible'])};
const sel=document.querySelector('select');
if(!sel) return {{ok:false, reason:'no_select', url:location.href, text:document.body.innerText.slice(0,300)}};
sel.focus();
sel.value=iso;
sel.dispatchEvent(new Event('input',{{bubbles:true}}));
sel.dispatchEvent(new Event('change',{{bubbles:true}}));
await new Promise(r=>setTimeout(r,900));
const tel=document.querySelector('input[type=tel]');
if(tel){{const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set; setter.call(tel,''); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'deleteContentBackward'}})); tel.dispatchEvent(new Event('change',{{bubbles:true}}));}}
const country=[...document.querySelectorAll('button')].map(b=>b.innerText).find(t=>/\\(\\+\\d+\\)/.test(t));
const selectValue=document.querySelector('select')?.value;
return {{ok:selectValue===iso && country===visible, country, selectValue, expected:visible, iso, url:location.href}};
"""
out = cdp(js, timeout=25)
return out
def submit_number(country, phone, aid):
meta = META[country]
digits = re.sub(r'\D+', '', str(phone))
local = digits[len(meta['prefix']):] if digits.startswith(meta['prefix']) else digits
js = f"""
const iso={json.dumps(meta['iso'])}, visible={json.dumps(meta['visible'])}, local={json.dumps(local)};
function visibleCountry(){{return [...document.querySelectorAll('button')].map(b=>b.innerText).find(t=>/\\(\\+\\d+\\)/.test(t));}}
let country=visibleCountry(), selectValue=document.querySelector('select')?.value;
if(selectValue!==iso || country!==visible) return {{clicked:false, reason:'pre_fill_country_gate_failed', country, selectValue, expected:visible, iso}};
const tel=document.querySelector('input[type=tel]');
if(!tel) return {{clicked:false, reason:'no_tel', country, selectValue}};
const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
tel.focus(); setter.call(tel,''); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'deleteContentBackward'}}));
setter.call(tel,local); tel.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:local}})); tel.dispatchEvent(new Event('change',{{bubbles:true}}));
await new Promise(r=>setTimeout(r,800));
country=visibleCountry(); selectValue=document.querySelector('select')?.value;
if(selectValue!==iso || country!==visible) return {{clicked:false, reason:'post_fill_country_gate_failed', country, selectValue, expected:visible, iso, tel:document.querySelector('input[type=tel]')?.value}};
[...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续')?.click();
await new Promise(r=>setTimeout(r,10000));
return {{clicked:true,url:location.href,title:document.title,text:document.body.innerText.slice(0,1200),country:visibleCountry(),selectValue:document.querySelector('select')?.value,tel:document.querySelector('input[type=tel]')?.value}};
"""
return cdp(js, timeout=35)
emit(phase='phone_strict_start', countries=ORDER, limit=LIMIT)
for country in ORDER:
if country not in META:
emit(phase='skip_unknown_country', country=country)
continue
for attempt in range(1, LIMIT + 1):
if not restore_add_phone():
emit(phase='cannot_restore_add_phone', country=country, attempt=attempt, state=current_state()[-800:])
sys.exit(20)
gate = set_country_gate(country)
emit(phase='country_gate_before_buy', country=country, attempt=attempt, state=gate[-700:])
if '"ok": true' not in gate:
continue
try:
act = req_json(f'https://5sim.net/v1/user/buy/activation/{country}/{OPERATOR}/{PRODUCT}')
except Exception as e:
emit(phase='buy_failed', country=country, attempt=attempt, error=str(e))
continue
aid = act.get('id'); phone = str(act.get('phone'))
pathlib.Path('/tmp/fivesim_activation.json').write_text(json.dumps(act, ensure_ascii=False, indent=2))
masked = re.sub(r'(\+?\d{4})\d+(\d{3})', r'\1***\2', phone)
emit(phase='buy', country=country, attempt=attempt, activation_id=aid, masked=masked, status=act.get('status'))
# Re-run country gate after buy; cancel instead of submit if wrong.
gate2 = current_state()
if META[country]['visible'] not in gate2 or ('"selectValue": "' + META[country]['iso'] + '"') not in gate2:
emit(phase='cancel_before_submit_country_mismatch', country=country, attempt=attempt, activation_id=aid, state=gate2[-600:], result=cancel(aid).get('status'))
continue
out = submit_number(country, phone, aid)
emit(phase='submit_result', country=country, attempt=attempt, activation_id=aid, state=out[-1000:])
if 'clicked": false' in out or 'country_gate_failed' in out:
emit(phase='cancel_submit_gate_failed', country=country, attempt=attempt, activation_id=aid, result=cancel(aid).get('status'))
continue
if any(x in out for x in ['无法向此电话号码发送', '无法向该号码发送', '电话号码无效']):
emit(phase='direct_reject_cancel', country=country, attempt=attempt, activation_id=aid, result=cancel(aid).get('status'))
continue
got = None
for r in range(1, 7):
time.sleep(10)
st = check(aid); sms = st.get('sms') or []
emit(phase='sms_poll', country=country, attempt=attempt, activation_id=aid, round=r, status=st.get('status'), sms_count=len(sms))
if sms:
got = sms[0].get('code') or sms[0].get('text')
break
if not got:
# Try resend once if page still allows it. Probe channel first; if it is WhatsApp, stop/cancel SMS instead of clicking.
resend = cdp("""
const text=(document.title+' '+document.body.innerText).replace(/\s+/g,' ').trim();
if(/this page isn[']?t working|currently unable to handle this request|http error 500|500 internal server error/i.test(text)) return {clicked:false, reason:'resend_server_error', url:location.href, text:text.slice(0,600)};
const btn=[...document.querySelectorAll('button,input[type=submit],input[type=button]')].find(b=>{const t=[b.value,b.getAttribute?.('aria-label'),b.getAttribute?.('title'),b.innerText,b.textContent].filter(Boolean).join(' '); return /resend|重新发送|再次发送|whats\s*app/i.test(t);});
const channelText=btn?[btn.value,btn.getAttribute?.('aria-label'),btn.getAttribute?.('title'),btn.innerText,btn.textContent].filter(Boolean).join(' ').replace(/\s+/g,' ').trim():'';
const channel=/whats\s*app/i.test(channelText)?'whatsapp':(/sms|text message|短信/i.test(channelText)?'sms':'unknown');
if(channel==='whatsapp') return {clicked:false, channel, channelText, url:location.href};
btn?.click();
await new Promise(r=>setTimeout(r,2000));
const after=(document.title+' '+document.body.innerText).replace(/\s+/g,' ').trim();
return {clicked:!!btn,channel,channelText,url:location.href,text:after.slice(0,600),serverError:/this page isn[']?t working|currently unable to handle this request|http error 500|500 internal server error/i.test(after)};
""", match='auth.openai.com', timeout=20)
emit(phase='resend', country=country, attempt=attempt, activation_id=aid, state=resend[-700:])
if 'whatsapp' in resend.lower():
emit(phase='whatsapp_resend_detected_cancel', country=country, attempt=attempt, activation_id=aid, result=cancel(aid).get('status'))
continue
if 'resend_server_error' in resend or 'serverError' in resend:
emit(phase='resend_server_error_cancel', country=country, attempt=attempt, activation_id=aid, result=cancel(aid).get('status'))
continue
for r in range(1, 7):
time.sleep(10)
st = check(aid); sms = st.get('sms') or []
emit(phase='sms_poll_after_resend', country=country, attempt=attempt, activation_id=aid, round=r, status=st.get('status'), sms_count=len(sms))
if sms:
got = sms[0].get('code') or sms[0].get('text')
break
if not got:
emit(phase='no_sms_cancel', country=country, attempt=attempt, activation_id=aid, result=cancel(aid).get('status'))
continue
emit(phase='sms_received', country=country, attempt=attempt, activation_id=aid)
code = ''.join(re.findall(r'\d', str(got)))[:6]
fill = cdp(f"""
const code={json.dumps(code)};
const input=document.querySelector('input[autocomplete=one-time-code], input[name=code], input');
const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
input.focus(); setter.call(input,code); input.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:code}})); input.dispatchEvent(new Event('change',{{bubbles:true}}));
[...document.querySelectorAll('button')].find(b=>(b.innerText||'').trim()==='继续')?.click();
await new Promise(r=>setTimeout(r,12000));
return {{url:location.href,title:document.title,text:document.body.innerText.slice(0,1000)}};
""", match='auth.openai.com', timeout=35)
emit(phase='code_submit', state=fill[-1000:])
finish(aid)
sys.exit(0)
emit(phase='phone_failed_all')
sys.exit(3)
@@ -48,6 +48,7 @@ These are confirmed operating rules for BOSS's environment:
- ClawEmail creates a fresh mailbox for every run (`CLAWEMAIL_CREATE_PER_RUN=true`). Record every created mailbox locally with outcome classification: pending, success, failed. **Immediately after creation, actively set/verify the mailbox's communication settings before submitting it to OpenAI:** new sub-mailboxes can default to internal-only (`commLevel=1`, `extReceiveType=0`), which blocks OpenAI verification mail. The gate must pass with external receive enabled (`commLevel=2`, `extReceiveType=1`). Do not merely tell BOSS to do this manually: first try the logged-in ClawEmail Dashboard API `POST /api/v1/mailboxes/comm-settings?id=<mailboxId>` with `{commLevel:2, extReceiveType:1, extSendType:0}`. If the Dashboard is not logged in, open/login to it and ask BOSS only for the QQ master-mailbox 6-digit login code if the agent cannot read QQ mail. See `clawemail-operations` reference `references/clawemail-dashboard-comm-settings-openai-signup-2026-05-10.md` and this skill's `references/clawemail-external-receive-gate-openai-2026-05-10.md`.
- Phone/SMS platform has no default. Keep `PHONE_VERIFICATION_ENABLED=false` and `PHONE_SMS_PROVIDER` empty unless BOSS configures one or approves enabling it after a phone verification appears.
- If phone verification appears and BOSS has configured 5sim, use **SMS activation for `openai`**, not WhatsApp receive channels. The original extension's 5sim provider buys `/v1/user/buy/activation/{country}/{operator}/openai`, polls `/v1/user/check/{activationId}`, and finishes/cancels the activation. **Before buying any 5sim number, pass the identity gate:** the browser must already be confirmed on `auth.openai.com/add-phone` for a usable OpenAI account. If OpenAI returns `account_deactivated`, if ClawEmail cannot create a new sub-mailbox due to quota, or if reused sub-mailboxes only show password mismatch, stop and ask BOSS to free mailbox quota/provide the correct password/approve another mailbox provider. **Country selection priority is `FIVE_SIM_COUNTRY_ORDER` first** (comma-separated list such as `argentina,netherlands,indonesia`), then `FIVE_SIM_COUNTRY_ID`, then the original extension default `vietnam` only if both are empty. For each configured country, rotate numbers up to `PHONE_VERIFICATION_REPLACEMENT_LIMIT`; do **not** silently switch outside the configured order unless BOSS approves. Before buying/submitting a non-USA number, verify the OpenAI visible country selector is already the expected country (for example `阿根廷 (+54)`, `荷兰 (+31)`, `印度尼西亚 (+62)`, or `越南 (+84)`); if it reverted to `美国 (+1)`, fix the selector first or the number will be parsed as invalid. Use visible UI paste/click on `add-phone` when possible because CDP-only input mutation can desync React state and revert the country on submit. If a number is **not immediately rejected**, do not cancel it after one polling timeout: poll 5sim, click OpenAI `重新发送`, poll again, and retry resend at least once before canceling/rotating. If changing proxy (for example `1085``1083` or back), expect OpenAI auth session invalidation and restart from a fresh target OAuth URL. Codex2API env URLs may point at `/admin/accounts`; derive the API origin before calling `/api/admin/oauth/generate-auth-url`. If OpenAI returns `account_deactivated` after email verification, stop the current account and start with a new ClawEmail sub-mailbox instead of burning 5sim numbers. See `references/openai-add-phone-5sim-sms-activation-2026-05-10.md`, `references/openai-phone-verification-5sim-sms-2026-05-10.md`, `references/openai-phone-verification-proxy1083-resend-2026-05-10.md`, `references/codex-oauth-token-lite-automation-flow-2026-05-10.md`, and `references/clawemail-quota-identity-gate-before-phone-verify-2026-05-10.md` for observed behavior, resend strategy, proxy-switch recovery, identity-gate checks, and pitfalls.
- If a phone-verification runner is stopped or interrupted, immediately inspect/cancel any active 5sim activation left in `RECEIVED`; killed scripts may not execute cleanup. For a UK `+44` run, use 5sim country slug `england`, OpenAI ISO `GB`, visible selector `英国 (+44)`, and dial prefix `44`. See `references/openai-add-phone-strict-country-gate-2026-05-10.md` and `references/openai-add-phone-uk44-and-runner-cleanup-2026-05-10.md`.
- Plus mode uses `PLUS_PAYMENT_METHOD=gopay`. Any paid GoPay action still requires explicit confirmation immediately before payment/approval.
- GoPay WhatsApp OTP uses **方案 A / manual checkpoint**: set `GOPAY_OTP_SOURCE=manual`. The extension detects OTP input and opens a side-panel prompt (`requestGoPayOtpInput` / “输入 GoPay 验证码”); BOSS pastes the WhatsApp code, then the extension fills OTP, fills `GOPAY_PIN`, and continues.
- `GOPAY_OTP` should normally stay empty before the run. It is only a temporary prefill/cache for the current OTP dialog, not a durable secret.
@@ -0,0 +1,97 @@
# Official codex-oauth extension update learnings for phone verification (2026-05-10)
Source inspected: `QLHazyCoder/codex-oauth-automation-extension` update from local `7dc5cf2` to upstream `6bd00db` (`origin/master`).
## Files inspected
- `background/phone-verification-flow.js`
- `content/phone-auth.js`
- `phone-sms/providers/five-sim.js`
- `tests/phone-auth-country-match.test.js`
- `tests/phone-verification-flow.test.js`
## Useful changes to copy into Hermes runner/workflow
### 1. Detect WhatsApp resend channel without clicking
Official content script now extracts resend button text from `value`, `aria-label`, `title`, `innerText`, and `textContent`, then classifies the resend channel:
- `whatsapp` if text matches `/whats\s*app/i`
- `sms` if text matches `sms|text message|短信`
- `unknown` otherwise
It supports a `probeOnly` mode that returns channel info without clicking.
Hermes runner rule:
1. After submitting a phone number and before clicking resend, probe resend button/channel.
2. If channel is `whatsapp` and BOSS's policy is SMS-only, cancel the 5sim SMS order and rotate/stop; do not click WhatsApp resend.
3. Log compact JSONL phase `whatsapp_resend_detected_cancel`.
### 2. Classify OpenAI HTTP 500/contact-verification server errors
Official update adds server-error detection for pages such as:
- `This page isnt working`
- `currently unable to handle this request`
- `HTTP ERROR 500`
- `500 Internal Server Error`
Hermes runner rule:
- If resend or phone verification lands on this error page, classify as `resend_server_error`, cancel current activation, recover by navigating/generating fresh OAuth session back to `add-phone` before next number.
### 3. Broader direct-delivery-refused detection
Official update adds a broader delivery-refused classifier for Chinese/English variants such as:
- `无法向此电话号码发送验证码`
- `无法向...号码...发送验证码/短信`
- `cannot/unable to send/deliver verification code/sms/text to this phone number`
Hermes runner should treat these as direct number rejection:
- cancel/ban the current order;
- rotate to next number;
- do not keep polling.
### 4. United Kingdom prefix mapping
Official HeroSMS prefix mapping includes:
```js
{ prefix: '44', id: 16, label: 'United Kingdom' }
```
For 5sim/OpenAI country tests in BOSS's runner, map UK as:
```text
5sim country slug: england
OpenAI select ISO: GB
visible text: 英国 (+44)
prefix: 44
```
### 5. Stale email-verification guard
Official signup phone flow now checks whether the page has unexpectedly returned to `/email-verification` before polling/submitting phone SMS. Treat that as stale signup/email-verification state and stop/replace number/account rather than burning SMS orders.
### 6. Waiting callback while polling 5sim
Official 5sim provider added `onWaitingForCode` during polling. Hermes runner can mimic this with compact JSONL status events so long waits do not look stuck.
## Runner patch applied locally
`/tmp/phone_verify_strict_gate_10.py` was patched to:
- probe resend channel before clicking;
- cancel and continue if WhatsApp resend is detected;
- classify server-error pages before/after resend;
- keep strict country gate before buying and before clicking Continue.
## Keep from prior Hermes-specific lessons
- Do not submit if `selectValue` and visible country/dial code mismatch.
- Do not submit a non-US number when visible country is `美国 (+1)`.
- If a 5sim order is already bought and country gate fails, cancel immediately.
- For BOSS's configured SMS-only policy, do not switch to WhatsApp providers unless explicitly approved.
@@ -0,0 +1,89 @@
# Strict OpenAI phone country/area-code gate (2026-05-10)
## Trigger
Use this note for any OpenAI `auth.openai.com/add-phone` automation that buys and submits 5sim phone numbers.
## Bug that caused this rule
During a 1085 + Indonesia test, the browser page had silently reverted to the United States selector (`美国 (+1)`) while the agent bought an Indonesia `+62` number. The number was submitted as a US local number, and OpenAI moved to `phone-verification` showing it had sent to a `+1 (...)` number. That attempt was invalid and the 5sim order was canceled.
## Mandatory rule
Never rely on the intended country in the script. Before buying, after filling, and immediately before clicking Continue, verify BOTH:
1. Native select value equals the intended ISO country code.
2. Visible country button text contains the intended country and dialing code.
Examples:
| Country | ISO | Visible text |
| --- | --- | --- |
| Argentina | `AR` | `阿根廷 (+54)` |
| Netherlands | `NL` | `荷兰 (+31)` |
| Indonesia | `ID` | `印度尼西亚 (+62)` |
If either check fails:
- Do not click Continue.
- If a 5sim order was already bought, cancel it immediately.
- Reset the country selector and re-check before retrying.
- Exclude the invalid attempt from country/provider result statistics.
## Reliable country setting
React Aria click/pointer events are unreliable. Use the hidden/native select directly:
```js
const sel = document.querySelector('select');
sel.focus();
sel.value = 'ID'; // AR, NL, ID, etc.
sel.dispatchEvent(new Event('input', { bubbles: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => setTimeout(r, 800));
```
## Gate function
```js
function visibleCountryText() {
return [...document.querySelectorAll('button')]
.map(b => b.innerText)
.find(t => /\(\+\d+\)/.test(t));
}
function assertCountryGate(iso, visible) {
const selectValue = document.querySelector('select')?.value;
const country = visibleCountryText();
if (selectValue !== iso || country !== visible) {
throw new Error(`country_gate_failed: select=${selectValue} visible=${country}`);
}
}
```
## Correct runner behavior
1. Restore or open `add-phone`.
2. Set country via native select.
3. Run `assertCountryGate()`.
4. Only then buy a 5sim activation.
5. Fill the local part of the phone number.
6. Run `assertCountryGate()` again.
7. Click Continue.
8. Read the result:
- direct rejection (`无法向此电话号码发送验证码` / `电话号码无效`) -> cancel and rotate;
- accepted/waiting -> poll 5sim, click resend once, poll again, then cancel/rotate if still no SMS.
## Replacement limit
For the current configured test, BOSS requested 10 replacements:
```text
PHONE_VERIFICATION_REPLACEMENT_LIMIT=10
```
Keep using configured country order first:
```text
FIVE_SIM_COUNTRY_ORDER=argentina,netherlands,indonesia
```
@@ -0,0 +1,96 @@
# OpenAI add-phone UK +44 and interrupted-run cleanup notes (2026-05-10)
## What changed
BOSS requested stopping the active Argentina/Netherlands/Indonesia run and switching phone verification to the UK `+44`.
## Mandatory cleanup when stopping a runner
When a background phone-verification runner is killed/interrupted, immediately inspect the last active 5sim activation from the JSONL log and cancel it if status is still `RECEIVED`.
Observed interrupted order:
```json
{"activation_id":"1006733450","country":"netherlands","status":"RECEIVED","sms_count":0}
```
Cleanup result:
```json
{"activation_id":"1006733450","result":"CANCELED"}
```
Do not assume a killed runner canceled its current order.
## UK +44 mapping for OpenAI add-phone
5sim country slug used successfully for buying OpenAI activation:
```text
england
```
OpenAI selector mapping:
```text
ISO: GB
Visible text: 英国 (+44)
Dial prefix: 44
```
Config for UK-only run:
```text
FIVE_SIM_COUNTRY_ORDER=england
PHONE_VERIFICATION_REPLACEMENT_LIMIT=10
FIVE_SIM_OPERATOR=any
FIVE_SIM_PRODUCT=openai
```
## Strict country gate remains mandatory
Before buying and before clicking Continue, verify both:
```json
{"country":"英国 (+44)","selectValue":"GB"}
```
Observed valid gate before the first UK buy:
```json
{
"phase": "country_gate_before_buy",
"country": "england",
"attempt": 1,
"ok": true,
"country_text": "英国 (+44)",
"selectValue": "GB"
}
```
Then 5sim bought a UK number:
```json
{"phase":"buy","country":"england","activation_id":1006734496,"masked":"+4477***381","status":"RECEIVED"}
```
## Runner behavior after UK switch
Use a single configurable strict-gate runner, not ad-hoc CDP snippets:
1. Stop old runner.
2. Cancel in-flight activation from its log.
3. Set `FIVE_SIM_COUNTRY_ORDER=england`.
4. Ensure metadata includes `england: {iso:'GB', visible:'英国 (+44)', prefix:'44'}`.
5. Restore OAuth/add-phone if currently on phone-verification or a Chrome error page.
6. Set country via native select.
7. Run strict gate.
8. Buy only after gate passes.
9. Fill local phone part.
10. Run strict gate again.
11. Click Continue.
12. Poll; resend once; poll; cancel/rotate if no SMS.
## Pitfall
BOSS watches the visible screen and will catch wrong country/area code. If the visible selector says `美国 (+1)` while a non-US number is being used, stop immediately, cancel any new order, restore add-phone, and verify the correct visible code before buying/submitting again.