fix: harden token pools and CPA export

This commit is contained in:
github-actions[bot]
2026-07-14 13:43:54 +00:00
parent 6f799e19ce
commit 286a0f1734
11 changed files with 210 additions and 767 deletions
-34
View File
@@ -1,34 +0,0 @@
name: Temporary audit fixes
on:
workflow_dispatch:
push:
branches: [main]
issues:
types: [edited, opened, reopened]
permissions:
contents: write
jobs:
apply:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.issue.number == 7
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Repair audit patch escapes
run: python tools/fix_audit_patch_escapes.py
- name: Apply audit fixes
run: python tools/apply_audit_fixes.py
- name: Commit and cleanup
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git rm -f tools/apply_audit_fixes.py tools/fix_audit_patch_escapes.py .github/workflows/temp-audit-fixes.yml
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "fix: harden token pools and CPA export"
git push
fi
+2
View File
@@ -24,3 +24,5 @@ autoPortData/
cpa_auths/
xai-*.json
cpa_auth_failed.txt
screenshots/
*.png
+6 -1
View File
@@ -291,6 +291,7 @@ GUI 模式会打开 Tkinter 窗口,适合手动调整配置和观察日志。
- `mail_credentials.txt`:临时邮箱凭证。
- `cpa_auths/xai-*.json`:可选导出的 CPA xAI OAuth 凭证。
- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。
- `screenshots/`:CPA/OIDC 浏览器失败调试截图,已被 `.gitignore` 忽略。
- `*.log`:可选日志文件。
这些文件包含敏感信息,已被 `.gitignore` 忽略。
@@ -321,10 +322,14 @@ GUI 数量控件可能有上限。CLI 模式直接读取 `config.json` 中的 `r
```text
.
├── grok_register_ttk.py # 主程序
├── grok_register_ttk.py # 主程序GUI / CLI
├── cpa_export.py # 注册成功后的 CPA/OIDC 导出入口
├── cpa_xai/ # xAI Device Auth、浏览器授权和凭证写入模块
├── cf_mail_debug.py # Cloudflare 邮箱调试工具
├── config.example.json # 配置示例
├── requirements.txt # Python 依赖
├── tests/ # 现有测试用例
├── assets/ # README 资源
└── README.md
```
+55 -8
View File
@@ -189,10 +189,11 @@ def create_standalone_page(proxy: Optional[str] = None, headless: bool = False,
pass
break
from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy
from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy
resolved = resolve_proxy(proxy)
chrome_proxy = proxy_for_chromium(resolved)
proxy_bridge = None
chrome_proxy, proxy_bridge = prepare_chromium_proxy(resolved, log=logger)
if chrome_proxy:
options.set_argument("--proxy-server=%s" % chrome_proxy)
logger("browser proxy=%s (chromium %s)" % (proxy_log_label(resolved), chrome_proxy))
@@ -200,19 +201,50 @@ def create_standalone_page(proxy: Optional[str] = None, headless: bool = False,
logger("browser proxy=(none)")
browser = Chromium(options)
if proxy_bridge is not None:
try:
setattr(browser, "_cpa_proxy_bridge", proxy_bridge)
except Exception:
pass
_register_mint_browser(browser)
page = browser.latest_tab
logger("standalone chromium started")
return browser, page
def close_standalone(browser: Any) -> None:
if browser is None:
return
_unregister_mint_browser(browser)
bridge = getattr(browser, "_cpa_proxy_bridge", None)
try:
browser.quit()
except Exception:
pass
if bridge is not None:
try:
bridge.stop()
except Exception:
pass
_mint_tls = threading.local()
_mint_registry_lock = threading.Lock()
_mint_registry = set()
def _register_mint_browser(browser: Any) -> None:
if browser is None:
return
with _mint_registry_lock:
_mint_registry.add(browser)
def _unregister_mint_browser(browser: Any) -> None:
if browser is None:
return
with _mint_registry_lock:
_mint_registry.discard(browser)
def _mint_tls_get():
@@ -417,8 +449,9 @@ def release_mint_browser(owned: bool, success: bool, log: Optional[LogFn] = None
def shutdown_mint_browsers() -> None:
state = _mint_tls_get()
browser = state.get("browser")
if browser is not None:
with _mint_registry_lock:
browsers = list(_mint_registry)
for browser in browsers:
try:
close_standalone(browser)
except Exception:
@@ -824,7 +857,7 @@ def mint_with_browser(
session = request_device_code(proxy=resolved or None)
last_error = None
break
except BaseException as exc:
except Exception as exc:
last_error = exc
logger("request_device_code attempt %s/3 failed: %s" % (attempt, exc))
_sleep(1.5 * attempt)
@@ -852,22 +885,28 @@ def mint_with_browser(
token_box = {}
error_box = {}
def combined_cancel():
return stop_event.is_set() or bool(cancel and cancel())
def _poll() -> None:
try:
time.sleep(2)
for _ in range(20):
if combined_cancel():
raise OAuthDeviceError("cancelled")
time.sleep(0.1)
result = poll_device_token(
session.device_code,
token_endpoint=session.token_endpoint,
interval=max(session.interval, 5),
expires_in=min(session.expires_in, int(browser_timeout_sec) + 60),
log=logger,
cancel=cancel,
cancel=combined_cancel,
proxy=resolved or None,
)
token_box["token"] = result
stop_event.set()
logger("token poll SUCCESS — stop_event set")
except BaseException as exc:
except Exception as exc:
error_box["err"] = exc
stop_event.set()
@@ -897,8 +936,16 @@ def mint_with_browser(
)
if hard:
stop_event.set()
thread.join(timeout=5)
if thread.is_alive():
logger("token poll thread did not stop within 5s after browser failure")
raise
thread.join(timeout=max(browser_timeout_sec, 60) + 30)
if thread.is_alive():
stop_event.set()
thread.join(timeout=5)
if thread.is_alive():
raise OAuthDeviceError("token poll thread did not stop after timeout")
if "token" in token_box:
token_result = token_box["token"]
success = True
+14 -6
View File
@@ -167,7 +167,7 @@ def _post_form(url, form, timeout=30.0, proxy=None, retries=0, retry_sleep=1.5):
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
status = int(exc.code)
except BaseException as exc:
except Exception as exc:
last_error = exc
if not _is_transient_net_error(exc) or attempt >= int(retries):
raise
@@ -216,6 +216,14 @@ def request_device_code(client_id=CLIENT_ID, scope=SCOPE, timeout=30.0, proxy=No
)
def _sleep_with_cancel(seconds, cancel=None):
deadline = time.time() + max(float(seconds), 0.0)
while time.time() < deadline:
if cancel and cancel():
raise OAuthDeviceError("cancelled")
time.sleep(min(0.2, max(deadline - time.time(), 0.0)))
def poll_device_token(
device_code,
token_endpoint,
@@ -249,7 +257,7 @@ def poll_device_token(
retry_sleep=1.0,
)
net_streak = 0
except BaseException as exc:
except Exception as exc:
if not _is_transient_net_error(exc):
raise
net_streak += 1
@@ -257,7 +265,7 @@ def poll_device_token(
logger("oauth poll network blip (%s/%s): %s — retry in %ss" % (net_streak, max_net_streak, exc, wait_seconds))
if net_streak >= max_net_streak:
raise OAuthDeviceError("device auth aborted after %s network errors: %s" % (net_streak, exc))
time.sleep(wait_seconds)
_sleep_with_cancel(wait_seconds, cancel)
continue
if status == 200 and isinstance(payload, dict) and payload.get("access_token"):
access_token = str(payload.get("access_token") or "").strip()
@@ -281,7 +289,7 @@ def poll_device_token(
if error_code == "slow_down":
sleep_seconds = min(sleep_seconds + 5, 30)
logger("oauth poll: %s (sleep %ss)" % (error_code, sleep_seconds))
time.sleep(sleep_seconds)
_sleep_with_cancel(sleep_seconds, cancel)
continue
if error_code in ("expired_token", "access_denied"):
raise OAuthDeviceError("device auth failed: %s: %s" % (error_code, error_description))
@@ -293,8 +301,8 @@ def poll_device_token(
logger("oauth poll soft HTTP %s: %r — retry in %ss" % (status, payload, wait_seconds))
if net_streak >= max_net_streak:
raise OAuthDeviceError("device auth aborted after repeated soft HTTP failures status=%s" % status)
time.sleep(wait_seconds)
_sleep_with_cancel(wait_seconds, cancel)
continue
logger("oauth poll unexpected HTTP %s: %r" % (status, payload))
time.sleep(sleep_seconds)
_sleep_with_cancel(sleep_seconds, cancel)
raise OAuthDeviceError("device auth timed out waiting for user approval")
+2
View File
@@ -87,6 +87,8 @@ def build_cpa_xai_auth(
expired = ""
if exp:
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
elif expires_in:
expired = datetime.fromtimestamp(time.time() + int(expires_in or 0), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
payload = {
"type": "xai",
"access_token": access,
+12 -1
View File
@@ -8,13 +8,24 @@ from pathlib import Path
from .schema import credential_file_name
def _is_relative_to(path, root):
try:
path.relative_to(root)
return True
except ValueError:
return False
def write_cpa_xai_auth(auth_dir, payload, filename=None):
root = Path(auth_dir).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
target_name = filename or credential_file_name(payload.get("email", ""), payload.get("sub", ""))
target_name = Path(str(target_name)).name
if not str(target_name).endswith(".json"):
target_name = str(target_name) + ".json"
destination = root / str(target_name)
destination = (root / str(target_name)).resolve()
if not _is_relative_to(destination, root):
raise ValueError("CPA auth filename must stay inside auth_dir")
data = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
file_descriptor, temp_name = tempfile.mkstemp(prefix=".xai-", suffix=".tmp", dir=str(root))
try:
+92 -25
View File
@@ -1,12 +1,22 @@
#!/usr/bin/env python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Grok 注册机 - TTK GUI 版本
整合 DrissionPage_example.py, openai_register.py, batch_open_nsfw.py
"""
try:
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
TK_AVAILABLE = True
TK_IMPORT_ERROR = None
except ImportError as exc:
tk = None
ttk = None
messagebox = None
scrolledtext = None
TK_AVAILABLE = False
TK_IMPORT_ERROR = exc
import threading
import datetime
import time
@@ -58,11 +68,11 @@ DEFAULT_CONFIG = {
"cloudmail_public_token": "",
"cloudmail_domains": "",
"cloudmail_path_messages": "/api/public/emailList",
"proxy": "http://127.0.0.1:7890",
"proxy": "",
"enable_nsfw": True,
"register_count": 1,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
"grok2api_auto_add_local": True,
"grok2api_auto_add_local": False,
"grok2api_local_token_file": "",
"grok2api_pool_name": "ssoBasic",
"grok2api_auto_add_remote": False,
@@ -100,8 +110,14 @@ def load_config():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("config root must be a JSON object")
config = {**DEFAULT_CONFIG, **loaded}
except Exception:
except Exception as exc:
message = f"配置文件解析失败: {CONFIG_FILE}: {exc}"
print(f"[!] {message}", file=sys.stderr)
raise SystemExit(message)
else:
config = DEFAULT_CONFIG.copy()
return config
@@ -647,20 +663,30 @@ def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
token = _normalize_sso_token(raw_token)
if not token:
return False
token_file = resolve_grok2api_local_token_file()
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip()
if not pool_name:
pool_name = "ssoBasic"
os.makedirs(os.path.dirname(token_file), exist_ok=True)
token_file = os.path.abspath(resolve_grok2api_local_token_file())
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic"
parent = os.path.dirname(token_file)
os.makedirs(parent, exist_ok=True)
lock_path = token_file + ".lock"
try:
from filelock import FileLock
except Exception as exc:
raise RuntimeError(f"filelock 依赖不可用,拒绝非原子写入 token 池: {exc}")
with FileLock(lock_path, timeout=30):
data = {}
if os.path.exists(token_file):
try:
with open(token_file, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except Exception as exc:
broken_path = token_file + f".broken-{int(time.time())}"
try:
os.replace(token_file, broken_path)
except Exception:
data = {}
broken_path = token_file
raise RuntimeError(f"本地 token 文件 JSON 解析失败,已停止写入以避免覆盖: {broken_path}: {exc}")
if not isinstance(data, dict):
data = {}
raise RuntimeError("本地 token 文件根节点不是 JSON object,拒绝覆盖")
pool = data.get(pool_name)
if not isinstance(pool, list):
pool = []
@@ -674,11 +700,31 @@ def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
if log_callback:
log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}")
return True
entry = {"token": token, "tags": ["auto-register"], "note": email}
pool.append(entry)
pool.append({"token": token, "tags": ["auto-register"], "note": email})
data[pool_name] = pool
with open(token_file, "w", encoding="utf-8") as f:
if os.path.exists(token_file):
backup_path = token_file + ".bak"
try:
with open(token_file, "rb") as src, open(backup_path, "wb") as dst:
dst.write(src.read())
dst.flush()
os.fsync(dst.fileno())
except Exception as exc:
raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}")
temp_path = token_file + ".tmp"
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, token_file)
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except Exception:
pass
if log_callback:
log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})")
return True
@@ -751,21 +797,30 @@ def add_token_to_grok2api_remote_pool(raw_token, email="", log_callback=None):
if log_callback:
log_callback(f"[Debug] /tokens/add 写入失败,尝试 /tokens 全量模式: {'; '.join(add_errors)}")
# 兜底:旧版全量保存接口
# 兜底:旧版全量保存接口。必须先成功读取远端旧状态,避免空池覆盖。
current = {}
fallback_base = api_bases[0] if api_bases else base
loaded_remote_state = False
load_errors = []
for api_base in api_bases or [base]:
try:
resp = http_get(f"{api_base}/tokens", headers=headers, params=query, timeout=20)
if resp.status_code == 200:
payload = resp.json()
current = payload.get("tokens", {}) if isinstance(payload, dict) else {}
if isinstance(payload, dict):
candidate = payload.get("tokens") if "tokens" in payload else payload
if isinstance(candidate, dict):
current = candidate
fallback_base = api_base
loaded_remote_state = True
break
except Exception:
continue
if not isinstance(current, dict):
current = {}
load_errors.append(f"{api_base}/tokens: unexpected payload")
else:
load_errors.append(f"{api_base}/tokens: HTTP {resp.status_code}")
except Exception as exc:
load_errors.append(f"{api_base}/tokens: {exc}")
if not loaded_remote_state:
raise RuntimeError("无法安全读取远端 token 池,拒绝执行全量覆盖: " + "; ".join(load_errors))
pool = current.get(pool_name)
if not isinstance(pool, list):
pool = []
@@ -874,7 +929,7 @@ def http_post(url, **kwargs):
def raise_if_cancelled(cancel_callback=None):
if cancel_callback and cancel_callback():
raise RegistrationCancelled("鐢ㄦ埛鍋滄娉ㄥ唽")
raise RegistrationCancelled("用户停止注册")
def sleep_with_cancel(seconds, cancel_callback=None):
@@ -1063,7 +1118,7 @@ def yyds_create_account(address=None, domain=None, api_key=None, jwt=None):
data = resp.json()
if data.get("success"):
return data.get("data", {})
raise Exception(f"YYDS 鍒涘缓閭澶辫触: {data}")
raise Exception(f"YYDS 创建邮箱失败: {data}")
def yyds_get_token(address, api_key=None, jwt=None):
@@ -1809,7 +1864,7 @@ def tk_entry(parent, textvariable=None, width=30, **kwargs):
)
def tk_button(parent, text="", command=None, state=tk.NORMAL, **kwargs):
def tk_button(parent, text="", command=None, state="normal", **kwargs):
return tk.Button(
parent,
text=text,
@@ -2822,6 +2877,8 @@ def wait_for_sso_cookie(timeout=120, log_callback=None, cancel_callback=None):
final_no_submit_state = ""
final_no_submit_since = None
final_no_submit_timeout = 25
last_wait_exception_message = ""
last_wait_exception_at = 0.0
while time.time() < deadline:
raise_if_cancelled(cancel_callback)
@@ -2948,8 +3005,14 @@ return String(cfInput.value || '').trim().length;
refresh_active_page()
except AccountRetryNeeded:
raise
except Exception:
pass
except Exception as exc:
if log_callback:
now = time.time()
message = f"{exc.__class__.__name__}: {exc}"
if message != last_wait_exception_message or now - last_wait_exception_at >= 10:
log_callback(f"[Debug] 等待 sso cookie 时出现异常,将继续等待: {message}")
last_wait_exception_message = message
last_wait_exception_at = now
sleep_with_cancel(1, cancel_callback)
@@ -3648,6 +3711,10 @@ def main():
if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"):
main_cli()
return
if not TK_AVAILABLE:
print(f"[!] GUI 模式需要 Tkinter,但当前环境不可用: {TK_IMPORT_ERROR}", file=sys.stderr)
print("[*] 可改用 CLI 模式: python grok_register_ttk.py cli", file=sys.stderr)
return
root = tk.Tk()
setup_light_theme(root)
app = GrokRegisterGUI(root)
+1 -1
View File
@@ -5,7 +5,7 @@ cssselect==1.3.0
curl_cffi==0.13.0
DataRecorder==3.6.2
DownloadKit==2.0.7
DrissionPage==4.1.1.2
DrissionPage>=4.1.1.2,<4.2
filelock==3.19.1
idna==3.13
lxml==6.1.0
-643
View File
@@ -1,643 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Apply targeted audit fixes without rewriting the project structure.
This script is intentionally idempotent because the repository may already
contain some of the fixes from earlier interrupted bot runs.
"""
from pathlib import Path
import ast
import re
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "grok_register_ttk.py"
REQ = ROOT / "requirements.txt"
GITIGNORE = ROOT / ".gitignore"
README = ROOT / "README.md"
OAUTH = ROOT / "cpa_xai" / "oauth_device.py"
BROWSER = ROOT / "cpa_xai" / "browser_confirm.py"
SCHEMA = ROOT / "cpa_xai" / "schema.py"
WRITER = ROOT / "cpa_xai" / "writer.py"
def read(path, encoding="utf-8"):
return path.read_text(encoding=encoding)
def write(path, content, encoding="utf-8"):
path.write_text(content, encoding=encoding)
def replace_required(text, old, new, label):
count = text.count(old)
if count != 1:
raise RuntimeError(f"{label}: expected one match, got {count}")
return text.replace(old, new, 1)
def replace_if_present(text, old, new):
return text.replace(old, new, 1) if old in text else text
def replace_func_between(text, start_marker, end_marker, new_block, label):
start = text.find(start_marker)
if start < 0:
if new_block in text:
return text
raise RuntimeError(f"{label}: start marker not found")
end = text.find(end_marker, start)
if end < 0:
raise RuntimeError(f"{label}: end marker not found")
return text[:start] + new_block + text[end:]
def ensure_line(content, line):
lines = content.splitlines()
if line not in lines:
content = content.rstrip() + "\n" + line + "\n"
return content
# Trigger token: audit-fixes-v4
# ---------------------------------------------------------------------------
# Main application fixes.
# ---------------------------------------------------------------------------
app = read(APP, encoding="utf-8-sig")
# L-01 / M-05: UTF-8 without BOM and CLI can start without Tkinter installed.
app = replace_if_present(
app,
"import tkinter as tk\nfrom tkinter import ttk, messagebox, scrolledtext\n",
"try:\n import tkinter as tk\n from tkinter import ttk, messagebox, scrolledtext\n TK_AVAILABLE = True\n TK_IMPORT_ERROR = None\nexcept ImportError as exc:\n tk = None\n ttk = None\n messagebox = None\n scrolledtext = None\n TK_AVAILABLE = False\n TK_IMPORT_ERROR = exc\n",
)
app = app.replace(
'def tk_button(parent, text="", command=None, state=tk.NORMAL, **kwargs):',
'def tk_button(parent, text="", command=None, state="normal", **kwargs):',
)
# M-02: safer defaults, matching config.example.json.
app = app.replace(' "proxy": "http://127.0.0.1:7890",', ' "proxy": "",')
app = app.replace(' "grok2api_auto_add_local": True,', ' "grok2api_auto_add_local": False,')
old_load_config = '''def load_config():
global config
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
config = {**DEFAULT_CONFIG, **loaded}
except Exception:
config = DEFAULT_CONFIG.copy()
return config
'''
new_load_config = '''def load_config():
global config
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("config root must be a JSON object")
config = {**DEFAULT_CONFIG, **loaded}
except Exception as exc:
message = f"配置文件解析失败: {CONFIG_FILE}: {exc}"
print(f"[!] {message}", file=sys.stderr)
raise SystemExit(message)
else:
config = DEFAULT_CONFIG.copy()
return config
'''
app = replace_if_present(app, old_load_config, new_load_config)
# H-05 / M-01: local token writes are locked, backed up and atomic.
new_local_pool = '''def add_token_to_grok2api_local_pool(raw_token, email="", log_callback=None):
token = _normalize_sso_token(raw_token)
if not token:
return False
token_file = os.path.abspath(resolve_grok2api_local_token_file())
pool_name = str(config.get("grok2api_pool_name", "ssoBasic") or "ssoBasic").strip() or "ssoBasic"
parent = os.path.dirname(token_file)
os.makedirs(parent, exist_ok=True)
lock_path = token_file + ".lock"
try:
from filelock import FileLock
except Exception as exc:
raise RuntimeError(f"filelock 依赖不可用,拒绝非原子写入 token 池: {exc}")
with FileLock(lock_path, timeout=30):
data = {}
if os.path.exists(token_file):
try:
with open(token_file, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except Exception as exc:
broken_path = token_file + f".broken-{int(time.time())}"
try:
os.replace(token_file, broken_path)
except Exception:
broken_path = token_file
raise RuntimeError(f"本地 token 文件 JSON 解析失败,已停止写入以避免覆盖: {broken_path}: {exc}")
if not isinstance(data, dict):
raise RuntimeError("本地 token 文件根节点不是 JSON object,拒绝覆盖")
pool = data.get(pool_name)
if not isinstance(pool, list):
pool = []
existing = set()
for item in pool:
if isinstance(item, str):
existing.add(_normalize_sso_token(item))
elif isinstance(item, dict):
existing.add(_normalize_sso_token(item.get("token", "")))
if token in existing:
if log_callback:
log_callback(f"[*] grok2api 本地池已存在 token: {pool_name}")
return True
pool.append({"token": token, "tags": ["auto-register"], "note": email})
data[pool_name] = pool
if os.path.exists(token_file):
backup_path = token_file + ".bak"
try:
with open(token_file, "rb") as src, open(backup_path, "wb") as dst:
dst.write(src.read())
dst.flush()
os.fsync(dst.fileno())
except Exception as exc:
raise RuntimeError(f"创建本地 token 备份失败,拒绝继续写入: {exc}")
temp_path = token_file + ".tmp"
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, token_file)
finally:
if os.path.exists(temp_path):
try:
os.unlink(temp_path)
except Exception:
pass
if log_callback:
log_callback(f"[+] 已写入 grok2api 本地池: {pool_name} ({token_file})")
return True
'''
if "with FileLock(lock_path" not in app:
app = replace_func_between(
app,
"def add_token_to_grok2api_local_pool(raw_token, email=\"\", log_callback=None):\n",
"def get_grok2api_remote_api_bases(base):\n",
new_local_pool,
"atomic local token pool",
)
# H-01: remote fallback must not POST a full replacement unless old state was read.
old_remote_fallback = ''' # 兜底:旧版全量保存接口
current = {}
fallback_base = api_bases[0] if api_bases else base
for api_base in api_bases or [base]:
try:
resp = http_get(f"{api_base}/tokens", headers=headers, params=query, timeout=20)
if resp.status_code == 200:
payload = resp.json()
current = payload.get("tokens", {}) if isinstance(payload, dict) else {}
fallback_base = api_base
break
except Exception:
continue
if not isinstance(current, dict):
current = {}
'''
new_remote_fallback = ''' # 兜底:旧版全量保存接口。必须先成功读取远端旧状态,避免空池覆盖。
current = {}
fallback_base = api_bases[0] if api_bases else base
loaded_remote_state = False
load_errors = []
for api_base in api_bases or [base]:
try:
resp = http_get(f"{api_base}/tokens", headers=headers, params=query, timeout=20)
if resp.status_code == 200:
payload = resp.json()
if isinstance(payload, dict):
candidate = payload.get("tokens") if "tokens" in payload else payload
if isinstance(candidate, dict):
current = candidate
fallback_base = api_base
loaded_remote_state = True
break
load_errors.append(f"{api_base}/tokens: unexpected payload")
else:
load_errors.append(f"{api_base}/tokens: HTTP {resp.status_code}")
except Exception as exc:
load_errors.append(f"{api_base}/tokens: {exc}")
if not loaded_remote_state:
raise RuntimeError("无法安全读取远端 token 池,拒绝执行全量覆盖: " + "; ".join(load_errors))
'''
app = replace_if_present(app, old_remote_fallback, new_remote_fallback)
# M-06: do not hide unexpected SSO wait exceptions forever.
if "last_wait_exception_message" not in app:
app = replace_if_present(
app,
''' final_no_submit_state = ""
final_no_submit_since = None
final_no_submit_timeout = 25
while time.time() < deadline:
''',
''' final_no_submit_state = ""
final_no_submit_since = None
final_no_submit_timeout = 25
last_wait_exception_message = ""
last_wait_exception_at = 0.0
while time.time() < deadline:
''',
)
app = replace_if_present(
app,
''' except Exception:
pass
sleep_with_cancel(1, cancel_callback)
''',
''' except Exception as exc:
if log_callback:
now = time.time()
message = f"{exc.__class__.__name__}: {exc}"
if message != last_wait_exception_message or now - last_wait_exception_at >= 10:
log_callback(f"[Debug] 等待 sso cookie 时出现异常,将继续等待: {message}")
last_wait_exception_message = message
last_wait_exception_at = now
sleep_with_cancel(1, cancel_callback)
''',
)
# L-02: repair known mojibake that affects logs/control flow.
for bad, good in {
"鐢ㄦ埛鍋滄娉ㄥ唽": "用户停止注册",
"YYDS 鍒涘缓閭澶辫触": "YYDS 创建邮箱失败",
"YYDS 鑾峰彇JWT澶辫触": "YYDS 获取 JWT 失败",
}.items():
app = app.replace(bad, good)
# M-05: GUI-only Tk failure should not break CLI mode.
old_main = '''def main():
if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"):
main_cli()
return
root = tk.Tk()
setup_light_theme(root)
app = GrokRegisterGUI(root)
root.mainloop()
'''
new_main = '''def main():
if len(sys.argv) > 1 and sys.argv[1].strip().lower() in ("start", "cli", "--cli"):
main_cli()
return
if not TK_AVAILABLE:
print(f"[!] GUI 模式需要 Tkinter,但当前环境不可用: {TK_IMPORT_ERROR}", file=sys.stderr)
print("[*] 可改用 CLI 模式: python grok_register_ttk.py cli", file=sys.stderr)
return
root = tk.Tk()
setup_light_theme(root)
app = GrokRegisterGUI(root)
root.mainloop()
'''
app = replace_if_present(app, old_main, new_main)
ast.parse(app)
write(APP, app, encoding="utf-8")
# ---------------------------------------------------------------------------
# CPA browser / OAuth fixes.
# ---------------------------------------------------------------------------
browser = read(BROWSER)
browser = browser.replace("except BaseException as exc:", "except Exception as exc:")
old_proxy_block = ''' from .proxyutil import proxy_for_chromium, proxy_log_label, resolve_proxy
resolved = resolve_proxy(proxy)
chrome_proxy = proxy_for_chromium(resolved)
if chrome_proxy:
options.set_argument("--proxy-server=%s" % chrome_proxy)
logger("browser proxy=%s (chromium %s)" % (proxy_log_label(resolved), chrome_proxy))
else:
logger("browser proxy=(none)")
browser = Chromium(options)
page = browser.latest_tab
logger("standalone chromium started")
return browser, page
'''
new_proxy_block = ''' from .proxyutil import prepare_chromium_proxy, proxy_log_label, resolve_proxy
resolved = resolve_proxy(proxy)
proxy_bridge = None
chrome_proxy, proxy_bridge = prepare_chromium_proxy(resolved, log=logger)
if chrome_proxy:
options.set_argument("--proxy-server=%s" % chrome_proxy)
logger("browser proxy=%s (chromium %s)" % (proxy_log_label(resolved), chrome_proxy))
else:
logger("browser proxy=(none)")
browser = Chromium(options)
if proxy_bridge is not None:
try:
setattr(browser, "_cpa_proxy_bridge", proxy_bridge)
except Exception:
pass
_register_mint_browser(browser)
page = browser.latest_tab
logger("standalone chromium started")
return browser, page
'''
browser = replace_if_present(browser, old_proxy_block, new_proxy_block)
old_close_block = '''def close_standalone(browser: Any) -> None:
try:
browser.quit()
except Exception:
pass
_mint_tls = threading.local()
'''
new_close_block = '''def close_standalone(browser: Any) -> None:
if browser is None:
return
_unregister_mint_browser(browser)
bridge = getattr(browser, "_cpa_proxy_bridge", None)
try:
browser.quit()
except Exception:
pass
if bridge is not None:
try:
bridge.stop()
except Exception:
pass
_mint_tls = threading.local()
_mint_registry_lock = threading.Lock()
_mint_registry = set()
def _register_mint_browser(browser: Any) -> None:
if browser is None:
return
with _mint_registry_lock:
_mint_registry.add(browser)
def _unregister_mint_browser(browser: Any) -> None:
if browser is None:
return
with _mint_registry_lock:
_mint_registry.discard(browser)
'''
if "_mint_registry = set()" not in browser:
browser = replace_required(browser, old_close_block, new_close_block, "CPA global browser registry")
old_shutdown = '''def shutdown_mint_browsers() -> None:
state = _mint_tls_get()
browser = state.get("browser")
if browser is not None:
try:
close_standalone(browser)
except Exception:
pass
state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None})
'''
new_shutdown = '''def shutdown_mint_browsers() -> None:
state = _mint_tls_get()
with _mint_registry_lock:
browsers = list(_mint_registry)
for browser in browsers:
try:
close_standalone(browser)
except Exception:
pass
state.update({"browser": None, "page": None, "served": 0, "proxy": None, "headless": None})
'''
browser = replace_if_present(browser, old_shutdown, new_shutdown)
old_poll = ''' def _poll() -> None:
try:
time.sleep(2)
result = poll_device_token(
session.device_code,
token_endpoint=session.token_endpoint,
interval=max(session.interval, 5),
expires_in=min(session.expires_in, int(browser_timeout_sec) + 60),
log=logger,
cancel=cancel,
proxy=resolved or None,
)
token_box["token"] = result
stop_event.set()
logger("token poll SUCCESS — stop_event set")
except Exception as exc:
error_box["err"] = exc
stop_event.set()
'''
new_poll = ''' def combined_cancel():
return stop_event.is_set() or bool(cancel and cancel())
def _poll() -> None:
try:
for _ in range(20):
if combined_cancel():
raise OAuthDeviceError("cancelled")
time.sleep(0.1)
result = poll_device_token(
session.device_code,
token_endpoint=session.token_endpoint,
interval=max(session.interval, 5),
expires_in=min(session.expires_in, int(browser_timeout_sec) + 60),
log=logger,
cancel=combined_cancel,
proxy=resolved or None,
)
token_box["token"] = result
stop_event.set()
logger("token poll SUCCESS — stop_event set")
except Exception as exc:
error_box["err"] = exc
stop_event.set()
'''
browser = replace_if_present(browser, old_poll, new_poll)
old_join = ''' if hard:
stop_event.set()
raise
thread.join(timeout=max(browser_timeout_sec, 60) + 30)
'''
new_join = ''' if hard:
stop_event.set()
thread.join(timeout=5)
if thread.is_alive():
logger("token poll thread did not stop within 5s after browser failure")
raise
thread.join(timeout=max(browser_timeout_sec, 60) + 30)
if thread.is_alive():
stop_event.set()
thread.join(timeout=5)
if thread.is_alive():
raise OAuthDeviceError("token poll thread did not stop after timeout")
'''
browser = replace_if_present(browser, old_join, new_join)
ast.parse(browser)
write(BROWSER, browser)
oauth = read(OAUTH)
oauth = oauth.replace("except BaseException as exc:", "except Exception as exc:")
old_poll_header = '''def poll_device_token(
device_code,
token_endpoint,
client_id=CLIENT_ID,
interval=5,
expires_in=1800,
timeout=30.0,
log=None,
cancel=None,
proxy=None,
):
logger = log or (lambda message: None)
deadline = time.time() + max(int(expires_in) - 5, 30)
sleep_seconds = max(int(interval), 1)
net_streak = 0
max_net_streak = 20
while time.time() < deadline:
if cancel and cancel():
raise OAuthDeviceError("cancelled")
'''
new_poll_header = '''def _sleep_with_cancel(seconds, cancel=None):
deadline = time.time() + max(float(seconds), 0.0)
while time.time() < deadline:
if cancel and cancel():
raise OAuthDeviceError("cancelled")
time.sleep(min(0.2, max(deadline - time.time(), 0.0)))
def poll_device_token(
device_code,
token_endpoint,
client_id=CLIENT_ID,
interval=5,
expires_in=1800,
timeout=30.0,
log=None,
cancel=None,
proxy=None,
):
logger = log or (lambda message: None)
deadline = time.time() + max(int(expires_in) - 5, 30)
sleep_seconds = max(int(interval), 1)
net_streak = 0
max_net_streak = 20
while time.time() < deadline:
if cancel and cancel():
raise OAuthDeviceError("cancelled")
'''
if "def _sleep_with_cancel(" not in oauth:
oauth = replace_required(oauth, old_poll_header, new_poll_header, "oauth cancellable sleep helper")
oauth = oauth.replace("time.sleep(wait_seconds)", "_sleep_with_cancel(wait_seconds, cancel)")
oauth = oauth.replace("time.sleep(sleep_seconds)", "_sleep_with_cancel(sleep_seconds, cancel)")
ast.parse(oauth)
write(OAUTH, oauth)
# ---------------------------------------------------------------------------
# CPA schema/writer hardening.
# ---------------------------------------------------------------------------
schema = read(SCHEMA)
old_expired = ''' expired = ""
if exp:
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
'''
new_expired = ''' expired = ""
if exp:
expired = datetime.fromtimestamp(exp, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
elif expires_in:
expired = datetime.fromtimestamp(time.time() + int(expires_in or 0), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
'''
schema = replace_if_present(schema, old_expired, new_expired)
ast.parse(schema)
write(SCHEMA, schema)
writer = read(WRITER)
old_writer_head = '''def write_cpa_xai_auth(auth_dir, payload, filename=None):
root = Path(auth_dir).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
target_name = filename or credential_file_name(payload.get("email", ""), payload.get("sub", ""))
if not str(target_name).endswith(".json"):
target_name = str(target_name) + ".json"
destination = root / str(target_name)
'''
new_writer_head = '''def _is_relative_to(path, root):
try:
path.relative_to(root)
return True
except ValueError:
return False
def write_cpa_xai_auth(auth_dir, payload, filename=None):
root = Path(auth_dir).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
target_name = filename or credential_file_name(payload.get("email", ""), payload.get("sub", ""))
target_name = Path(str(target_name)).name
if not str(target_name).endswith(".json"):
target_name = str(target_name) + ".json"
destination = (root / str(target_name)).resolve()
if not _is_relative_to(destination, root):
raise ValueError("CPA auth filename must stay inside auth_dir")
'''
writer = replace_if_present(writer, old_writer_head, new_writer_head)
ast.parse(writer)
write(WRITER, writer)
# ---------------------------------------------------------------------------
# Requirements / ignore / docs.
# ---------------------------------------------------------------------------
req = read(REQ)
req = req.replace("DrissionPage==4.1.1.2", "DrissionPage>=4.1.1.2,<4.2")
write(REQ, req)
gitignore = read(GITIGNORE)
gitignore = ensure_line(gitignore, "screenshots/")
gitignore = ensure_line(gitignore, "*.png")
write(GITIGNORE, gitignore)
readme = read(README)
readme = readme.replace(
"- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `*.log`:可选日志文件。",
"- `cpa_auths/cpa_auth_failed.txt`OIDC 导出失败记录。\n- `screenshots/`CPA/OIDC 浏览器失败调试截图,已被 `.gitignore` 忽略。\n- `*.log`:可选日志文件。",
)
old_tree = '''```text
.
grok_register_ttk.py # 主程序
cf_mail_debug.py # Cloudflare 邮箱调试工具
config.example.json # 配置示例
requirements.txt # Python 依赖
README.md
```
'''
new_tree = '''```text
.
grok_register_ttk.py # 主程序(GUI / CLI
cpa_export.py # 注册成功后的 CPA/OIDC 导出入口
cpa_xai/ # xAI Device Auth、浏览器授权和凭证写入模块
cf_mail_debug.py # Cloudflare 邮箱调试工具
config.example.json # 配置示例
requirements.txt # Python 依赖
tests/ # 现有测试用例
assets/ # README 资源
README.md
```
'''
readme = replace_if_present(readme, old_tree, new_tree)
write(README, readme)
# Final syntax validation for touched Python files.
for path in (APP, OAUTH, BROWSER, SCHEMA, WRITER):
ast.parse(read(path, encoding="utf-8"))
print("audit fixes applied")
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python3
"""Repair string escapes inside the temporary audit patch script before running it.
The patch script embeds Python source inside triple-quoted strings. Source snippets
that should generate literal backslash-n in grok_register_ttk.py must therefore
use double escaping in this script.
"""
from pathlib import Path
path = Path(__file__).resolve().parent / "apply_audit_fixes.py"
text = path.read_text(encoding="utf-8")
replacements = {
'f.write("\\n")': 'f.write("\\\\n")',
'line = f"{email}----{password or \'\'}----{sso}\\n"': 'line = f"{email}----{password or \'\'}----{sso}\\\\n"',
}
for old, new in replacements.items():
text = text.replace(old, new)
path.write_text(text, encoding="utf-8")
print("audit patch escapes repaired")