1248 lines
48 KiB
Python
1248 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MAIN_PATH = ROOT / "grok_register_ttk.py"
|
|
|
|
|
|
def node_span(text: str, node: ast.AST) -> tuple[int, int]:
|
|
lines = text.splitlines(keepends=True)
|
|
offsets = [0]
|
|
for line in lines:
|
|
offsets.append(offsets[-1] + len(line))
|
|
start = offsets[node.lineno - 1] + getattr(node, "col_offset", 0)
|
|
end_line = getattr(node, "end_lineno", node.lineno)
|
|
end_col = getattr(node, "end_col_offset", len(lines[end_line - 1]))
|
|
end = offsets[end_line - 1] + end_col
|
|
while end < len(text) and text[end] in "\r\n":
|
|
end += 1
|
|
return start, end
|
|
|
|
|
|
def assignment_names(node: ast.AST) -> set[str]:
|
|
names: set[str] = set()
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
for target in targets:
|
|
if isinstance(target, ast.Name):
|
|
names.add(target.id)
|
|
return names
|
|
|
|
|
|
def definition_map(text: str) -> dict[str, ast.AST]:
|
|
tree = ast.parse(text)
|
|
return {
|
|
node.name: node
|
|
for node in tree.body
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
}
|
|
|
|
|
|
def assignment_map(text: str) -> dict[str, ast.AST]:
|
|
tree = ast.parse(text)
|
|
out: dict[str, ast.AST] = {}
|
|
for node in tree.body:
|
|
for name in assignment_names(node):
|
|
out[name] = node
|
|
return out
|
|
|
|
|
|
def source_for_names(text: str, names: list[str]) -> str:
|
|
defs = definition_map(text)
|
|
chunks = []
|
|
for name in names:
|
|
node = defs.get(name)
|
|
if node is None:
|
|
raise RuntimeError(f"definition not found: {name}")
|
|
start, end = node_span(text, node)
|
|
chunks.append(text[start:end].rstrip() + "\n\n")
|
|
return "".join(chunks)
|
|
|
|
|
|
def source_for_assignments(text: str, names: list[str]) -> str:
|
|
amap = assignment_map(text)
|
|
chunks = []
|
|
for name in names:
|
|
node = amap.get(name)
|
|
if node is None:
|
|
raise RuntimeError(f"assignment not found: {name}")
|
|
start, end = node_span(text, node)
|
|
chunks.append(text[start:end].rstrip() + "\n\n")
|
|
return "".join(chunks)
|
|
|
|
|
|
def remove_top_level(text: str, definition_names=(), assignment_name_set=()) -> str:
|
|
tree = ast.parse(text)
|
|
spans = []
|
|
for node in tree.body:
|
|
remove = False
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
remove = node.name in set(definition_names)
|
|
elif assignment_names(node) & set(assignment_name_set):
|
|
remove = True
|
|
if remove:
|
|
spans.append(node_span(text, node))
|
|
for start, end in sorted(spans, reverse=True):
|
|
text = text[:start] + text[end:]
|
|
return text
|
|
|
|
|
|
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
|
count = text.count(old)
|
|
if count != 1:
|
|
raise RuntimeError(f"{label}: expected one match, got {count}")
|
|
return text.replace(old, new, 1)
|
|
|
|
|
|
main_original = MAIN_PATH.read_text(encoding="utf-8")
|
|
main = main_original
|
|
|
|
APP_CONFIG_DEFS = [
|
|
"ConfigError", "_require_bool", "_require_int", "_require_string",
|
|
"validate_config_structure", "validate_run_requirements", "validate_config",
|
|
]
|
|
TOKEN_NAMES = [
|
|
"resolve_grok2api_local_token_file", "_normalize_sso_token",
|
|
"add_token_to_grok2api_local_pool", "get_grok2api_remote_api_bases",
|
|
"add_token_to_grok2api_remote_pool", "add_token_to_grok2api_pools",
|
|
]
|
|
RUNTIME_NAMES = [
|
|
"get_configured_proxy", "get_proxies", "_parse_proxy_url", "_safe_proxy_port",
|
|
"_proxy_has_auth", "_strip_proxy_auth", "_proxy_endpoint_terms",
|
|
"is_proxy_connection_error", "page_has_proxy_error",
|
|
"_ReusableThreadingTCPServer", "_proxy_recv_until_headers", "_proxy_relay",
|
|
"_LocalAuthProxyBridgeHandler", "LocalAuthProxyBridge", "prepare_browser_proxy",
|
|
"apply_browser_proxy_option", "create_browser_options", "_build_request_kwargs",
|
|
"http_get", "http_post",
|
|
]
|
|
MAIL_EXACT = {
|
|
"get_duckmail_api_key", "get_user_agent", "get_domains", "create_account",
|
|
"get_token", "get_messages", "get_message_detail", "generate_username",
|
|
"pick_domain", "get_email_provider", "get_email_and_token", "get_oai_code",
|
|
"extract_verification_code", "duckmail_get_oai_code", "_pick_list_payload",
|
|
}
|
|
main_defs = definition_map(main_original)
|
|
MAIL_NAMES = sorted(
|
|
name for name in main_defs
|
|
if name in MAIL_EXACT
|
|
or name.startswith("cloudflare_")
|
|
or name.startswith("cloudmail_")
|
|
or name.startswith("yyds_")
|
|
or name.startswith("get_cloudflare_")
|
|
or name.startswith("get_cloudmail_")
|
|
or name.startswith("get_yyds_")
|
|
)
|
|
# Exclude non-mail Cloudflare response helpers used for NSFW settings.
|
|
MAIL_NAMES = [name for name in MAIL_NAMES if name not in {
|
|
"is_cloudflare_block_response",
|
|
}]
|
|
REGISTRATION_NAMES = [
|
|
"generate_random_birthdate", "response_preview", "is_cloudflare_block_response",
|
|
"set_birth_date", "set_tos_accepted", "encode_grpc_nsfw_settings",
|
|
"update_nsfw_settings", "enable_nsfw_for_token", "stop_browser_proxy_bridge",
|
|
"start_browser", "stop_browser", "restart_browser", "cleanup_runtime_memory",
|
|
"refresh_active_page", "click_email_signup_button", "open_signup_page",
|
|
"has_profile_form", "fill_email_and_submit", "fill_code_and_submit",
|
|
"getTurnstileToken", "build_profile", "fill_profile_and_submit",
|
|
"wait_for_sso_cookie",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app_config.py: standalone mutable config object and two-stage validation.
|
|
# ---------------------------------------------------------------------------
|
|
default_config_src = source_for_assignments(main_original, ["DEFAULT_CONFIG"])
|
|
config_error_src = source_for_names(main_original, ["ConfigError"])
|
|
validator_src = source_for_names(main_original, APP_CONFIG_DEFS[1:])
|
|
app_config = f'''"""Application configuration loading, normalization, and validation."""
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import urllib.parse
|
|
|
|
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
|
|
|
{default_config_src}
|
|
config = DEFAULT_CONFIG.copy()
|
|
|
|
{config_error_src}
|
|
{validator_src}
|
|
|
|
def _replace_config(value):
|
|
config.clear()
|
|
config.update(value)
|
|
return config
|
|
|
|
|
|
def load_config():
|
|
if os.path.exists(CONFIG_FILE):
|
|
try:
|
|
with open(CONFIG_FILE, "r", encoding="utf-8") as handle:
|
|
loaded = json.load(handle)
|
|
return _replace_config(validate_config_structure(loaded))
|
|
except ConfigError:
|
|
raise
|
|
except Exception as exc:
|
|
raise ConfigError(f"配置文件解析失败: {{CONFIG_FILE}}: {{exc}}") from exc
|
|
return _replace_config(validate_config_structure(DEFAULT_CONFIG.copy()))
|
|
|
|
|
|
def save_config():
|
|
normalized = validate_config_structure(config)
|
|
_replace_config(normalized)
|
|
config_dir = os.path.dirname(os.path.abspath(CONFIG_FILE))
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
fd = None
|
|
temp_path = None
|
|
try:
|
|
fd, temp_path = tempfile.mkstemp(prefix=".config-", suffix=".json.tmp", dir=config_dir)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
fd = None
|
|
json.dump(config, handle, indent=4, ensure_ascii=False)
|
|
handle.write("\\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
try:
|
|
os.chmod(temp_path, 0o600)
|
|
except Exception:
|
|
pass
|
|
os.replace(temp_path, CONFIG_FILE)
|
|
temp_path = None
|
|
try:
|
|
os.chmod(CONFIG_FILE, 0o600)
|
|
except Exception:
|
|
pass
|
|
except Exception as exc:
|
|
raise ConfigError(f"保存配置失败: {{exc}}") from exc
|
|
finally:
|
|
if fd is not None:
|
|
try:
|
|
os.close(fd)
|
|
except Exception:
|
|
pass
|
|
if temp_path and os.path.exists(temp_path):
|
|
try:
|
|
os.unlink(temp_path)
|
|
except Exception:
|
|
pass
|
|
return config
|
|
'''
|
|
ast.parse(app_config)
|
|
(ROOT / "app_config.py").write_text(app_config, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# browser_runtime.py: single proxy implementation via cpa_xai.proxyutil.
|
|
# ---------------------------------------------------------------------------
|
|
browser_runtime = r'''"""Shared HTTP, proxy, and Chromium option helpers."""
|
|
import os
|
|
import urllib.parse
|
|
|
|
from DrissionPage import ChromiumOptions
|
|
from curl_cffi import requests
|
|
from cpa_xai.proxyutil import (
|
|
LocalAuthProxyBridge,
|
|
prepare_chromium_proxy,
|
|
proxy_for_chromium,
|
|
)
|
|
|
|
_config = {}
|
|
_extension_path = ""
|
|
|
|
|
|
def configure_runtime(config_ref, extension_path=""):
|
|
global _config, _extension_path
|
|
_config = config_ref
|
|
_extension_path = str(extension_path or "")
|
|
|
|
|
|
def get_configured_proxy():
|
|
return str(_config.get("proxy", "") or "").strip()
|
|
|
|
|
|
def get_proxies():
|
|
proxy = get_configured_proxy()
|
|
return {"http": proxy, "https": proxy} if proxy else {}
|
|
|
|
|
|
def _parse_proxy_url(proxy):
|
|
raw = str(proxy or "").strip()
|
|
if not raw:
|
|
return None
|
|
if "://" not in raw:
|
|
raw = "http://" + raw
|
|
try:
|
|
return urllib.parse.urlsplit(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _safe_proxy_port(parsed):
|
|
try:
|
|
return parsed.port
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _proxy_has_auth(proxy):
|
|
parsed = _parse_proxy_url(proxy)
|
|
return bool(parsed and parsed.hostname and (parsed.username is not None or parsed.password is not None))
|
|
|
|
|
|
def _strip_proxy_auth(proxy):
|
|
raw = str(proxy or "").strip()
|
|
parsed = _parse_proxy_url(raw)
|
|
if not parsed or not parsed.hostname:
|
|
return raw
|
|
host = parsed.hostname
|
|
if ":" in host and not host.startswith("["):
|
|
host = "[%s]" % host
|
|
port = _safe_proxy_port(parsed)
|
|
netloc = "%s:%s" % (host, port) if port else host
|
|
stripped = urllib.parse.urlunsplit((parsed.scheme or "http", netloc, parsed.path, parsed.query, parsed.fragment))
|
|
return stripped.split("://", 1)[1] if "://" not in raw else stripped
|
|
|
|
|
|
def _proxy_endpoint_terms(proxy=None):
|
|
parsed = _parse_proxy_url(proxy or get_configured_proxy())
|
|
if not parsed or not parsed.hostname:
|
|
return []
|
|
terms = [parsed.hostname]
|
|
port = _safe_proxy_port(parsed)
|
|
if port:
|
|
terms.extend(["%s:%s" % (parsed.hostname, port), "port %s" % port])
|
|
return [item.lower() for item in terms if item]
|
|
|
|
|
|
def is_proxy_connection_error(exc):
|
|
if not get_configured_proxy():
|
|
return False
|
|
err = str(exc or "").lower()
|
|
if not err:
|
|
return False
|
|
if any(item in err for item in ("proxy", "tunnel", "socks")):
|
|
return True
|
|
markers = (
|
|
"could not connect", "failed to connect", "connection refused",
|
|
"connection reset", "connect error", "timed out", "timeout",
|
|
)
|
|
if any(item in err for item in markers):
|
|
terms = _proxy_endpoint_terms()
|
|
return not terms or any(term in err for term in terms)
|
|
return False
|
|
|
|
|
|
def page_has_proxy_error(page_obj):
|
|
try:
|
|
url = str(getattr(page_obj, "url", "") or "")
|
|
title = str(page_obj.run_js("return document.title || ''") or "")
|
|
body = str(page_obj.run_js("return document.body ? document.body.innerText.slice(0, 2000) : ''") or "")
|
|
except Exception:
|
|
return False
|
|
text = "%s\n%s\n%s" % (url, title, body)
|
|
text = text.lower()
|
|
return any(marker in text for marker in (
|
|
"err_proxy", "proxy connection failed", "proxy server",
|
|
"proxy authentication", "tunnel connection failed",
|
|
"无法连接到代理服务器", "代理服务器",
|
|
))
|
|
|
|
|
|
def prepare_browser_proxy(use_proxy=True, log_callback=None):
|
|
proxy = get_configured_proxy()
|
|
if not use_proxy or not proxy:
|
|
return "", None
|
|
parsed = _parse_proxy_url(proxy)
|
|
if _proxy_has_auth(proxy) and parsed and (parsed.scheme or "http").lower() not in ("http", "https"):
|
|
stripped = _strip_proxy_auth(proxy)
|
|
if log_callback:
|
|
log_callback("[!] Chromium 暂不直接支持该认证代理协议,已使用去认证代理地址,失败将回退直连")
|
|
return stripped, None
|
|
logger = None
|
|
if log_callback:
|
|
logger = lambda message: log_callback("[*] 已为 Chromium启动本地认证代理桥: %s" % message.split(": ", 1)[-1]) if "started authenticated proxy bridge" in message else log_callback(message)
|
|
return prepare_chromium_proxy(proxy, log=logger)
|
|
|
|
|
|
def apply_browser_proxy_option(options, proxy):
|
|
if not proxy:
|
|
return
|
|
if hasattr(options, "set_proxy"):
|
|
try:
|
|
options.set_proxy(proxy)
|
|
return
|
|
except Exception:
|
|
pass
|
|
if not hasattr(options, "set_argument"):
|
|
raise AttributeError("当前 DrissionPage ChromiumOptions 不支持设置浏览器代理")
|
|
try:
|
|
options.set_argument("--proxy-server=%s" % proxy)
|
|
except TypeError:
|
|
options.set_argument("--proxy-server", proxy)
|
|
|
|
|
|
def create_browser_options(browser_proxy=""):
|
|
options = ChromiumOptions()
|
|
options.auto_port()
|
|
options.set_timeouts(base=1)
|
|
apply_browser_proxy_option(options, browser_proxy)
|
|
if _extension_path and os.path.exists(_extension_path):
|
|
options.add_extension(_extension_path)
|
|
return options
|
|
|
|
|
|
def _build_request_kwargs(**kwargs):
|
|
request_kwargs = dict(kwargs)
|
|
proxies = request_kwargs.pop("proxies", None)
|
|
if proxies is None:
|
|
proxies = get_proxies()
|
|
if proxies:
|
|
request_kwargs["proxies"] = proxies
|
|
request_kwargs.setdefault("timeout", 15)
|
|
return request_kwargs
|
|
|
|
|
|
def http_get(url, **kwargs):
|
|
request_kwargs = _build_request_kwargs(**kwargs)
|
|
try:
|
|
return requests.get(url, **request_kwargs)
|
|
except Exception as exc:
|
|
if is_proxy_connection_error(exc):
|
|
direct = dict(request_kwargs)
|
|
direct.pop("proxies", None)
|
|
return requests.get(url, **direct)
|
|
raise
|
|
|
|
|
|
def http_post(url, **kwargs):
|
|
request_kwargs = _build_request_kwargs(**kwargs)
|
|
try:
|
|
return requests.post(url, **request_kwargs)
|
|
except Exception as exc:
|
|
if is_proxy_connection_error(exc):
|
|
direct = dict(request_kwargs)
|
|
direct.pop("proxies", None)
|
|
return requests.post(url, **direct)
|
|
raise
|
|
'''
|
|
ast.parse(browser_runtime)
|
|
(ROOT / "browser_runtime.py").write_text(browser_runtime, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# account_outputs.py: add token-pool persistence with explicit dependencies.
|
|
# ---------------------------------------------------------------------------
|
|
account_path = ROOT / "account_outputs.py"
|
|
account = account_path.read_text(encoding="utf-8")
|
|
if "import time\n" not in account:
|
|
account = account.replace("import tempfile\n", "import tempfile\nimport time\n")
|
|
token_source = source_for_names(main_original, TOKEN_NAMES)
|
|
account += r'''
|
|
|
|
# Token-pool runtime dependencies are injected by the application adapter.
|
|
config = {}
|
|
_http_get = None
|
|
_http_post = None
|
|
_log_exception = None
|
|
_remote_compat_error = RuntimeError
|
|
_remote_request_error = RuntimeError
|
|
|
|
|
|
def configure_token_runtime(config_ref, http_get, http_post, log_exception,
|
|
compatibility_error=RuntimeError, request_error=RuntimeError):
|
|
global config, _http_get, _http_post, _log_exception
|
|
global _remote_compat_error, _remote_request_error
|
|
config = config_ref
|
|
_http_get = http_get
|
|
_http_post = http_post
|
|
_log_exception = log_exception
|
|
_remote_compat_error = compatibility_error
|
|
_remote_request_error = request_error
|
|
globals()["http_get"] = http_get
|
|
globals()["http_post"] = http_post
|
|
globals()["log_exception"] = log_exception
|
|
globals()["RemoteTokenCompatibilityError"] = compatibility_error
|
|
globals()["RemoteTokenRequestError"] = request_error
|
|
|
|
|
|
'''
|
|
account += token_source
|
|
ast.parse(account)
|
|
account_path.write_text(account, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mail_service.py: move the existing provider implementations unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
mail_source = source_for_names(main_original, MAIL_NAMES)
|
|
constants = source_for_assignments(main_original, ["DUCKMAIL_API_BASE", "YYDS_API_BASE"])
|
|
mail_service = f'''"""Temporary-mail providers shared by GUI, CLI, and debug tooling."""
|
|
import re
|
|
import secrets
|
|
import string
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from curl_cffi import requests
|
|
|
|
{constants}
|
|
config = {{}}
|
|
_cf_domain_index = 0
|
|
_cloudmail_domain_index = 0
|
|
_OWN_NAMES = {set(MAIL_NAMES)!r}
|
|
|
|
|
|
def bind_runtime(namespace):
|
|
global config
|
|
config = namespace.get("config", config)
|
|
for name, value in namespace.items():
|
|
if name.startswith("__") or name in _OWN_NAMES or name in {{"config", "_cf_domain_index", "_cloudmail_domain_index"}}:
|
|
continue
|
|
globals()[name] = value
|
|
|
|
|
|
{mail_source}
|
|
|
|
class CloudflareMailClient:
|
|
"""Standalone Cloudflare mail client used by the debug CLI."""
|
|
def __init__(self, api_base, auth_mode="none", api_key="", create_path="/api/new_address", timeout=20):
|
|
self.api_base = str(api_base or "").rstrip("/")
|
|
self.auth_mode = str(auth_mode or "none").lower()
|
|
self.api_key = str(api_key or "")
|
|
self.create_path = self.normalize_path(create_path, "/api/new_address")
|
|
self.timeout = int(timeout)
|
|
|
|
@staticmethod
|
|
def normalize_path(path, default_path):
|
|
raw = (path or default_path).strip() or default_path
|
|
return raw if raw.startswith("/") else "/" + raw
|
|
|
|
def build_auth_headers(self, content_type=False):
|
|
headers = {{"Content-Type": "application/json"}} if content_type else {{}}
|
|
if not self.api_key:
|
|
return headers
|
|
if self.auth_mode == "x-admin-auth":
|
|
headers["x-admin-auth"] = self.api_key
|
|
elif self.auth_mode == "x-api-key":
|
|
headers["X-API-Key"] = self.api_key
|
|
elif self.auth_mode == "bearer":
|
|
headers["Authorization"] = "Bearer " + self.api_key
|
|
return headers
|
|
|
|
@staticmethod
|
|
def json_or_text(response):
|
|
try:
|
|
return response.json(), ""
|
|
except Exception:
|
|
return None, str(getattr(response, "text", "") or "")[:400]
|
|
|
|
def create_address(self, domain="", name=""):
|
|
is_admin = self.create_path.rstrip("/").lower() == "/admin/new_address"
|
|
payload = {{}}
|
|
headers = {{"Content-Type": "application/json"}}
|
|
if is_admin:
|
|
payload = {{"name": name.strip() if str(name).strip() else generate_username(), "enablePrefix": True}}
|
|
if str(domain).strip():
|
|
payload["domain"] = str(domain).strip()
|
|
headers = self.build_auth_headers(content_type=True)
|
|
elif str(domain).strip():
|
|
payload["domain"] = str(domain).strip()
|
|
response = requests.post(self.api_base + self.create_path, json=payload, headers=headers, timeout=self.timeout)
|
|
response.raise_for_status()
|
|
data, raw = self.json_or_text(response)
|
|
if not data:
|
|
raise RuntimeError("%s 非JSON: %s" % (self.create_path, raw))
|
|
address = str(data.get("address", "")).strip()
|
|
jwt = str(data.get("jwt", "")).strip()
|
|
if not address or not jwt:
|
|
raise RuntimeError("%s 缺少 address/jwt: %r" % (self.create_path, data))
|
|
return address, jwt
|
|
|
|
def fetch_box(self, jwt, path, params):
|
|
response = requests.get(
|
|
self.api_base + path,
|
|
params=params,
|
|
headers={{"Authorization": "Bearer " + str(jwt)}},
|
|
timeout=self.timeout,
|
|
)
|
|
if response.status_code >= 400:
|
|
return []
|
|
data, _ = self.json_or_text(response)
|
|
return _pick_list_payload(data)
|
|
|
|
def probe_all_boxes(self, jwt):
|
|
probes = [
|
|
("/api/mails", {{"limit": 20, "offset": 0}}),
|
|
("/api/sendbox", {{"limit": 20, "offset": 0}}),
|
|
("/api/mails", {{"limit": 20, "offset": 0, "box": "trash"}}),
|
|
("/api/mails", {{"limit": 20, "offset": 0, "folder": "trash"}}),
|
|
("/api/mails", {{"limit": 20, "offset": 0, "deleted": "1"}}),
|
|
("/api/mails", {{"limit": 20, "offset": 0, "status": "deleted"}}),
|
|
]
|
|
return [("%s?%s" % (path, params), self.fetch_box(jwt, path, params)) for path, params in probes]
|
|
|
|
def get_detail(self, jwt, mail_id):
|
|
for path in ("/api/mail/%s" % mail_id, "/api/mails/%s" % mail_id):
|
|
try:
|
|
response = requests.get(
|
|
self.api_base + path,
|
|
headers={{"Authorization": "Bearer " + str(jwt)}},
|
|
timeout=self.timeout,
|
|
)
|
|
if response.status_code >= 400:
|
|
continue
|
|
data, _ = self.json_or_text(response)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
continue
|
|
return {{}}
|
|
|
|
@staticmethod
|
|
def flatten_mail_text(item, detail):
|
|
subject = str(item.get("subject") or detail.get("subject") or "")
|
|
parts = []
|
|
for source in (item, detail):
|
|
for key in ("text", "raw", "content", "intro", "body", "snippet"):
|
|
value = source.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
parts.append(value)
|
|
html_value = source.get("html")
|
|
if isinstance(html_value, str):
|
|
html_value = [html_value]
|
|
if isinstance(html_value, list):
|
|
parts.extend(re.sub(r"<[^>]+>", " ", item) for item in html_value if isinstance(item, str))
|
|
return subject, "\\n".join(parts)
|
|
'''
|
|
ast.parse(mail_service)
|
|
(ROOT / "mail_service.py").write_text(mail_service, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# registration_browser.py: move browser state and page automation unchanged.
|
|
# ---------------------------------------------------------------------------
|
|
registration_source = source_for_names(main_original, REGISTRATION_NAMES)
|
|
registration_browser = f'''"""Registration browser lifecycle and page automation."""
|
|
import gc
|
|
import random
|
|
import re
|
|
import secrets
|
|
import struct
|
|
import time
|
|
|
|
from DrissionPage import Chromium
|
|
from DrissionPage.errors import PageDisconnectedError
|
|
from curl_cffi import requests
|
|
|
|
browser = None
|
|
page = None
|
|
browser_proxy_bridge = None
|
|
browser_started_with_proxy = False
|
|
cf_clearance = ""
|
|
SIGNUP_URL = "https://accounts.x.ai/sign-up"
|
|
_OWN_NAMES = {set(REGISTRATION_NAMES)!r}
|
|
|
|
|
|
def bind_runtime(namespace):
|
|
for name, value in namespace.items():
|
|
if name.startswith("__") or name in _OWN_NAMES or name in {{
|
|
"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance",
|
|
}}:
|
|
continue
|
|
globals()[name] = value
|
|
|
|
|
|
{registration_source}
|
|
'''
|
|
ast.parse(registration_browser)
|
|
(ROOT / "registration_browser.py").write_text(registration_browser, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rewrite main as GUI/CLI adapter plus compatibility wrappers.
|
|
# ---------------------------------------------------------------------------
|
|
main = remove_top_level(main, APP_CONFIG_DEFS, {"CONFIG_FILE", "DEFAULT_CONFIG", "config", "_cf_domain_index", "_cloudmail_domain_index"})
|
|
main = remove_top_level(main, TOKEN_NAMES)
|
|
main = remove_top_level(main, RUNTIME_NAMES)
|
|
main = remove_top_level(main, MAIL_NAMES, {"DUCKMAIL_API_BASE", "YYDS_API_BASE"})
|
|
main = remove_top_level(main, REGISTRATION_NAMES, {"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance", "SIGNUP_URL"})
|
|
|
|
import_block = '''\nimport functools\nimport app_config as _app_config\nimport account_outputs as _account_outputs\nimport browser_runtime as _browser_runtime\nimport mail_service as _mail_service\nimport registration_browser as _registration_browser\nfrom app_config import (\n CONFIG_FILE, DEFAULT_CONFIG, ConfigError, config, load_config, save_config,\n validate_config, validate_config_structure, validate_run_requirements,\n)\n\n'''
|
|
main = replace_once(main, "from curl_cffi import requests\n", "from curl_cffi import requests\n" + import_block, "main imports")
|
|
|
|
compat_block = f'''\ndef _make_compat_proxy(module, name, binder=None):
|
|
target = getattr(module, name)
|
|
@functools.wraps(target)
|
|
def proxy(*args, **kwargs):
|
|
if binder is not None:
|
|
binder()
|
|
return getattr(module, name)(*args, **kwargs)
|
|
return proxy
|
|
|
|
|
|
def _bind_browser_runtime():
|
|
_browser_runtime.configure_runtime(config, EXTENSION_PATH)
|
|
|
|
|
|
def _bind_account_outputs():
|
|
_account_outputs.configure_token_runtime(
|
|
config, http_get, http_post, log_exception,
|
|
compatibility_error=RemoteTokenCompatibilityError,
|
|
request_error=RemoteTokenRequestError,
|
|
)
|
|
|
|
|
|
def _bind_mail_service():
|
|
_mail_service.bind_runtime(globals())
|
|
|
|
|
|
def _bind_registration_browser():
|
|
_registration_browser.bind_runtime(globals())
|
|
|
|
|
|
LocalAuthProxyBridge = _browser_runtime.LocalAuthProxyBridge
|
|
for _name in {RUNTIME_NAMES!r}:
|
|
if _name.startswith("_") and _name in {{"_ReusableThreadingTCPServer", "_LocalAuthProxyBridgeHandler", "_proxy_recv_until_headers", "_proxy_relay"}}:
|
|
continue
|
|
if _name != "LocalAuthProxyBridge":
|
|
globals()[_name] = _make_compat_proxy(_browser_runtime, _name, _bind_browser_runtime)
|
|
for _name in {TOKEN_NAMES!r}:
|
|
globals()[_name] = _make_compat_proxy(_account_outputs, _name, _bind_account_outputs)
|
|
for _name in {MAIL_NAMES!r}:
|
|
globals()[_name] = _make_compat_proxy(_mail_service, _name, _bind_mail_service)
|
|
for _name in {REGISTRATION_NAMES!r}:
|
|
globals()[_name] = _make_compat_proxy(_registration_browser, _name, _bind_registration_browser)
|
|
|
|
|
|
def __getattr__(name):
|
|
if name in {{"browser", "page", "browser_proxy_bridge", "browser_started_with_proxy", "cf_clearance"}}:
|
|
return getattr(_registration_browser, name)
|
|
raise AttributeError(name)
|
|
|
|
|
|
'''
|
|
main = replace_once(main, "def raise_if_cancelled(cancel_callback=None):", compat_block + "def raise_if_cancelled(cancel_callback=None):", "compat block")
|
|
main = main.replace("current_page = page", "current_page = _registration_browser.page")
|
|
main = main.replace("browser_missing=lambda: browser is None", "browser_missing=lambda: _registration_browser.browser is None")
|
|
ast.parse(main)
|
|
MAIN_PATH.write_text(main, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CPA browser lifecycle extraction.
|
|
# ---------------------------------------------------------------------------
|
|
browser_confirm_path = ROOT / "cpa_xai" / "browser_confirm.py"
|
|
browser_confirm_original = browser_confirm_path.read_text(encoding="utf-8")
|
|
SESSION_NAMES = [
|
|
"_noop_log", "BrowserConfirmError", "_sleep", "create_standalone_page",
|
|
"close_standalone", "_register_mint_browser", "_unregister_mint_browser",
|
|
"_mint_tls_get", "clear_page_session", "normalize_cookies", "inject_cookies",
|
|
"acquire_mint_browser", "release_mint_browser", "shutdown_mint_browsers",
|
|
]
|
|
session_source = source_for_names(browser_confirm_original, SESSION_NAMES)
|
|
browser_session = '''"""CPA Chromium session lifecycle, reuse, cleanup, and cookies."""\nfrom __future__ import annotations\n\nimport os\nimport sys\nimport threading\nimport time\nfrom pathlib import Path\nfrom typing import Any, Callable, Optional\n\nLogFn = Callable[[str], None]\n_mint_tls = threading.local()\n_mint_registry_lock = threading.Lock()\n_mint_registry = set()\n\n''' + session_source
|
|
ast.parse(browser_session)
|
|
(ROOT / "cpa_xai" / "browser_session.py").write_text(browser_session, encoding="utf-8")
|
|
|
|
browser_confirm = remove_top_level(browser_confirm_original, SESSION_NAMES, {"_mint_tls", "_mint_registry_lock", "_mint_registry"})
|
|
session_import = '''from .browser_session import (\n BrowserConfirmError, _noop_log, _sleep, create_standalone_page, close_standalone,\n clear_page_session, normalize_cookies, inject_cookies, acquire_mint_browser,\n release_mint_browser, shutdown_mint_browsers,\n)\n\n'''
|
|
browser_confirm = replace_once(browser_confirm, "LogFn = Callable[[str], None]\n", "LogFn = Callable[[str], None]\n\n" + session_import, "browser session import")
|
|
ast.parse(browser_confirm)
|
|
browser_confirm_path.write_text(browser_confirm, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cf_mail_debug.py becomes a thin CLI around CloudflareMailClient.
|
|
# ---------------------------------------------------------------------------
|
|
cf_debug = r'''#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
import argparse
|
|
import time
|
|
|
|
from mail_service import CloudflareMailClient, extract_verification_code
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--api-base", required=True)
|
|
parser.add_argument("--address", default="")
|
|
parser.add_argument("--credential", default="")
|
|
parser.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"])
|
|
parser.add_argument("--api-key", default="")
|
|
parser.add_argument("--create-path", default="/api/new_address")
|
|
parser.add_argument("--domain", default="")
|
|
parser.add_argument("--name", default="")
|
|
parser.add_argument("--timeout", type=int, default=180)
|
|
parser.add_argument("--interval", type=int, default=3)
|
|
args = parser.parse_args()
|
|
|
|
client = CloudflareMailClient(
|
|
args.api_base,
|
|
auth_mode=args.auth_mode,
|
|
api_key=args.api_key,
|
|
create_path=args.create_path,
|
|
)
|
|
address = args.address.strip()
|
|
credential = args.credential.strip()
|
|
if not credential:
|
|
address, credential = client.create_address(domain=args.domain, name=args.name)
|
|
print("[NEW] address=%s" % address)
|
|
print("[NEW] credential(jwt)=%s" % credential)
|
|
else:
|
|
print("[USE] address=%s" % (address or "(unknown, from credential)"))
|
|
|
|
deadline = time.time() + max(args.timeout, 1)
|
|
seen_ids = set()
|
|
while time.time() < deadline:
|
|
boxes = client.probe_all_boxes(credential)
|
|
total = 0
|
|
for box_name, mails in boxes:
|
|
if mails:
|
|
print("[BOX] %s -> %s" % (box_name, len(mails)))
|
|
total += len(mails)
|
|
for item in mails:
|
|
mail_id = item.get("id") or item.get("mail_id")
|
|
if not mail_id or mail_id in seen_ids:
|
|
continue
|
|
seen_ids.add(mail_id)
|
|
detail = client.get_detail(credential, mail_id)
|
|
subject, text = client.flatten_mail_text(item, detail)
|
|
code = extract_verification_code(text, subject)
|
|
print("[MAIL] id=%s subject=%r code=%r" % (mail_id, subject, code))
|
|
if code:
|
|
print("[FOUND] %s" % code)
|
|
return
|
|
if total == 0:
|
|
print("[INFO] no mails yet")
|
|
time.sleep(max(args.interval, 1))
|
|
print("[TIMEOUT] no code found")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
'''
|
|
ast.parse(cf_debug)
|
|
(ROOT / "cf_mail_debug.py").write_text(cf_debug, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cpa_export.py settings object and import without sys.path mutation.
|
|
# ---------------------------------------------------------------------------
|
|
cpa_export = r'''"""Optional post-registration CPA/OIDC export hook."""
|
|
from dataclasses import dataclass
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import time
|
|
from pathlib import Path
|
|
|
|
_ROOT = Path(__file__).resolve().parent
|
|
_DEFAULT_AUTH_DIR = _ROOT / "cpa_auths"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CpaExportSettings:
|
|
enabled: bool
|
|
auth_dir: Path
|
|
hotload_dir: Path | None
|
|
copy_to_hotload: bool
|
|
proxy: str
|
|
headless: bool
|
|
mint_timeout: float
|
|
request_timeout: float
|
|
poll_timeout: float
|
|
base_url: str
|
|
force_standalone: bool
|
|
cookie_inject: bool
|
|
tools_dir: str
|
|
|
|
@classmethod
|
|
def from_config(cls, config):
|
|
cfg = dict(config or {})
|
|
auth_dir = Path(cfg.get("cpa_auth_dir") or _DEFAULT_AUTH_DIR).expanduser()
|
|
if not auth_dir.is_absolute():
|
|
auth_dir = (_ROOT / auth_dir).resolve()
|
|
hotload_value = str(cfg.get("cpa_hotload_dir") or "").strip()
|
|
hotload_dir = Path(hotload_value).expanduser() if hotload_value else None
|
|
if hotload_dir is not None and not hotload_dir.is_absolute():
|
|
hotload_dir = (_ROOT / hotload_dir).resolve()
|
|
return cls(
|
|
enabled=bool(cfg.get("cpa_export_enabled", False)),
|
|
auth_dir=auth_dir,
|
|
hotload_dir=hotload_dir,
|
|
copy_to_hotload=bool(cfg.get("cpa_copy_to_hotload", False)),
|
|
proxy=str(cfg.get("cpa_proxy") or cfg.get("proxy") or "").strip(),
|
|
headless=bool(cfg.get("cpa_headless", False)),
|
|
mint_timeout=float(cfg.get("cpa_mint_timeout_sec") or 300),
|
|
request_timeout=float(cfg.get("cpa_oidc_request_timeout_sec") or 15),
|
|
poll_timeout=float(cfg.get("cpa_oidc_poll_timeout_sec") or 15),
|
|
base_url=str(cfg.get("cpa_base_url") or "https://cli-chat-proxy.grok.com/v1").strip(),
|
|
force_standalone=bool(cfg.get("cpa_force_standalone", True)),
|
|
cookie_inject=bool(cfg.get("cpa_mint_cookie_inject", True)),
|
|
tools_dir=str(cfg.get("api_reverse_tools") or "").strip(),
|
|
)
|
|
|
|
|
|
def _load_mint_and_export(tools_dir=""):
|
|
tools_value = str(tools_dir or "").strip()
|
|
if not tools_value:
|
|
from cpa_xai import mint_and_export
|
|
return mint_and_export
|
|
tools = Path(tools_value).expanduser().resolve()
|
|
package = tools if tools.name == "cpa_xai" else tools / "cpa_xai"
|
|
init_path = package / "__init__.py"
|
|
if package.resolve() == (_ROOT / "cpa_xai").resolve():
|
|
from cpa_xai import mint_and_export
|
|
return mint_and_export
|
|
if not init_path.is_file():
|
|
raise ImportError("cpa_xai package not found under %s" % tools)
|
|
module_name = "_external_cpa_xai_%s" % abs(hash(str(package)))
|
|
spec = importlib.util.spec_from_file_location(
|
|
module_name,
|
|
str(init_path),
|
|
submodule_search_locations=[str(package)],
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError("unable to load cpa_xai from %s" % package)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module.mint_and_export
|
|
|
|
|
|
def export_cookies_from_page(page):
|
|
if page is None:
|
|
return []
|
|
cookies = None
|
|
for getter in (
|
|
lambda: page.cookies(all_domains=True, all_info=True),
|
|
lambda: page.cookies(all_domains=True),
|
|
lambda: page.cookies(),
|
|
):
|
|
try:
|
|
cookies = getter()
|
|
if cookies:
|
|
break
|
|
except TypeError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
if not cookies:
|
|
try:
|
|
browser = getattr(page, "browser", None)
|
|
if browser is not None:
|
|
cookies = browser.cookies()
|
|
except Exception:
|
|
cookies = None
|
|
return [item for item in cookies if isinstance(item, dict)] if isinstance(cookies, list) else []
|
|
|
|
|
|
def _normalize_result(result, email=""):
|
|
value = dict(result or {})
|
|
value.setdefault("ok", False)
|
|
value.setdefault("skipped", False)
|
|
value.setdefault("email", str(email or ""))
|
|
value.setdefault("error", None)
|
|
return value
|
|
|
|
|
|
def export_cpa_xai_for_account(email, password, page=None, cookies=None, sso=None,
|
|
config=None, log_callback=None, cancel_callback=None):
|
|
settings = CpaExportSettings.from_config(config)
|
|
log = log_callback or (lambda message: None)
|
|
if not settings.enabled:
|
|
return _normalize_result({"ok": False, "skipped": True, "reason": "disabled"}, email)
|
|
try:
|
|
mint_and_export = _load_mint_and_export(settings.tools_dir)
|
|
except Exception as exc:
|
|
log("[cpa] import cpa_xai failed: %s" % exc)
|
|
return _normalize_result({"ok": False, "error": "import: %s" % exc}, email)
|
|
|
|
use_cookies = cookies
|
|
if use_cookies is None and settings.cookie_inject and page is not None:
|
|
use_cookies = export_cookies_from_page(page)
|
|
if not settings.cookie_inject:
|
|
use_cookies = None
|
|
elif sso:
|
|
base = list(use_cookies) if isinstance(use_cookies, list) else []
|
|
sso_value = str(sso).strip()
|
|
for cookie_name in ("sso", "sso-rw"):
|
|
for domain in (".x.ai", "accounts.x.ai", ".accounts.x.ai", "auth.x.ai", ".auth.x.ai", "grok.com", ".grok.com"):
|
|
base.append({"name": cookie_name, "value": sso_value, "domain": domain,
|
|
"path": "/", "secure": True, "httpOnly": True})
|
|
use_cookies = base
|
|
|
|
settings.auth_dir.mkdir(parents=True, exist_ok=True)
|
|
log("[cpa] mint OIDC for %s -> %s" % (email, settings.auth_dir))
|
|
result = mint_and_export(
|
|
email=email, password=password, auth_dir=settings.auth_dir,
|
|
page=None if settings.force_standalone else page,
|
|
proxy=settings.proxy or None, headless=settings.headless,
|
|
base_url=settings.base_url, browser_timeout_sec=settings.mint_timeout,
|
|
force_standalone=settings.force_standalone, cookies=use_cookies,
|
|
reuse_browser=True, recycle_every=15,
|
|
log=lambda message: log("[cpa] %s" % message), cancel=cancel_callback,
|
|
request_timeout_sec=settings.request_timeout,
|
|
poll_timeout_sec=settings.poll_timeout,
|
|
)
|
|
result = _normalize_result(result, email)
|
|
if result.get("ok") and result.get("path") and settings.copy_to_hotload and settings.hotload_dir:
|
|
try:
|
|
settings.hotload_dir.mkdir(parents=True, exist_ok=True)
|
|
source = Path(result["path"])
|
|
target = settings.hotload_dir / source.name
|
|
shutil.copy2(str(source), str(target))
|
|
try:
|
|
os.chmod(str(target), 0o600)
|
|
except Exception:
|
|
pass
|
|
result["hotload_path"] = str(target)
|
|
log("[cpa] hotload copy -> %s" % target)
|
|
except Exception as exc:
|
|
result["cpa_copy_error"] = str(exc)
|
|
log("[cpa] hotload copy failed: %s" % exc)
|
|
if not result.get("ok"):
|
|
fail_path = settings.auth_dir / "cpa_auth_failed.txt"
|
|
try:
|
|
with open(str(fail_path), "a", encoding="utf-8") as handle:
|
|
handle.write("%s----%s----%s\n" % (email, result.get("error") or "unknown", int(time.time())))
|
|
except Exception as exc:
|
|
log("[cpa] failed to persist failure record: %s" % exc)
|
|
return result
|
|
'''
|
|
ast.parse(cpa_export)
|
|
(ROOT / "cpa_export.py").write_text(cpa_export, encoding="utf-8")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional regression tests.
|
|
# ---------------------------------------------------------------------------
|
|
compat_tests = f'''import unittest
|
|
from unittest.mock import patch
|
|
|
|
import app_config
|
|
import grok_register_ttk as app
|
|
import mail_service
|
|
import registration_browser
|
|
from cpa_xai import browser_confirm
|
|
from cpa_xai import browser_session
|
|
|
|
|
|
class ModuleCompatibilityTests(unittest.TestCase):
|
|
def test_config_object_is_shared(self):
|
|
self.assertIs(app.config, app_config.config)
|
|
|
|
def test_original_public_functions_remain_available(self):
|
|
names = {sorted(set(MAIL_NAMES + REGISTRATION_NAMES + TOKEN_NAMES + ["http_get", "http_post", "create_browser_options"]))!r}
|
|
for name in names:
|
|
self.assertTrue(callable(getattr(app, name)), name)
|
|
|
|
def test_mail_wrapper_delegates(self):
|
|
with patch.object(mail_service, "get_email_provider", return_value="duckmail") as mocked:
|
|
self.assertEqual(app.get_email_provider(), "duckmail")
|
|
mocked.assert_called_once_with()
|
|
|
|
def test_browser_confirm_reexports_session_api(self):
|
|
self.assertIs(browser_confirm.close_standalone, browser_session.close_standalone)
|
|
self.assertIs(browser_confirm.normalize_cookies, browser_session.normalize_cookies)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
'''
|
|
(ROOT / "tests" / "test_module_compatibility.py").write_text(compat_tests, encoding="utf-8")
|
|
|
|
flow_test_path = ROOT / "tests" / "test_registration_flow.py"
|
|
flow_tests = flow_test_path.read_text(encoding="utf-8")
|
|
extra_flow = r'''
|
|
def test_cleanup_failure_does_not_change_success_statistics(self):
|
|
fake = FakeOps()
|
|
ops = fake.operations()
|
|
base_cleanup = ops.cleanup
|
|
def cleanup(reason):
|
|
if "已成功" in reason:
|
|
raise RuntimeError("cleanup failed")
|
|
base_cleanup(reason)
|
|
ops.cleanup = cleanup
|
|
batch = run_batch(2, self.callbacks(), lambda *args: None, ops, cleanup_interval=1)
|
|
self.assertEqual((batch.success_count, batch.fail_count, batch.processed_count), (2, 0, 2))
|
|
|
|
def test_cancel_during_next_account_wait_is_normal_cancellation(self):
|
|
fake = FakeOps()
|
|
ops = fake.operations()
|
|
ops.sleep = lambda seconds: (_ for _ in ()).throw(Cancelled())
|
|
batch = run_batch(2, self.callbacks(), lambda *args: None, ops)
|
|
self.assertTrue(batch.cancelled)
|
|
self.assertEqual(batch.processed_count, 1)
|
|
|
|
def test_final_cleanup_does_not_mask_original_error(self):
|
|
fake = FakeOps()
|
|
ops = fake.operations()
|
|
ops.start_browser = lambda: (_ for _ in ()).throw(ValueError("original"))
|
|
ops.cleanup = lambda reason: (_ for _ in ()).throw(RuntimeError("cleanup"))
|
|
with self.assertRaisesRegex(ValueError, "original"):
|
|
run_batch(1, self.callbacks(), lambda *args: None, ops)
|
|
|
|
def test_optional_postprocessing_exceptions_become_warning(self):
|
|
fake = FakeOps()
|
|
ops = fake.operations()
|
|
ops.add_tokens = lambda sso, email: (_ for _ in ()).throw(RuntimeError("pool"))
|
|
ops.export_cpa = lambda email, password, sso: (_ for _ in ()).throw(RuntimeError("cpa"))
|
|
batch = run_batch(1, self.callbacks(), lambda *args: None, ops)
|
|
self.assertEqual(batch.success_count, 1)
|
|
self.assertEqual(batch.postprocess_warning_count, 1)
|
|
'''
|
|
flow_tests = replace_once(flow_tests, '\n\nif __name__ == "__main__":\n', extra_flow + '\n\nif __name__ == "__main__":\n', "flow tests")
|
|
flow_test_path.write_text(flow_tests, encoding="utf-8")
|
|
|
|
oauth_tests = r'''import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from cpa_xai import oauth_device as oauth
|
|
|
|
|
|
class Response:
|
|
def __init__(self, body, status=200):
|
|
self.body = body.encode("utf-8")
|
|
self.status = status
|
|
def __enter__(self):
|
|
return self
|
|
def __exit__(self, *args):
|
|
return False
|
|
def read(self):
|
|
return self.body
|
|
|
|
|
|
class Opener:
|
|
def __init__(self, actions):
|
|
self.actions = list(actions)
|
|
self.calls = 0
|
|
def open(self, request, timeout=None):
|
|
self.calls += 1
|
|
action = self.actions.pop(0)
|
|
if isinstance(action, BaseException):
|
|
raise action
|
|
return action
|
|
|
|
|
|
class OAuthDeviceTests(unittest.TestCase):
|
|
def test_discovery_success(self):
|
|
payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"}
|
|
opener = Opener([Response(json.dumps(payload))])
|
|
with patch.object(oauth, "_build_opener", return_value=opener):
|
|
self.assertEqual(oauth.discover(retries=0)["token_endpoint"], payload["token_endpoint"])
|
|
|
|
def test_discovery_cancelled_before_request(self):
|
|
with self.assertRaisesRegex(oauth.OAuthDeviceError, "cancelled"):
|
|
oauth.discover(cancel=lambda: True)
|
|
|
|
def test_discovery_retries_transient_error(self):
|
|
payload = {"device_authorization_endpoint": "https://auth.x.ai/device", "token_endpoint": "https://auth.x.ai/token"}
|
|
opener = Opener([TimeoutError("slow"), Response(json.dumps(payload))])
|
|
with patch.object(oauth, "_build_opener", return_value=opener), patch.object(oauth, "_sleep_with_cancel"):
|
|
oauth.discover(retries=1)
|
|
self.assertEqual(opener.calls, 2)
|
|
|
|
def test_post_form_returns_non_json_body(self):
|
|
opener = Opener([Response("not-json", status=502)])
|
|
with patch.object(oauth, "_build_opener", return_value=opener):
|
|
status, payload = oauth._post_form("https://auth.x.ai/token", {}, retries=0)
|
|
self.assertEqual((status, payload), (502, "not-json"))
|
|
|
|
def test_slow_down_increases_wait(self):
|
|
responses = [
|
|
(400, {"error": "slow_down"}),
|
|
(200, {"access_token": "a", "refresh_token": "r"}),
|
|
]
|
|
waits = []
|
|
with patch.object(oauth, "_post_form", side_effect=responses), patch.object(oauth, "_sleep_with_cancel", side_effect=lambda seconds, cancel=None: waits.append(seconds)):
|
|
result = oauth.poll_device_token("d", "https://auth.x.ai/token", interval=1, expires_in=60)
|
|
self.assertEqual(result.refresh_token, "r")
|
|
self.assertEqual(waits, [6])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
'''
|
|
(ROOT / "tests" / "test_oauth_device.py").write_text(oauth_tests, encoding="utf-8")
|
|
|
|
browser_session_tests = r'''import unittest
|
|
from cpa_xai import browser_session
|
|
|
|
|
|
class Bridge:
|
|
def __init__(self):
|
|
self.stops = 0
|
|
def stop(self):
|
|
self.stops += 1
|
|
|
|
|
|
class Browser:
|
|
def __init__(self, bridge=None):
|
|
self._cpa_proxy_bridge = bridge
|
|
self.quits = 0
|
|
def quit(self):
|
|
self.quits += 1
|
|
|
|
|
|
class BrowserSessionTests(unittest.TestCase):
|
|
def test_close_standalone_closes_browser_and_bridge(self):
|
|
bridge = Bridge()
|
|
browser = Browser(bridge)
|
|
browser_session._register_mint_browser(browser)
|
|
browser_session.close_standalone(browser)
|
|
self.assertEqual(browser.quits, 1)
|
|
self.assertEqual(bridge.stops, 1)
|
|
|
|
def test_normalize_cookies_rejects_invalid_items(self):
|
|
value = browser_session.normalize_cookies([None, {"name": "a", "value": "b"}, "bad"])
|
|
self.assertEqual(len(value), 1)
|
|
self.assertEqual(value[0]["name"], "a")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
'''
|
|
(ROOT / "tests" / "test_browser_session.py").write_text(browser_session_tests, encoding="utf-8")
|
|
|
|
core_tests = r'''import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from cpa_xai.mint import mint_and_export
|
|
from cpa_xai.schema import build_cpa_xai_auth, jwt_payload
|
|
from cpa_xai.writer import write_cpa_xai_auth
|
|
|
|
|
|
class CpaCoreTests(unittest.TestCase):
|
|
def test_schema_rejects_missing_tokens(self):
|
|
with self.assertRaises(ValueError):
|
|
build_cpa_xai_auth("a@example.com", "", "refresh")
|
|
with self.assertRaises(ValueError):
|
|
jwt_payload("not-a-jwt")
|
|
|
|
def test_writer_failure_does_not_leave_temp_file(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
with patch("cpa_xai.writer.os.replace", side_effect=OSError("disk")):
|
|
with self.assertRaises(OSError):
|
|
write_cpa_xai_auth(directory, {"email": "a@example.com"}, "a.json")
|
|
self.assertEqual([p.name for p in Path(directory).iterdir()], [])
|
|
|
|
def test_mint_rejects_missing_identity_without_browser(self):
|
|
result = mint_and_export("", "", tempfile.gettempdir())
|
|
self.assertFalse(result["ok"])
|
|
self.assertIn("missing", result["error"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
'''
|
|
(ROOT / "tests" / "test_cpa_core.py").write_text(core_tests, encoding="utf-8")
|
|
|
|
# Syntax validation for every touched Python file.
|
|
for path in [
|
|
MAIN_PATH, ROOT / "app_config.py", ROOT / "account_outputs.py",
|
|
ROOT / "browser_runtime.py", ROOT / "mail_service.py",
|
|
ROOT / "registration_browser.py", browser_confirm_path,
|
|
ROOT / "cpa_xai" / "browser_session.py", ROOT / "cf_mail_debug.py",
|
|
ROOT / "cpa_export.py",
|
|
] + list((ROOT / "tests").glob("test_*.py")):
|
|
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
|
|
print("full safe modularization applied")
|