"""Approve xAI device-code in Chromium (DrissionPage). Paths resolve relative to the grok_reg project root (parent of oidc_mint). Proven flow (2026-07-10, free account): 1. Open verification_uri_complete (user_code prefilled) 2. Click 继续 on device page 3. Cookie banner: 全部允许 (optional) 4. 使用邮箱登录 → fill email → 下一步 5. Wait cf-turnstile-response → fill password → REAL click 登录 6. May land /account redirect or device page → 继续 7. Consent page /oauth2/device/consent → REAL click exact 允许 (by_js click causes Invalid action / empty form action) 8. /oauth2/device/done "设备已授权" + token poll SUCCESS Hard rules: - Token poll is source of truth - Button match is EXACT text only (允许 ≠ 全部允许) - Consent Allow MUST be a real click, not by_js - Prefer headed browser + register turnstilePatch """ from __future__ import annotations import os import re import sys import threading import time from pathlib import Path from typing import Any, Callable from urllib.parse import urlparse LogFn = Callable[[str], None] def _noop_log(_: str) -> None: return None class BrowserConfirmError(RuntimeError): pass def _sleep(sec: float) -> None: time.sleep(sec) def create_standalone_page( *, proxy: str | None = None, headless: bool = False, log: LogFn | None = None, ) -> tuple[Any, Any]: log = log or _noop_log try: from DrissionPage import Chromium, ChromiumOptions except ImportError as e: raise BrowserConfirmError( "DrissionPage not installed; run inside grok_reg uv env or pip install DrissionPage" ) from e opts = None # Project root = parent of this package (./oidc_mint → ../) _pkg_root = Path(__file__).resolve().parents[1] try: reg_file = _pkg_root / "grok_register_ttk.py" if reg_file.is_file(): reg_dir = str(_pkg_root) if reg_dir not in sys.path: sys.path.insert(0, reg_dir) try: from grok_register_ttk import create_browser_options # type: ignore opts = create_browser_options() log("using register create_browser_options (turnstilePatch)") except Exception as e: # noqa: BLE001 log(f"register browser options unavailable: {e}") opts = None except Exception as e: # noqa: BLE001 log(f"register options probe failed: {e}") opts = None if opts is None: opts = ChromiumOptions() opts.auto_port() opts.set_timeouts(base=2) for flag in ( "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage", "--mute-audio", "--no-first-run", "--disable-background-networking", "--window-size=1280,900", ): opts.set_argument(flag) ext = str(_pkg_root / "turnstilePatch") if os.path.isdir(ext): try: opts.add_extension(ext) log(f"added extension {ext}") except Exception as e: # noqa: BLE001 log(f"extension add failed: {e}") if headless: try: opts.headless(True) except Exception: opts.set_argument("--headless=new") log("headless=True (may hit Cloudflare / break real clicks)") else: try: opts.headless(False) except Exception: pass log(f"headed browser DISPLAY={os.environ.get('DISPLAY', '')!r}") for cand in ( "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", ): if os.path.isfile(cand): try: opts.set_browser_path(cand) except Exception: pass break from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy # explicit / runtime config first; env only as fallback proxy = resolve_proxy(proxy) chrome_proxy = proxy_for_chromium(proxy) if chrome_proxy: opts.set_argument(f"--proxy-server={chrome_proxy}") log(f"browser proxy={proxy_log_label(proxy)} (chromium {chrome_proxy})") else: log("browser proxy=(none)") browser = Chromium(opts) page = browser.latest_tab log("standalone chromium started") return browser, page def close_standalone(browser: Any) -> None: try: browser.quit() except Exception: pass # ── mint browser reuse (per-thread) ── _mint_tls = threading.local() def _mint_tls_get() -> dict[str, Any]: d = getattr(_mint_tls, "state", None) if d is None: d = {"browser": None, "page": None, "served": 0, "proxy": None, "headless": None} _mint_tls.state = d return d def clear_page_session(page: Any, browser: Any | None = None, log: LogFn | None = None) -> None: """Blank page + wipe storage/cookies for reuse between mint jobs.""" log = log or _noop_log try: if page is not None: try: page.get("about:blank") except Exception: pass for js in ( "try{localStorage.clear()}catch(e){}", "try{sessionStorage.clear()}catch(e){}", ): try: page.run_js(js) except Exception: pass for target in (page, browser): if target is None: continue try: target.set.cookies.clear() # type: ignore[attr-defined] log("mint session cookies cleared") break except Exception: try: # older API cks = target.cookies() if isinstance(cks, list): for c in cks: try: target.set.cookies.remove(c) # type: ignore[attr-defined] except Exception: pass except Exception: pass except Exception as e: log(f"clear_page_session: {e}") def normalize_cookies(cookies: Any) -> list[dict[str, Any]]: """Normalize DrissionPage / browser cookie list to settable dicts. Also clones SSO-like cookies onto accounts.x.ai / auth.x.ai domains so device-auth can skip secondary login when possible. """ out: list[dict[str, Any]] = [] if not cookies: return out if isinstance(cookies, dict): for k, v in cookies.items(): if k and v is not None: out.append({"name": str(k), "value": str(v), "domain": ".x.ai", "path": "/"}) cookies = out out = [] if not isinstance(cookies, (list, tuple)): return out for c in cookies: if not isinstance(c, dict): continue name = c.get("name") or c.get("Name") value = c.get("value") or c.get("Value") if not name or value is None: continue domain = str(c.get("domain") or c.get("Domain") or ".x.ai") path = str(c.get("path") or c.get("Path") or "/") item = { "name": str(name), "value": str(value), "domain": domain, "path": path, } for src, dst in ( ("expiry", "expiry"), ("expires", "expiry"), ("secure", "secure"), ("httpOnly", "httpOnly"), ("sameSite", "sameSite"), ): if src in c and c[src] is not None: item[dst] = c[src] out.append(item) # Expand SSO cookies to xAI account hosts (register browser is often on grok.com) sso_names = {"sso", "sso-rw", "cf_clearance", "sso_jwt", "__cf_bm"} extras: list[dict[str, Any]] = [] seen = {(i["name"], i["domain"], i["path"]) for i in out} for item in list(out): n = item["name"] if n not in sso_names and not n.startswith("sso"): continue for dom in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai"): key = (n, dom, item["path"]) if key in seen: continue clone = dict(item) clone["domain"] = dom extras.append(clone) seen.add(key) out.extend(extras) return out def inject_cookies(page: Any, cookies: Any, log: LogFn | None = None) -> int: """Inject cookies into page/browser. Returns count attempted.""" log = log or _noop_log items = normalize_cookies(cookies) if not items or page is None: return 0 for url in ( "https://accounts.x.ai/", "https://auth.x.ai/", "https://grok.com/", ): try: page.get(url) _sleep(0.4) except Exception: continue n = 0 for target_name, target in (("page", page), ("browser", getattr(page, "browser", None))): if target is None: continue try: target.set.cookies(items) # type: ignore[attr-defined] n = len(items) log(f"injected cookies bulk via {target_name}={n}") break except Exception as e: log(f"bulk set via {target_name} failed: {e}") if n == 0: for item in items: ok = False for target in (page, getattr(page, "browser", None)): if target is None: continue try: target.set.cookies(item) # type: ignore[attr-defined] ok = True break except Exception: continue if ok: n += 1 log(f"injected cookies one-by-one={n}/{len(items)}") # JS document.cookie for non-httpOnly SSO cookies (best effort) try: js_items = [ c for c in items if (not c.get("httpOnly")) and c.get("name") in {"sso", "sso-rw", "cf_clearance"} ] if js_items: page.get("https://accounts.x.ai/") for c in js_items: name = str(c["name"]) val = str(c["value"]) # avoid quote breakage if "'" in name or "'" in val: continue page.run_js( "document.cookie='" + name + "=" + val + "; path=/; domain=.x.ai; Secure; SameSite=None'" ) log(f"js cookie fallback applied={len(js_items)}") except Exception as e: log(f"js cookie fallback: {e}") return n def acquire_mint_browser( *, proxy: str | None = None, headless: bool = False, reuse: bool = True, recycle_every: int = 15, log: LogFn | None = None, ) -> tuple[Any, Any, bool]: """Return (browser, page, owned). owned=True means caller must close if not reusing. When reuse=True, browser is kept in thread-local and cleared between jobs. """ log = log or _noop_log st = _mint_tls_get() if reuse and st.get("browser") is not None: # recycle if proxy/headless changed or served enough need_recycle = ( st.get("proxy") != (proxy or None) or st.get("headless") != headless or (recycle_every > 0 and int(st.get("served") or 0) >= recycle_every) ) if not need_recycle: page = st.get("page") browser = st.get("browser") clear_page_session(page, browser, log=log) log(f"mint browser reused served={st.get('served')}") return browser, page, False log("mint browser recycle (proxy/headless/served threshold)") try: close_standalone(st.get("browser")) except Exception: pass st["browser"] = None st["page"] = None st["served"] = 0 browser, page = create_standalone_page(proxy=proxy, headless=headless, log=log) if reuse: st["browser"] = browser st["page"] = page st["proxy"] = proxy or None st["headless"] = headless st["served"] = 0 return browser, page, False return browser, page, True def release_mint_browser( *, owned: bool, success: bool = True, force_quit: bool = False, log: LogFn | None = None, ) -> None: log = log or _noop_log st = _mint_tls_get() if force_quit or owned: browser = st.get("browser") if not owned else None # if owned, caller passes via closing create path — handle both if owned: # owned browser not in tls return if browser is not None: close_standalone(browser) st["browser"] = None st["page"] = None st["served"] = 0 log("mint browser quit") return if success: st["served"] = int(st.get("served") or 0) + 1 else: # fail: drop browser to avoid dirty state if st.get("browser") is not None: close_standalone(st.get("browser")) st["browser"] = None st["page"] = None st["served"] = 0 log("mint browser dropped after failure") def shutdown_mint_browsers() -> None: st = getattr(_mint_tls, "state", None) if not st: return if st.get("browser") is not None: close_standalone(st.get("browser")) st["browser"] = None st["page"] = None st["served"] = 0 def _page_url(page: Any) -> str: try: return page.url or "" except Exception: return "" def _visible_text(page: Any) -> str: try: t = page.run_js( "return (document.body && (document.body.innerText || document.body.textContent)) || '';" ) if isinstance(t, str) and t.strip(): return t except Exception: pass try: raw = getattr(page, "raw_text", None) if callable(raw): t = raw() if isinstance(t, str) and t.strip(): return t if isinstance(raw, str) and raw.strip(): return raw except Exception: pass return "" def _norm(s: str) -> str: return re.sub(r"\s+", " ", (s or "").strip()) def _find_button_exact(page: Any, label: str) -> Any | None: try: for el in page.eles("tag:button") or []: try: if _norm(el.text or "") == label: return el except Exception: continue except Exception: pass try: return page.ele(f"xpath://button[normalize-space(.)='{label}']", timeout=0.3) except Exception: return None def _click_exact( page: Any, labels: list[str], log: LogFn, *, real: bool = False, ) -> str | None: """Click button by EXACT visible text. real=True uses physical click (needed for consent).""" for label in labels: el = _find_button_exact(page, label) if not el: continue try: if real: try: el.scroll.to_see() except Exception: pass el.click() log(f"clicked REAL exact {label!r}") else: el.click(by_js=True) log(f"clicked JS exact {label!r}") return label except Exception as e: log(f"click {label!r} failed: {e}") if real: try: el.click(by_js=True) log(f"clicked JS fallback exact {label!r}") return label except Exception as e2: log(f"js fallback {label!r} failed: {e2}") return None def _wait_turnstile(page: Any, log: LogFn, timeout: float = 45.0) -> bool: """Wait/click Cloudflare Turnstile on the mint browser page.""" deadline = time.time() + timeout clicked = False while time.time() < deadline: try: el = page.ele("css:input[name='cf-turnstile-response']", timeout=0.3) if el is not None: v = (el.attr("value") or "").strip() if len(v) > 20: log(f"turnstile ready len={len(v)}") return True except Exception: pass # Mimic register-machine: shadow-root checkbox click try: challenge_input = page.ele("@name=cf-turnstile-response", timeout=0.2) if challenge_input is not None: wrapper = challenge_input.parent() iframe = None try: iframe = wrapper.shadow_root.ele("tag:iframe") except Exception: iframe = None if iframe is not None: try: iframe.run_js( """ window.dtp = 1; function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } let sx = getRandomInt(800, 1200); let sy = getRandomInt(400, 700); Object.defineProperty(MouseEvent.prototype, 'screenX', { value: sx }); Object.defineProperty(MouseEvent.prototype, 'screenY', { value: sy }); """ ) except Exception: pass try: body_sr = iframe.ele("tag:body").shadow_root btn = body_sr.ele("tag:input") if btn is not None: btn.click() if not clicked: log("clicked turnstile shadow checkbox") clicked = True except Exception: pass except Exception: pass if not clicked: try: page.run_js( """ const nodes = Array.from(document.querySelectorAll('div,span,iframe')).filter((n) => { const txt = (n.className || '') + ' ' + (n.id || '') + ' ' + (n.getAttribute?.('src') || ''); return String(txt).toLowerCase().includes('turnstile'); }); if (nodes.length && typeof nodes[0].click === 'function') nodes[0].click(); """ ) clicked = True log("clicked turnstile container via JS") except Exception: pass _sleep(0.9) log("turnstile not ready") return False def _fill(page, selector, value, log, label=""): """找到 selector 输入框,清空并填入 value;成功返回 True。""" try: el = page.ele(selector, timeout=3) except Exception: el = None if not el: return False try: try: el.clear() except Exception: pass el.input(value) if label: log(f"filled {label}") return True except Exception as exc: log(f"fill {label} failed: {exc}") return False def approve_device_code( page: Any, *, verification_uri_complete: str, email: str, password: str, user_code: str = "", timeout_sec: float = 240.0, stop_event: threading.Event | None = None, log: LogFn | None = None, ) -> None: log = log or _noop_log if page is None: raise BrowserConfirmError("page is None") email = (email or "").strip() password = password or "" if not email or not password: raise BrowserConfirmError("email/password required") if not user_code and "user_code=" in (verification_uri_complete or ""): try: user_code = verification_uri_complete.split("user_code=", 1)[1].split("&", 1)[0] except Exception: user_code = "" log(f"open device url: {verification_uri_complete}") try: page.get(verification_uri_complete, timeout=60) except TypeError: page.get(verification_uri_complete) _sleep(2.0) deadline = time.time() + timeout_sec phase = "device" login_attempts = 0 last_url = "" while time.time() < deadline: if stop_event is not None and stop_event.is_set(): log("stop_event set — leave browser loop") return url = _page_url(page) text = _visible_text(page) if url != last_url: log(f"url: {url[:180]}") last_url = url snip = _norm(text)[:160] if snip: log(f"visible: {snip}") # Done page if "device/done" in url or "设备已授权" in text or "device authorized" in text.lower(): log("device done page — waiting for token poll") _sleep(1.5) continue if "Invalid action" in text: log("Invalid action — reopen device uri") page.get(verification_uri_complete) _sleep(2.0) phase = "device" continue # Consent page — REAL click exact 允许 if "/consent" in url or "授权 Grok Build" in text or "Authorize Grok Build" in text: phase = "consent" # Prefer real click; React needs it to set form action=allow if _click_exact(page, ["允许", "Allow", "Authorize", "Approve"], log, real=True): _sleep(2.5) continue # last resort: set action and submit try: page.run_js( """ const f=document.querySelector('form'); if(!f) return; let a=f.querySelector('input[name=action]'); if(!a){a=document.createElement('input');a.type='hidden';a.name='action';f.appendChild(a);} a.value='allow'; const btn=[...f.querySelectorAll('button')].find(b=>((b.innerText||'').trim())==='允许'||(b.innerText||'').trim()==='Allow'); if(btn) btn.click(); else f.submit(); """ ) log("consent form submit via JS fallback") _sleep(2.5) except Exception as e: log(f"consent fallback failed: {e}") continue # Device code entry if page.ele("css:input[name='user_code']", timeout=0.3) and "consent" not in url: phase = "device" if user_code: try: uc = page.ele("css:input[name='user_code']") cur = (uc.value or "") if uc else "" if user_code.replace("-", "") not in cur.replace("-", ""): uc.clear() uc.input(user_code) log("filled user_code") except Exception: pass if _click_exact(page, ["继续", "Continue"], log, real=False): _sleep(2.0) continue try: el = page.ele("css:button[type='submit']", timeout=0.5) if el: el.click(by_js=True) log("clicked device submit") _sleep(2.0) continue except Exception: pass # Account redirect if "正在重定向" in text or ("/account" in url and "sign-in" not in url): if _click_exact(page, ["继续", "Continue"], log, real=False): _sleep(2.0) continue # Cookie banner (exact labels only) if "全部允许" in text or "隐私偏好" in text: _click_exact(page, ["全部允许", "全部拒绝"], log, real=False) _sleep(0.5) # Sign-in chooser if "使用邮箱登录" in text or "Continue with email" in text: if _click_exact(page, ["使用邮箱登录", "Continue with email", "Sign in with email"], log, real=False): _sleep(1.5) phase = "email" continue # Email only step if page.ele("css:input[type='email']", timeout=0.3) and not page.ele( "css:input[type='password']", timeout=0.2 ): phase = "email" _fill(page, "css:input[type='email']", email, log, "email") if _click_exact(page, ["下一步", "Next", "Continue", "继续"], log, real=False): _sleep(1.8) continue # Password login if page.ele("css:input[type='password']", timeout=0.3): phase = "password" if login_attempts >= 5: _sleep(1.0) continue login_attempts += 1 log(f"login attempt {login_attempts}") _fill(page, "css:input[type='email']", email, log, "email") _wait_turnstile(page, log, 25) _fill(page, "css:input[type='password']", password, log, "password") _wait_turnstile(page, log, 12) # REAL click login helps form submit if not _click_exact(page, ["登录", "Sign in", "Log in"], log, real=True): try: el = page.ele("css:button[type='submit']", timeout=0.5) or page.ele( "css:button[data-testid='sign-in-submit']", timeout=0.5 ) if el: el.click() log("clicked login submit real") except Exception as e: log(f"login submit fail: {e}") # wait navigation for _ in range(24): if stop_event is not None and stop_event.is_set(): return _sleep(0.5) if not page.ele("css:input[type='password']", timeout=0.2): break if "sign-in" not in _page_url(page): break continue _sleep(1.0) if stop_event is not None and stop_event.is_set(): log("browser finished via stop_event") return log(f"browser loop ended phase={phase} login_attempts={login_attempts}") def mint_with_browser( *, email: str, password: str, page: Any | None = None, proxy: str | None = None, headless: bool = False, browser_timeout_sec: float = 240.0, poll_log: LogFn | None = None, cancel: Callable[[], bool] | None = None, force_standalone: bool = True, cookies: Any | None = None, reuse_browser: bool = True, recycle_every: int = 15, ) -> dict[str, Any]: """Request device code, approve in browser, poll tokens. force_standalone=True (default): do not reuse the *register* tab. Mint workers may still reuse their *own* Chromium via reuse_browser. cookies: optional register-browser cookie list to skip re-login. """ from .oauth_device import OAuthDeviceError, poll_device_token, request_device_code from .proxyutil import proxy_log_label, resolve_proxy, set_runtime_proxy log = poll_log or _noop_log own_browser = None owned = False work_page = None if force_standalone else page resolved = resolve_proxy(proxy) set_runtime_proxy(resolved or None) success = False try: last_err: BaseException | None = None sess = None for attempt in range(1, 4): try: sess = request_device_code(proxy=resolved or None) last_err = None break except BaseException as e: # noqa: BLE001 last_err = e log(f"request_device_code attempt {attempt}/3 failed: {e}") _sleep(1.5 * attempt) if sess is None: raise last_err or RuntimeError("request_device_code failed") log( f"device user_code={sess.user_code} expires_in={sess.expires_in} " f"proxy={proxy_log_label(resolved) or '(none)'}" ) if work_page is None: own_browser, work_page, owned = acquire_mint_browser( proxy=resolved or None, headless=headless, reuse=reuse_browser, recycle_every=recycle_every, log=log, ) if owned: # non-reuse path: track for finally close pass # Cookie inject before opening device URL (skip secondary login when possible) if cookies: n = inject_cookies(work_page, cookies, log=log) log(f"cookie inject count={n}") try: work_page.get("https://accounts.x.ai/") _sleep(1.0) url = _page_url(work_page) text = _visible_text(work_page) snip = _norm(text)[:120] log(f"post-inject session url={url[:120]} visible={snip}") except Exception as e: log(f"post-inject check: {e}") stop_event = threading.Event() token_box: dict[str, Any] = {} err_box: dict[str, BaseException] = {} def _poll() -> None: try: time.sleep(2) tr = poll_device_token( sess.device_code, interval=max(sess.interval, 5), expires_in=min(sess.expires_in, int(browser_timeout_sec) + 60), log=log, cancel=cancel, proxy=resolved or None, ) token_box["token"] = tr stop_event.set() log("token poll SUCCESS — stop_event set") except BaseException as e: # noqa: BLE001 err_box["err"] = e stop_event.set() t = threading.Thread(target=_poll, name="oauth-poll", daemon=True) t.start() try: approve_device_code( work_page, verification_uri_complete=sess.verification_uri_complete, email=email, password=password, user_code=sess.user_code, timeout_sec=browser_timeout_sec, stop_event=stop_event, log=log, ) except BrowserConfirmError as e: log(f"browser confirm warning: {e}") t.join(timeout=max(browser_timeout_sec, 60) + 30) if "token" in token_box: tr = token_box["token"] success = True return { "access_token": tr.access_token, "refresh_token": tr.refresh_token, "id_token": tr.id_token, "token_type": tr.token_type, "expires_in": tr.expires_in, "user_code": sess.user_code, } if "err" in err_box: raise err_box["err"] raise OAuthDeviceError("token poll thread ended without result") finally: if own_browser is not None: if owned: close_standalone(own_browser) else: release_mint_browser(owned=False, success=success, log=log)