Initial commit: grok-free-register-oss

Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
This commit is contained in:
chaos
2026-07-16 21:04:05 +08:00
commit d10009d639
72 changed files with 18752 additions and 0 deletions

124
.env.example Normal file
View File

@@ -0,0 +1,124 @@
# grok-free-register 配置模板
# 复制为 .env 后按需修改。未填写的项目会使用程序默认值。
# 邮箱模式
# tempmail: 免费临时邮箱,适合快速试跑。
# custom: 自建域名邮箱,需要 Cloudflare Email Routing 和本地收信服务。
EMAIL_MODE=tempmail
# custom 模式专用
# EMAIL_DOMAIN=example.com
# EMAIL_API=http://127.0.0.1:8080
# 认证账号来源
# auto: 配置 SSH 主机时读取远端,否则读取当前项目;一般保持默认即可。
# XAI_AUTH_SERVICE_SOURCE=auto # auto | local | ssh
# XAI_AUTH_SERVICE_REGISTER_ROOT=. # local 模式下的注册项目目录
# 注册与认证不在同一台机器时填写
# XAI_AUTH_SERVICE_SSH_HOST=user@server.example
# XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/key.pem
# XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
# XAI_AUTH_SERVICE_SYNC_SEC=30 # 同步新账号的间隔(秒)
# 认证节奏
# XAI_AUTH_SERVICE_MIN_INTERVAL_SEC=10 # 两次认证请求的基础最小间隔(秒)
# # 触发限流后会自动抬高(约18s起最多约90s),成功连串后再缓慢回落
# XAI_AUTH_SERVICE_RETRY_SEC=60 # 被限流后的基础探测间隔(秒);连续撞限会按 1.5x 增长,上限 300s
# XAI_AUTH_SERVICE_LOG_MODE=user # user | debug
# 运行目标
# TARGET=0 # 成功 N 个后停止;0=不限
# 日志显示
# REGISTER_LOG_MODE=user # 默认:任务开始、结果、平均速度和限流等待
# REGISTER_LOG_MODE=debug # 额外显示 T/Q/并发/阶段耗时调试面板
# 浏览器并发容量
# PHYSICAL_CAP=0 # 0=按 CPU/内存自动估算;>0=手动指定
# PHYSICAL_PER_CPU=2 # 自动估算时每个 CPU 核心对应的并发参考值
# PHYSICAL_MEM_MB=512 # 自动估算时每个浏览器任务的内存预算(MB)
# MIN_FREE_MEM_MB=500 # 自动估算时保留的内存(MB)
# CAPACITY_PROFILE= # 可选:离线压测生成的静态 profile(JSON)
# 缓冲容量
# T_SLOT_CAP=8
# Q_SLOT_CAP=8
# Q_PENDING_CAP=12
# Worker 数量
# 通常不需要手动设置。0 表示根据容量自动派生。
# S_WORKERS=0
# P_WORKERS=0
# C_WORKERS=0
# 资源有效期
# T_MAX_AGE=300 # token 最大年龄(秒)
# Q_MAX_AGE=120 # 验证码最大年龄(秒)
# 阶段超时
# P_REQUEST_TIMEOUT=95 # 等待验证码返回超时(秒)
# C_CONSUME_TIMEOUT=60 # 最终提交阶段超时(秒)
# 局部门控和批量发送
# 这些项目默认会按容量派生。只有在压测定位后才建议覆盖。
# T_TARGET=4 # token 缓冲目标
# Q_TARGET=4 # 验证码缓冲目标
# T_HIGH_WATER= # token 生产高水位
# T_LOW_WATER= # token 生产恢复低水位
# Q_HIGH_WATER= # 验证码生产高水位
# Q_LOW_WATER= # 验证码生产恢复低水位
# P_BATCH_MAX=4 # 单个发送页面最多发送的验证码请求数
# P_SEND_CAP=0 # 0=不额外限制;>0=限制发送页面并发
# 页面和 token 获取
# SOLVER_REUSE=1 # token 页面复用
# MAX_SOLVER_REUSE=25 # 单个 token 页面最大复用次数
# SOLVER_INITIAL_WAIT_MS=500 # token 注入后的首次等待
# SOLVER_POLL_INTERVAL_MS=500 # token 轮询间隔
# SOLVER_POLL_ATTEMPTS=100 # token 最大轮询次数
# SOLVER_HARD_TIMEOUT=90 # 单次 solver 全链路硬截止(秒)
# SOLVER_CLEANUP_TIMEOUT=5 # 异常页回收最长等待(秒)
# SOLVER_FAST_CLICK=1 # 没有可见验证框时跳过慢点击
# SOLVER_MOUSE_CLICK_RETRIES=3 # token 验证框中心点击次数;0=关闭
# SOLVER_MOUSE_CLICK_INTERVAL_MS=600 # token 中心点击间隔(ms)
# PAGE_GOTO_WAIT_UNTIL=domcontentloaded # 页面导航等待策略
# PAGE_POST_WAIT_MS=500 # 页面导航后的短等待
# 可选性能开关
# PAGE_BLOCK_STATIC_ASSETS=0 # 阻断部分静态资源,降低页面准备成本
# C_HOT_PAGE_POOL=0 # 复用消费阶段页面,会增加常驻内存
# C_HOT_PAGE_POOL_SIZE=0 # 0=按启动期并发派生;>0=手动指定
# C_SET_COOKIE_VIA_REQUEST=0 # 用浏览器 request 访问 set-cookie URL;热页池开启时默认启用
# ---------------------------------------------------------------------------
# 代理grok-free-register 本进程出口,给 Playwright + httpx
# 与下面 FlareSolverr 清障栈是两套东西,不要混。
# Playwright 不会自动读环境变量,代码里会显式挂到 launch(proxy=...)。
# ---------------------------------------------------------------------------
# REGISTER_PROXY=http://127.0.0.1:40080 # 优先;指向本机 privoxy→WARP 时用这个
# HTTP_PROXY=http://127.0.0.1:7890 # 兜底REGISTER_PROXY 未设时)
# HTTPS_PROXY=http://127.0.0.1:7890
# ---------------------------------------------------------------------------
# Cloudflare 清障预热(调用外部 FlareSolverr不在本仓起 FS
# 默认 FS: docker compose 暴露 127.0.0.1:8191
# 预热根域名(无 path 后缀): accounts.x.ai / x.ai / status.x.ai / console.x.ai / auth.x.ai
# CLEARANCE_PROXY 是 FS *容器内* 浏览器的出口,必须是容器可达地址;
# 不要把 host 的 127.0.0.1 塞进去。走 WARP 时用 docker 网内 privoxy。
# ---------------------------------------------------------------------------
# CLEARANCE_ENABLED=1
# FLARESOLVERR_URL=http://127.0.0.1:8191
# CLEARANCE_URLS=https://accounts.x.ai,https://x.ai,https://status.x.ai,https://console.x.ai,https://auth.x.ai
# CLEARANCE_PROXY=http://privoxy:8118
# CLEARANCE_TIMEOUT_SEC=60
# CLEARANCE_REFRESH_SEC=3000
# ---------------------------------------------------------------------------
# 本机注册 → 推 keys 到远端 auth可选scripts/push_keys_to_auth.sh
# 无内置默认主机,必须显式填写。
# ---------------------------------------------------------------------------
# AUTH_SSH_HOST=user@auth-server.example
# AUTH_REMOTE_ROOT=/opt/grok-free-register
# AUTH_SSH_IDENTITY=
# AUTH_PUSH_INTERVAL=20

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
__pycache__/
*.pyc
.env
*.log
logs/
*.bak
.venv/
.setup.lock/
.DS_Store
.pytest_cache/
xai-enroller-ledger.db*
tmp_*.py
# runtime credential outputs under keys/ (keep product scripts tracked)
keys/*
!keys/acpa_watchdog.py
!keys/sync_acc.py
!keys/async_auth.sh
!keys/README.md

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 grok-free-register contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

370
README.md Normal file
View File

@@ -0,0 +1,370 @@
# grok-free-register
`grok-free-register` 是一个命令行注册工具。程序会启动本机浏览器,完成页面操作、邮箱验证码处理和结果保存。
运行结果写入 `keys/` 目录。
## 快速开始
```bash
git clone <your-fork-or-mirror>/grok-free-register.git
cd grok-free-register
bash start.sh
```
首次运行会自动创建 `.venv`、安装依赖,并引导生成 `.env`
需要完整说明时,按用途查看:
- [注册教程](docs/guides/registration.md)
- [本地认证服务](docs/guides/auth-service.md)
- [凭据库存与取用](docs/guides/credential-inventory.md)
- [运行状态与排障](docs/guides/runtime-troubleshooting.md)
常用命令:
```bash
bash start.sh # 按当前 .env 前台运行
bash start.sh --target 100 # 成功 100 个后停止
bash start.sh --max-mem 6G # 自动估算并发时最多使用 6G 内存
bash start.sh --reconfig # 重新选择邮箱模式
```
`start.sh` 直接在当前终端显示状态。按 `Ctrl-C` 停止,再次执行同一命令即可重启;不需要额外的会话管理或守护进程依赖。
代理与 CF 清障是两套配置,不要混:
```env
# 本进程出口Playwright + httpx 显式挂载Playwright 不会自己读 env
REGISTER_PROXY=http://127.0.0.1:40080
# 或兜底:
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
# 清障预热:调用外部 FlareSolverr本仓不跑 FS
CLEARANCE_ENABLED=1
FLARESOLVERR_URL=http://127.0.0.1:8191
CLEARANCE_URLS=https://accounts.x.ai,https://x.ai,https://status.x.ai,https://console.x.ai,https://auth.x.ai
# FS 容器内出口(容器可达名);走 WARP 时用 docker 网内 privoxy
# CLEARANCE_PROXY=http://privoxy:8118
```
## 邮箱模式
`tempmail` 是默认模式,不需要额外配置,适合快速试跑:
```env
EMAIL_MODE=tempmail
```
`custom` 是自建域名邮箱模式,适合长时间运行。需要一个已接入 Cloudflare Email Routing 的域名,并在运行机器上启动本项目的收信服务。
配置步骤:
1. 在 Cloudflare 为域名开启 Email Routing。
2. 部署 `cloudflare/email-worker.js`
3. 在 Email Routing 中配置 catch-all动作选择发送到该 Worker。
4. 在运行机器上启动收信服务:
```bash
bash start.sh --email-service
```
5.`.env` 中配置:
```env
EMAIL_MODE=custom
EMAIL_DOMAIN=example.com
EMAIL_API=http://127.0.0.1:8080
```
如果 Worker 需要回调本机服务,`WEBHOOK_URL` 应使用可访问的域名地址。
## 配置
完整模板见 `.env.example`。日常使用通常只需要配置邮箱模式、代理、目标数量和内存预算。
| 配置 | 默认值 | 说明 |
|---|---:|---|
| `EMAIL_MODE` | `tempmail` | 邮箱模式,支持 `tempmail``custom` |
| `EMAIL_DOMAIN` | 空 | `custom` 模式使用的域名 |
| `EMAIL_API` | `http://127.0.0.1:8080` | 本地收信服务地址 |
| `TARGET` | `0` | 成功数量目标,`0` 表示不限 |
| `PHYSICAL_CAP` | `0` | 浏览器并发上限,`0` 表示启动时自动估算 |
| `PHYSICAL_PER_CPU` | `2` | 自动估算时每个 CPU 核心对应的并发参考值 |
| `PHYSICAL_MEM_MB` | `512` | 自动估算时每个浏览器任务的内存预算 |
| `MIN_FREE_MEM_MB` | `500` | 自动估算时保留的内存 |
| `T_SLOT_CAP` | `8` | token 缓冲容量 |
| `Q_SLOT_CAP` | `8` | 验证码缓冲容量 |
| `Q_PENDING_CAP` | `12` | 等待验证码返回的请求上限 |
| `SOLVER_MOUSE_CLICK_RETRIES` | `3` | token 验证框中心点击次数,`0` 表示关闭 |
| `PAGE_BLOCK_STATIC_ASSETS` | `0` | 可选:阻断部分静态资源,降低页面准备成本 |
| `C_HOT_PAGE_POOL` | `0` | 可选:复用消费阶段页面,减少页面重建开销 |
不确定怎么设置时,先保持默认值。性能压测时优先观察 `PHYSICAL_CAP` 和内存,不建议先改 Worker 数量。
## 运行日志
直接运行 `bash start.sh` 时,终端只输出任务开始、成功或失败、本次运行平均速度、累计数量和限流等待:
```text
[→] 开始注册 #38
[✓] 注册成功 #38 | 运行平均 47/分 | 累计 38
[⏸] 触发限流 | 60秒后恢复探测
[▶] 限流解除 | 实际等待 61秒
```
需要调试并发、库存和阶段耗时时,使用:
```bash
bash start.sh --debug
```
它会在上述任务事件之外,每 8 秒输出一次完整的 T/Q、物理并发和阶段耗时面板。已有自动化环境也可继续使用 `REGISTER_LOG_MODE=debug`
常用字段:
| 字段 | 含义 |
|---|---|
| `T` | 当前可用 token 数量 |
| `Q` | 当前可用验证码数量 |
| `phys` | 空闲浏览器并发许可 |
| `s_phys` / `p_phys` / `c_phys` | S/P/C 获取浏览器许可的平均等待秒数 / 平均持有秒数 |
| `p_stage` | P 阶段平均耗时:建邮箱 / 准备页面 / 发送请求 |
| `c_stage` | C 阶段平均耗时:拿页面 / 验证码校验 / 注册提交 |
| `c_hot` | C 热页池命中 / 未命中次数 |
| `t_solve_avg` | 平均 token 获取时间 |
| `q_sent` / `q_ret` | 已发送 / 已收到的验证码数量 |
| `pair` | 已配对消费次数 |
| `ok` / `fail` | 成功 / 失败数量 |
| `rate` | 当前累计成功速率 |
简单判断:
- `T` 长期为 `0``Q` 有库存,通常是 token 获取较慢。
- `Q` 长期为 `0``T` 有库存,通常是邮箱或验证码链路较慢。
- `phys` 长期为 `0`,说明浏览器并发已经用满。
- `t_solve_avg` 明显升高,通常表示浏览器压力、网络质量或 token 服务响应变慢。
可以用日志分析工具解析已有日志:
```bash
python3 - <<'PY'
from pathlib import Path
from tools.runtime_log_analyzer import analyze_text
print(analyze_text(Path("run.log").read_text()))
PY
```
## 输出文件
成功结果写入:
```text
keys/accounts.txt
keys/grok.txt
```
`accounts.txt` 每行格式:
```text
email:password:sso_token
```
`keys/` 目录包含运行结果,默认不会提交到 Git。
认证成功后的 OAuth 出货默认在:
```text
~/Downloads/grok-free-register-auth/authenticated/
```
可用 `keys/async_auth.sh` 把出货整备成 `keys/cpa_ready/` 并同步探活通过的 `access_token``keys/acc.md`
## 清零流水线(删库跑路)
只删 `keys/acc.md` / `accounts.txt` **不够**`acpa_watchdog` 会从
`~/Downloads/grok-free-register-auth/authenticated/` 把旧号再整备进
`keys/cpa_ready/``sync_acc` 再写回 `acc.md`。状态文件 `_state.tsv` 也会残留幽灵 alive。
**全量清零**用项目根的 `reset_pipeline.sh`(默认 dry-run必须加 `--yes` 才真删):
```bash
# 建议先停 register / auth-service / async_auth
bash reset_pipeline.sh # dry-run只列将删内容
bash reset_pipeline.sh --yes # 真删:注册源 + 认证出货 + cpa_ready + state
bash reset_pipeline.sh --yes --keep-register
# 只清认证/CPA保留 keys 里新注册的 SSO 源
bash reset_pipeline.sh --yes --keep-cpa
# 只清认证账本/源快照,保留 cpa_ready少用
bash reset_pipeline.sh --yes --wipe-salt
# 连 enrollment ledger salt 一并清(极少需要)
```
会清掉的大致范围:
| 范围 | 路径 |
|------|------|
| 注册源 | `keys/accounts.txt``keys/grok.txt``keys/auth-sessions.jsonl``keys/acc.md` |
| CPA | `keys/cpa_ready/xai-*.json``_state.tsv``_discarded/` |
| 认证出货 | `$XAI_AUTH_SERVICE_LOCAL_DIR`(默认 `~/Downloads/grok-free-register-auth`)下的 `authenticated/``claimed/``enrollment-ledger.db*``source-snapshot.jsonl` |
认证目录可用环境变量覆盖:
```bash
export XAI_AUTH_SERVICE_LOCAL_DIR=~/Downloads/grok-free-register-auth
# 或
export XAI_ENROLLER_LOCAL_AUTH_DIR=~/Downloads/grok-free-register-auth
```
**只清历史归档、不动现网号池**用 `scripts/clean_history.sh`
```bash
bash scripts/clean_history.sh # dry-run
bash scripts/clean_history.sh --yes # 清 zip / _discarded / logs / 杂包
bash scripts/clean_history.sh --yes --deep # 再清 source-snapshot 等更深一层
```
清完后重开:
```bash
bash start.sh # 或远端注册机继续跑
bash auth-service.sh # device-flow → authenticated/
bash keys/async_auth.sh # acpa_watchdog + sync_acc
```
## 项目结构
```text
grok_register/ 注册核心与 custom 邮箱服务
xai_enroller/ OAuth 认证服务
keys/ acpa_watchdog / sync_acc / async_auth运行时产物 gitignore
cloudflare/email-worker.js Cloudflare Email Routing Worker 示例
start.sh 首次配置和运行
auth-service.sh 认证服务入口
reset_pipeline.sh 全量清零注册→认证→CPA 运行时数据
scripts/clean_history.sh 只清历史归档,不动现网号池
scripts/push_keys_to_auth.sh 本机 SSO 源推远端 auth需显式 AUTH_SSH_*
setup.sh 安装依赖
.env.example 配置模板
tools/ 日志分析 + GrokSession→CPA/sub2api 前端转换
tests/ 自动化测试
docs/architecture.md 并发架构说明
```
## 测试
测试依赖与运行依赖分开安装:
```bash
.venv/bin/pip install -r tests/requirements.txt
```
快速检查:
```bash
python3 -m unittest tests.test_admission_gate tests.test_register_runtime_unittest tests.test_inventory_unittest tests.test_runtime_log_analyzer -v
```
完整测试:
```bash
python3 -m pytest tests -q
```
## xAI OAuth Enroller
`xai_enroller/` 用于把已有 xAI 账号的 SSO 会话转换为 OAuth 凭据,并导入 CPA
凭据库。它独立于注册流程运行:注册机不需要启动,已有账号也不需要重新注册。
### 默认同机模式
注册和认证在同一个项目目录运行时,无需配置来源:
```bash
bash auth-service.sh
```
认证服务默认读取本项目的 `keys/auth-sessions.jsonl` 和历史
`keys/accounts.txt`,每 30 秒生成一次经过校验的原子快照。注册仍可同时运行;认证服务
只读取完整记录,不会读取正在追加的半行。
### 分离设备的 SSH 模式
注册机和认证服务分开运行时,服务器继续写入注册结果,本地认证服务
每 30 秒通过一次性 SSH 导出全量 JSONL 快照。快照经逐行校验、`fsync` 后原子替换到
`~/Downloads/grok-free-register-auth/source-snapshot.jsonl`;同步失败会继续使用上一份有效快照。
认证使用本机 CloakBrowser Chromium
成功结果写入 `~/Downloads/grok-free-register-auth/authenticated/`,运行状态、同步快照与
认证文件分开保存;认证文件格式可以直接供 CPA 使用。
先把导出器同步到注册机的 `scripts/` 目录:
```bash
scp scripts/export_registered_sessions.py user@your-server:/opt/grok-free-register/scripts/
```
然后在本地配置 SSH 连接:
下面这些变量既可在终端 `export`,也可写入由 `.env.example` 复制出的 `.env`
`auth-service.sh` 会自动读取该文件。
```bash
export XAI_AUTH_SERVICE_SSH_HOST=user@your-server
export XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/ssh-key.pem # 使用 ssh-agent 时可省略
export XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
export XAI_AUTH_SERVICE_SYNC_SEC=30
```
存在 `XAI_AUTH_SERVICE_SSH_HOST` 时会自动选择 SSH。也可显式设置
`XAI_AUTH_SERVICE_SOURCE=local|ssh`;默认值 `auto` 优先兼容已有 SSH 配置,否则使用同机来源。
启动认证服务:
```bash
bash auth-service.sh
```
需要查看队列、重试、节拍和冷却探针时使用:
```bash
bash auth-service.sh --debug
```
默认每次认证至少间隔 10 秒;实测该值比无间隔运行有更高的长期平均成功速率。限流后
每 60 秒只进行一次恢复探测。需要覆盖时使用:
```bash
export XAI_AUTH_SERVICE_MIN_INTERVAL_SEC=10
export XAI_AUTH_SERVICE_RETRY_SEC=60
```
终端只在账号开始、认证结果、限流状态或控制状态变化时输出,并在底部保持 `认证> ` 输入行。`s` 查看状态,`p` 暂停,
`r` 恢复,`c` 取消当前账号,`q` 退出;`Ctrl-C` 同样会退出。在输入行键入 `take 100`
会把最新的 100 个可用凭证登记为已取用,并移动到独立批次目录。认证记录仍保留为
`imported`,不会因为凭证被取出而重新认证。
可用凭证保存在:
```text
~/Downloads/grok-free-register-auth/authenticated/
```
已取用批次保存在:
```text
~/Downloads/grok-free-register-auth/claimed/<batch-id>/
```
库存状态保存在 `enrollment-ledger.db``credential_inventory` 表中,状态为
`available``claiming``claimed`。每条记录预留 `note` 字段,默认留空。
`auth-service.sh` 首次运行会自动安装项目依赖。正式用户流程只需要这个 Bash 入口;底层 Python 模块保留给开发和测试,不作为另一套使用方式。
## 开发文档
[docs/architecture.md](docs/architecture.md) 记录并发模型、资源生命周期和必须保持的不变量。
## License
MIT

55
auth-service.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
. scripts/ensure_runtime.sh
ensure_runtime
# ---------------------------------------------------------------------------
# 后台拉起 CPA 整备 + acc token 汇总
# acpa_watchdog.py → authenticated/ → keys/cpa_ready/ (整备 cli-chat-proxy 凭据)
# sync_acc.py → keys/cpa_ready/ → keys/acc.md (只抽 access_token, 一行一个)
# 两者都常驻轮询。不用 exec: exec 会替换 shell, 后台子进程变孤儿且 trap 失效。
# 脚本退出(正常 / Ctrl-C / SIGTERM)时一并回收, 避免孤儿。
# ---------------------------------------------------------------------------
WATCHDOG="keys/acpa_watchdog.py"
SYNC_ACC="keys/sync_acc.py"
WATCHDOG_PID=""
SYNC_PID=""
cleanup() {
for pid in "${SYNC_PID:-}" "${WATCHDOG_PID:-}"; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
mkdir -p logs
if [ -f "$WATCHDOG" ]; then
.venv/bin/python "$WATCHDOG" </dev/null >>logs/watchdog.log 2>&1 &
WATCHDOG_PID=$!
echo "[auth-service] watchdog 已拉起 (pid=$WATCHDOG_PID, 日志 logs/watchdog.log)"
else
echo "[auth-service] 未找到 $WATCHDOG, 跳过自动整备" >&2
fi
if [ -f "$SYNC_ACC" ]; then
.venv/bin/python "$SYNC_ACC" </dev/null >>logs/sync_acc.log 2>&1 &
SYNC_PID=$!
echo "[auth-service] sync_acc 已拉起 (pid=$SYNC_PID, 日志 logs/sync_acc.log → keys/acc.md)"
else
echo "[auth-service] 未找到 $SYNC_ACC, 跳过 acc.md 汇总" >&2
fi
# ---------------------------------------------------------------------------
# 前台跑认证服务 (注册出货)。退出码透传; trap cleanup 在 exit 时兜底回收后台进程。
# ---------------------------------------------------------------------------
set +e
.venv/bin/python -m xai_enroller.service "$@"
RC=$?
set -e
exit "$RC"

View File

@@ -0,0 +1,61 @@
// ============================================================
// Cloudflare Email Worker — 把收到的邮件 POST 到你的 webhook
// 用于 grok-free-register 的「自建邮箱模式」(EMAIL_MODE=custom)
//
// 链路: 发件方 → Cloudflare Email Routing → 本 Worker → 你的 webhook(email_server.py)
//
// ⚠️ 重要(踩过的坑):env.WEBHOOK_URL 必须是【域名】,不能用【裸 IP】!
// Cloudflare Workers 的 fetch 不允许直接请求裸 IP,会返回
// "error code: 1003 (Direct IP access not allowed)" 并被静默吞掉
// —— Worker 看起来成功、你的服务器却收不到任何请求。
// 做法:给服务器 IP 加一条 DNS A 记录(如 hook.example.com,灰云/DNS-only),
// WEBHOOK_URL 填 http://hook.example.com:8080/webhook。
//
// 部署:
// npm create cloudflare@latest cf-mail-webhook
// cd cf-mail-webhook && npm i postal-mime
// # 用本文件替换 src/index.js
// npx wrangler secret put WEBHOOK_URL # 输入 http://hook.example.com:8080/webhook
// npx wrangler secret put WEBHOOK_TOKEN # 可选,鉴权用
// npx wrangler deploy
// 然后在 Cloudflare 后台:Email → Email Routing → Routing rules →
// Catch-all → 动作选「Send to a Worker」→ 选本 Worker。
// (子域名收信需在 Email Routing 单独启用该子域并配它自己的 catch-all。)
// ============================================================
import PostalMime from "postal-mime";
function trim(s, max = 20000) {
if (!s) return "";
return s.length > max ? s.slice(0, max) + "\n...[truncated]" : s;
}
export default {
async email(message, env, ctx) {
if (!env.WEBHOOK_URL) {
console.error("WEBHOOK_URL 未配置 (npx wrangler secret put WEBHOOK_URL)");
return;
}
const parsed = await PostalMime.parse(message.raw);
const payload = {
from: message.from,
to: message.to,
subject: parsed.subject || message.headers.get("subject") || "",
text: trim(parsed.text || ""),
html: trim(parsed.html || ""),
};
const headers = { "content-type": "application/json" };
if (env.WEBHOOK_TOKEN) headers["x-webhook-token"] = env.WEBHOOK_TOKEN;
const res = await fetch(env.WEBHOOK_URL, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
// 日志只保留状态码,避免把 webhook 地址或上游响应写入 Worker 日志。
console.log(`webhook delivery -> ${res.status}`);
if (!res.ok) {
console.error(`webhook delivery failed: ${res.status}`);
}
},
};

180
docs/architecture.md Normal file
View File

@@ -0,0 +1,180 @@
# CSP 架构说明
本文档记录当前运行时架构。README 面向使用者;本文面向维护者,重点是并发边界、资源生命周期和测试不变量。
## 目标
运行时采用 CSP 风格的异步流水线:
- `S_Worker` 生产 `T`
- `P_Worker` 发起外部请求,等待并生产 `Q`
- `C_Worker` 原子获取一组 `T + Q` 并执行最终消费。
架构目标是资源所有权闭合、背压边界清晰、取消语义可测试。当前设计不包含中心调度器、动态角色选择或运行时动态并发分配。
## 核心组件
`Physical_Sem` 限制本地浏览器重操作并发。`P_Worker` 发出请求后,在等待 `Q` 返回期间不得持有该许可。
`T_Slot_Sem` 限制已入库 `T` 的容量。`T` 生成成功后才允许获取 slot。
`Q_Slot_Sem` 限制已返回并入库 `Q` 的容量。`Q` 真正返回前不得获取 slot。
`Q_Pending_Sem` 限制已发出但尚未终态的外部 `Q` 请求数量。
`Inventory` 是唯一库存门面。worker 不直接访问底层队列,只能调用:
```text
put_t(env)
put_q(env)
claim_pair()
```
`ResourceEnvelope` 把资源实体和库存 slot 绑定在一起。slot 只能释放一次。
`PairLease``claim_pair()` 返回的异步上下文管理器。pair 一旦 claim 成功,两个 envelope 的所有权转移给 lease直到 consumer 退出上下文。
`AdmissionGate` 是局部生产准入门控,只根据库存深度和静态水位决定是否允许继续生产。它不选择 worker 角色,不搬运资源,不调整并发容量。
## Worker 流程
### S_Worker
1. 等待 `AdmissionGate` 允许生产 `T`
2. 获取 `Physical_Sem`
3. 生产 `T`
4. 释放 `Physical_Sem`
5. 调用 `ResourceEnvelope.create_with_slot(...)`,创建 envelope 并获取 `T_Slot_Sem`
6. 调用 `Inventory.put_t(...)`,把所有权转移给 `Inventory`
7. 所有权转移前如果异常或取消,释放已获取的 slot。
slot 获取和 envelope 创建必须绑定,避免取消落在“已获取 slot、尚未创建 envelope”的窗口。
### P_Worker
1. 等待 `AdmissionGate` 允许生产 `Q`
2. 获取 `Q_Pending_Sem`
3. 获取 `Physical_Sem`
4. 创建请求并发送。
5. 释放 `Physical_Sem`
6. 在不持有 `Physical_Sem` 的情况下等待 `Q` 返回。
7. `Q` 返回后调用 `ResourceEnvelope.create_with_slot(...)`,创建 envelope 并获取 `Q_Slot_Sem`
8. 调用 `Inventory.put_q(...)`,把所有权转移给 `Inventory`
9. 请求进入终态后释放 `Q_Pending_Sem`
`Q_Pending_Sem` 表达外部在途上限;`Q_Slot_Sem` 只表达已返回库存容量。两者不能合并。
### C_Worker
1. 进入 `async with inventory.claim_pair() as pair`
2. 获取 `Physical_Sem`
3. 消费 pair。
4. 释放 `Physical_Sem`
5. 退出 `PairLease`,释放两个库存 slot。
`C_Worker` 不允许先取单边资源再等待另一边。对 consumer 来说pair claim 必须是原子的。
## Inventory 语义
第一版 `Inventory` 使用一把 lock 和一个 condition。lock 保护等待、复查、过期清理和弹出操作。
必须保持以下语义:
- 等待中的 consumer 不移除资源。
- claim 成功时同时移除一个有效 `T` 和一个有效 `Q`
- 等待 pair 时被取消,不影响库存。
- claim 成功后被取消,由 `PairLease` 核销两个 envelope。
- worker 永远不能直接操作底层 `T` / `Q` 队列。
只要锁内逻辑保持很小,除 lazy expiry cleanup 外基本是 O(1),单锁在当前版本可以接受。只有 profile 证明锁竞争成为真实瓶颈时,才考虑拆锁或分片。
## 过期模型
`ResourceEnvelope` 可以携带 `created_at``expires_at``Inventory` 在配对前可以丢弃已过期资源。
当前采用 lazy cleanup
- `put_t``put_q``claim_pair` 被触发时顺手清理。
- 清理会释放它看到的过期 envelope 对应 slot。
- 系统完全静默时不会主动扫库。
- 单边长期故障和静默停摆由监控暴露,不靠后台 sweeper 修复。
当前不做 worker 级回队。消费失败后,已 claim 的 `T``Q` 都由 `PairLease` 核销。
## 容量策略
容量边界由 Semaphore 表达。启动期容量优先级:
```text
显式 PHYSICAL_CAP > CAPACITY_PROFILE > CPU/内存自动派生
```
`CAPACITY_PROFILE` 只在启动时读取,是静态 profile不是运行时调度器。
默认 worker 数量由容量派生:
```text
S_WORKERS = Physical_Sem + 2
P_WORKERS = Q_Pending_Sem + 2
C_WORKERS = Physical_Sem + 2
```
worker 数量不是主要调参入口。主要并发边界是各类容量许可,而不是 coroutine 循环数量。
## 当前不做
当前版本明确不包含:
- 中心调度器;
- 运行时角色选择;
- 动态打分;
- 动态并发控制;
- worker 级回队;
- 高价值资源抢救策略;
- 后台过期清扫;
- 自动切换高风险浏览器模式。
这些方向可以单独实验,但不能混入基础所有权模型。
## 必须保持的不变量
实现和测试必须维持以下不变量:
- 每个已获取的库存 slot 最终释放一次且只释放一次。
- 每个已准入的 pending 请求最终释放一次 `Q_Pending_Sem`
- `P_Worker` 等待 `Q` 返回时不持有 `Physical_Sem`
- `Q_Slot_Sem` 只在 `Q` 返回后获取。
- `C_Worker` 只能通过 `Inventory.claim_pair()` 获取 `T``Q`
- `claim_pair()` 要么返回一个受 `PairLease` 保护的完整 pair要么不返回资源。
- 等待 pair 时取消,不移除库存资源。
- claim pair 后取消,两个 envelope 由 `PairLease` 释放。
- 触发清理时,过期资源不能被配对。
- 监控只读,不修改 Semaphore、队列或 worker 状态。
## 测试
```bash
.venv/bin/pip install -r tests/requirements.txt
```
快速检查:
```bash
python3 -m unittest tests.test_admission_gate tests.test_register_runtime_unittest tests.test_inventory_unittest tests.test_runtime_log_analyzer -v
```
完整测试:
```bash
python3 -m pytest tests -q
```
重点测试文件:
- `tests.test_inventory_unittest`:库存、过期和 lease 行为。
- `tests.test_register_runtime_unittest`worker 运行语义和监控行。
- `tests.test_admission_gate`:局部门控水位。
- `tests.test_runtime_log_analyzer`:日志解析兼容性。
- `tests/test_cancel.py`:取消边界。
- `tests/test_property.py`:随机化不变量检查。
- `tests/test_stress.py`:更高并发 fake-service 压测。

View File

@@ -0,0 +1,67 @@
# 本地认证服务
认证服务把已有的 SSO 会话转换为 CPA 可直接读取的 OAuth 凭据。注册与认证可以在同一台机器,也可以分开运行。
## 默认同机运行
同一个项目目录已经或正在运行注册服务时,直接启动认证:
```bash
bash auth-service.sh
```
未配置 SSH 主机时,认证服务自动读取本项目 `keys/` 中的完整会话与历史账号。注册可以继续追加,认证服务只安装经过校验的完整快照。
## 配置远端同步
先把无密码导出器放到服务器项目目录:
```bash
scp scripts/export_registered_sessions.py user@server.example:/opt/grok-free-register/scripts/
```
在本地终端设置连接信息:
可以直接 `export`,也可以把 `.env.example` 复制为 `.env` 后填写;认证入口会自动读取 `.env`
```bash
export XAI_AUTH_SERVICE_SSH_HOST=user@server.example
export XAI_AUTH_SERVICE_SSH_IDENTITY=/path/to/key.pem
export XAI_AUTH_SERVICE_REMOTE_ROOT=/opt/grok-free-register
```
使用 `ssh-agent` 时可省略 `XAI_AUTH_SERVICE_SSH_IDENTITY`
设置了 `XAI_AUTH_SERVICE_SSH_HOST` 后,默认的 `auto` 模式会选择 SSH。需要明确覆盖时使用
```bash
export XAI_AUTH_SERVICE_SOURCE=local # 强制读取同机注册结果
export XAI_AUTH_SERVICE_SOURCE=ssh # 强制使用 SSH必须配置主机
```
## 运行
```bash
bash auth-service.sh
```
首次运行会自动安装项目依赖。该命令在当前终端持续运行并直接接受控制命令;输入 `q` 或按 `Ctrl-C` 停止,再次执行同一命令即可重启。不需要额外的会话管理工具。
普通模式只在来源连接、发现新账号、任务开始、认证结果、限流和控制状态变化时输出。查看队列、重试、节拍和冷却探针时使用:
```bash
bash auth-service.sh --debug
```
运行中终端底部会保持 `认证> ` 输入行。日志更新不会清掉尚未提交的内容;直接输入命令并回车:
```text
s 查看状态
take N 取用 N 个凭据
p 暂停
r 恢复
c 取消当前任务
q 安全退出
```
快照默认每 30 秒更新一次;内容无变化时终端保持安静。有效快照和已生成凭据会在重启后继续使用。

View File

@@ -0,0 +1,19 @@
# 凭据库存与取用
认证成功的凭据保存在本地认证目录,并在 SQLite 库存中维护三种状态:
- `available`:已经认证、尚未取用;
- `claiming`:正在移动到取用批次;
- `claimed`:已经取用。
运行认证服务后输入:
```text
take 100
```
服务会选择最新的 100 个可用凭据,移动到 `claimed/<batch-id>/`,再把对应库存标记为 `claimed`。认证 ledger 中的 `imported` 记录仍然保留,所以这些账号不会被重新认证。
每条库存记录预留 `note` 字段,默认为空。取用操作不要求填写用途;以后需要备注时可直接更新该字段。
若文件移动中断,服务下次启动会恢复 `claiming` 状态。库存不足或凭据文件缺失时,操作失败但认证服务继续运行。

View File

@@ -0,0 +1,54 @@
# 注册教程
## 开始运行
```bash
git clone <your-fork-or-mirror>/grok-free-register.git
cd grok-free-register
bash start.sh
```
首次运行会安装 Python、CloakBrowser Chromium 及其系统依赖,然后引导选择邮箱模式。以后再次执行 `bash start.sh` 会直接使用已有配置。
普通模式只显示服务启动、任务开始、注册成功或失败、本次运行平均速率、累计数量和限流状态。查看完整并发、库存和阶段耗时时使用:
```bash
bash start.sh --debug
```
常用参数:
```bash
bash start.sh --target 100
bash start.sh --max-mem 6G
bash start.sh --reconfig
```
未设置 `--target` 时服务持续运行,按 `Ctrl-C` 安全停止。
再次执行 `bash start.sh` 即可重启。程序直接使用当前终端,不需要额外的会话管理工具。
## 配置邮箱
临时邮箱无需额外配置:
```env
EMAIL_MODE=tempmail
```
自建邮箱需要可接收邮件的域名和本项目的收信服务:
```env
EMAIL_MODE=custom
EMAIL_DOMAIN=example.com
EMAIL_API=http://127.0.0.1:8080
```
自建模式还需运行:
```bash
bash start.sh --email-service
```
性能参数默认会根据 CPU 和可用内存估算。除非正在压测,否则保持 `.env.example` 中的默认值即可。
成功结果写入 `keys/accounts.txt``keys/grok.txt``keys/auth-sessions.jsonl`;这些文件默认不提交到 Git。

View File

@@ -0,0 +1,38 @@
# 运行状态与排障
## 普通模式
普通模式的每一行都对应一次状态变化:
- `[→]`:新任务开始;
- `[✓]`:任务成功或来源连接完成;
- `[✗]`:当前任务失败或被跳过;
- `[⏸]`:进入限流冷却或服务暂停;
- `[▶]`:限流解除或服务恢复;
- `[!]`:来源、配置或流水线出现需要关注的问题。
认证端输入 `s` 可查看运行状态、待处理数量、当前阶段、本次运行平均速率、累计成功、可用和已取用凭据,以及是否处于限流。
## Debug 模式
注册端:
```bash
bash start.sh --debug
```
认证端:
```bash
bash auth-service.sh --debug
```
注册 Debug 面板包含 T/Q 库存、物理并发、S/P/C 阶段耗时和 token 求解时间。认证 Debug 状态包含 source/prepared/completion 队列、重试、授权节拍、冷却、单次探针和近五分钟滚动速率。
## 常见状态
限流后不会持续重试。认证端默认等待 60 秒,再只放行一个恢复探针;探针仍被限流时重新等待。注册端同样通过全局冷却闸门阻止并发任务漏过等待周期。
远端来源暂时断开时,本地认证服务继续使用上一份完整有效快照。恢复连接后会自动同步,不需要重启。
配置错误会指出缺少或非法的配置名,不输出 traceback。按提示检查 [注册配置](registration.md#配置邮箱) 或 [认证同步配置](auth-service.md#配置远端同步)。

View File

@@ -0,0 +1 @@
"""Registration service package."""

395
grok_register/clearance.py Normal file
View File

@@ -0,0 +1,395 @@
"""Cloudflare clearance prewarm via external FlareSolverr.
This module does NOT run FlareSolverr. It only calls an already-deployed
FlareSolverr instance (default http://127.0.0.1:8191).
Proxy roles (do not mix):
- REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY
→ grok-free-register Playwright + httpx egress (this process)
- CLEARANCE_PROXY
→ optional proxy *inside* FlareSolverr's browser (must be reachable
from the FS *container*, e.g. http://privoxy:8118)
- FLARESOLVERR_URL
→ host-side URL to reach FS (http://127.0.0.1:8191)
Clearance cookies are only valid on the same egress path they were minted on.
If REGISTER_PROXY points at host Privoxy→WARP, set CLEARANCE_PROXY to the
docker-internal privoxy URL so FS solves on the same WARP exit.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from urllib.parse import urlparse
# CF-fronted x.ai family roots (no path suffix; path pages share host clearance)
DEFAULT_CLEARANCE_URLS = (
"https://accounts.x.ai",
"https://x.ai",
"https://status.x.ai",
"https://console.x.ai",
"https://auth.x.ai",
)
_lock = threading.RLock()
_cache: dict[str, Any] = {
"cookies": [], # Playwright cookie dicts
"user_agent": "",
"fetched_at": 0.0,
"hosts": [],
"last_error": "",
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = str(os.environ.get(name, "")).strip().lower()
if not raw:
return default
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return default
def _env_int(name: str, default: int) -> int:
try:
return int(str(os.environ.get(name, "")).strip() or default)
except (TypeError, ValueError):
return default
def clearance_enabled() -> bool:
return _env_bool("CLEARANCE_ENABLED", False)
def flaresolverr_url() -> str:
return (os.environ.get("FLARESOLVERR_URL") or "http://127.0.0.1:8191").strip().rstrip("/")
def clearance_timeout_sec() -> int:
return max(10, _env_int("CLEARANCE_TIMEOUT_SEC", 60))
def clearance_refresh_sec() -> int:
return max(60, _env_int("CLEARANCE_REFRESH_SEC", 3000))
def clearance_urls() -> list[str]:
raw = (os.environ.get("CLEARANCE_URLS") or "").strip()
if not raw:
return list(DEFAULT_CLEARANCE_URLS)
urls = []
for part in raw.split(","):
item = part.strip()
if not item:
continue
if "://" not in item:
item = "https://" + item
# strip path/query — host-level prewarm only
parsed = urlparse(item)
if parsed.scheme and parsed.netloc:
urls.append(f"{parsed.scheme}://{parsed.netloc}")
else:
urls.append(item.rstrip("/"))
# de-dupe preserve order
seen = set()
out = []
for u in urls:
if u not in seen:
seen.add(u)
out.append(u)
return out or list(DEFAULT_CLEARANCE_URLS)
def register_proxy_url() -> str:
"""Egress proxy for *this* process (Playwright / httpx)."""
for key in ("REGISTER_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"):
value = (os.environ.get(key) or "").strip()
if value:
return value
return ""
def clearance_proxy_url() -> str:
"""Proxy for FlareSolverr browser (container-reachable). Empty = FS direct egress."""
explicit = (os.environ.get("CLEARANCE_PROXY") or "").strip()
if explicit:
return explicit
# Do NOT auto-forward host REGISTER_PROXY into FS — 127.0.0.1 inside
# the container is not the host. Operator must set CLEARANCE_PROXY when
# minting on WARP (e.g. http://privoxy:8118).
return ""
def playwright_proxy_settings() -> dict[str, str] | None:
"""Playwright launch/context proxy dict, or None for direct."""
url = register_proxy_url()
if not url:
return None
return {"server": url}
def httpx_proxy_mounts() -> str | None:
"""Single proxy URL for httpx.AsyncClient(proxy=...), or None."""
return register_proxy_url() or None
def _host_from_url(url: str) -> str:
try:
return (urlparse(url).hostname or "").lower()
except Exception:
return ""
def _fs_post(payload: dict[str, Any], timeout: float) -> dict[str, Any]:
endpoint = f"{flaresolverr_url()}/v1"
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read()
return json.loads(raw.decode("utf-8"))
def _cookie_to_playwright(item: dict[str, Any], fallback_host: str) -> dict[str, Any] | None:
name = str(item.get("name") or "").strip()
if not name:
return None
value = str(item.get("value") or "")
domain = str(item.get("domain") or "").strip()
if not domain:
domain = fallback_host
if domain and not domain.startswith(".") and domain.count(".") >= 1:
# keep host-only cookies host-only; CF often uses .x.ai
pass
path = str(item.get("path") or "/") or "/"
cookie: dict[str, Any] = {
"name": name,
"value": value,
"domain": domain,
"path": path,
}
if "httpOnly" in item:
cookie["httpOnly"] = bool(item.get("httpOnly"))
if "secure" in item:
cookie["secure"] = bool(item.get("secure"))
else:
cookie["secure"] = True
same_site = item.get("sameSite") or item.get("same_site")
if same_site:
# Playwright: Strict | Lax | None
normalized = str(same_site).capitalize()
if normalized.lower() == "none":
normalized = "None"
if normalized in {"Strict", "Lax", "None"}:
cookie["sameSite"] = normalized
expires = item.get("expires")
if expires is not None:
try:
exp = float(expires)
# FS may return ms or session -1
if exp > 0:
if exp > 1e12:
exp = exp / 1000.0
cookie["expires"] = exp
except (TypeError, ValueError):
pass
return cookie
def _merge_cookies(existing: list[dict[str, Any]], incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
index: dict[tuple[str, str, str], int] = {}
merged: list[dict[str, Any]] = []
for cookie in existing:
key = (
str(cookie.get("name") or ""),
str(cookie.get("domain") or ""),
str(cookie.get("path") or "/"),
)
index[key] = len(merged)
merged.append(cookie)
for cookie in incoming:
key = (
str(cookie.get("name") or ""),
str(cookie.get("domain") or ""),
str(cookie.get("path") or "/"),
)
if key in index:
merged[index[key]] = cookie
else:
index[key] = len(merged)
merged.append(cookie)
return merged
def prewarm_clearance(*, force: bool = False) -> dict[str, Any]:
"""Hit FlareSolverr for each CLEARANCE_URLS host; cache Playwright cookies.
Safe to call when disabled — returns status without network.
"""
if not clearance_enabled():
return {
"enabled": False,
"ok": True,
"skipped": True,
"message": "CLEARANCE_ENABLED=0",
"cookies": 0,
}
with _lock:
age = time.time() - float(_cache.get("fetched_at") or 0)
if (
not force
and _cache.get("cookies")
and age < clearance_refresh_sec()
):
return {
"enabled": True,
"ok": True,
"cached": True,
"age_sec": round(age, 1),
"cookies": len(_cache["cookies"]),
"hosts": list(_cache.get("hosts") or []),
"user_agent": _cache.get("user_agent") or "",
}
urls = clearance_urls()
timeout = float(clearance_timeout_sec())
fs_proxy = clearance_proxy_url()
max_timeout_ms = int(timeout * 1000)
merged: list[dict[str, Any]] = []
user_agent = ""
hosts_ok: list[str] = []
errors: list[str] = []
per_host: list[dict[str, Any]] = []
for url in urls:
host = _host_from_url(url)
payload: dict[str, Any] = {
"cmd": "request.get",
"url": url,
"maxTimeout": max_timeout_ms,
}
if fs_proxy:
payload["proxy"] = {"url": fs_proxy}
t0 = time.time()
try:
data = _fs_post(payload, timeout=timeout + 15)
elapsed = round(time.time() - t0, 2)
if str(data.get("status") or "").lower() != "ok":
errors.append(f"{host}: status={data.get('status')} msg={data.get('message')}")
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": data.get("message")})
continue
solution = data.get("solution") if isinstance(data.get("solution"), dict) else {}
raw_cookies = solution.get("cookies") or []
pw_cookies = []
if isinstance(raw_cookies, list):
for item in raw_cookies:
if not isinstance(item, dict):
continue
converted = _cookie_to_playwright(item, host)
if converted:
pw_cookies.append(converted)
merged = _merge_cookies(merged, pw_cookies)
ua = str(solution.get("userAgent") or "").strip()
if ua:
user_agent = ua
has_cf = any(c.get("name") == "cf_clearance" for c in pw_cookies)
hosts_ok.append(host)
per_host.append(
{
"host": host,
"ok": True,
"elapsed": elapsed,
"http": solution.get("status"),
"cookies": len(pw_cookies),
"cf_clearance": has_cf,
}
)
except Exception as exc: # noqa: BLE001 — surface to operator log
elapsed = round(time.time() - t0, 2)
errors.append(f"{host}: {exc}")
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": str(exc)})
with _lock:
if merged:
_cache["cookies"] = merged
_cache["user_agent"] = user_agent
_cache["fetched_at"] = time.time()
_cache["hosts"] = hosts_ok
_cache["last_error"] = "; ".join(errors)
elif errors:
_cache["last_error"] = "; ".join(errors)
return {
"enabled": True,
"ok": bool(merged) or not errors,
"cached": False,
"cookies": len(merged),
"hosts": hosts_ok,
"user_agent": user_agent,
"errors": errors,
"detail": per_host,
"flaresolverr": flaresolverr_url(),
"clearance_proxy": fs_proxy or "(direct)",
"register_proxy": register_proxy_url() or "(direct)",
}
def cached_playwright_cookies() -> list[dict[str, Any]]:
with _lock:
return [dict(c) for c in (_cache.get("cookies") or [])]
def cached_user_agent() -> str:
with _lock:
return str(_cache.get("user_agent") or "")
async def apply_clearance_to_context(context) -> int:
"""Inject cached CF cookies into a Playwright BrowserContext. Returns count."""
cookies = cached_playwright_cookies()
if not cookies or context is None:
return 0
try:
await context.add_cookies(cookies)
return len(cookies)
except Exception:
return 0
def format_prewarm_log(result: dict[str, Any]) -> str:
if result.get("skipped"):
return "[clearance] 未启用 (CLEARANCE_ENABLED=0)"
if result.get("cached"):
return (
f"[clearance] 复用缓存 cookies={result.get('cookies')} "
f"age={result.get('age_sec')}s hosts={','.join(result.get('hosts') or [])}"
)
detail = result.get("detail") or []
parts = []
for row in detail:
host = row.get("host")
if row.get("ok"):
cf = "cf+" if row.get("cf_clearance") else "cf-"
parts.append(f"{host}:{row.get('elapsed')}s/{cf}")
else:
parts.append(f"{host}:FAIL")
err = result.get("errors") or []
suffix = f" errors={len(err)}" if err else ""
return (
f"[clearance] 预热完成 cookies={result.get('cookies')} "
f"fs={result.get('flaresolverr')} reg_proxy={result.get('register_proxy')} "
f"fs_proxy={result.get('clearance_proxy')} | " + " ".join(parts) + suffix
)

View File

View File

@@ -0,0 +1,129 @@
"""Local admission gates for CSP workers.
AdmissionGate is not a scheduler: it never chooses a role and never moves
resources. It only lets producers reserve local production intent against
inventory watermarks, so fixed S/P workers can self-throttle without a central
dispatcher.
"""
import asyncio
class _TProductionLease:
__slots__ = ("_gate", "_released")
def __init__(self, gate):
self._gate = gate
self._released = False
async def release(self):
if self._released:
return
self._released = True
async with self._gate._cond:
self._gate.t_in_progress -= 1
self._gate._cond.notify_all()
class _QBatchLease:
__slots__ = ("_gate", "count", "_remaining")
def __init__(self, gate, count):
self._gate = gate
self.count = count
self._remaining = count
async def release_one(self):
if self._remaining <= 0:
return
async with self._gate._cond:
if self._remaining <= 0:
return
self._remaining -= 1
self._gate.q_inflight -= 1
self._gate._cond.notify_all()
async def release_all(self):
if self._remaining <= 0:
return
async with self._gate._cond:
if self._remaining <= 0:
return
self._gate.q_inflight -= self._remaining
self._remaining = 0
self._gate._cond.notify_all()
class AdmissionGate:
"""Watermark-based producer admission for T and Q.
T production uses high/low hysteresis because local T generation can be
heavy and should not keep filling an already sufficient inventory.
Q production reserves a batch by current demand:
q_high - q_depth - q_inflight
capped by the worker's max batch. Each reservation is released when the
corresponding pending Q requests have reached terminal state.
"""
def __init__(self, inventory, *, t_low, t_high, q_low, q_high):
if t_low < 0 or q_low < 0:
raise ValueError("low watermarks must be non-negative")
if t_high <= 0 or q_high <= 0:
raise ValueError("high watermarks must be positive")
if t_low > t_high:
raise ValueError("t_low must be <= t_high")
if q_low > q_high:
raise ValueError("q_low must be <= q_high")
self.inventory = inventory
self.t_low = t_low
self.t_high = t_high
self.q_low = q_low
self.q_high = q_high
self.t_in_progress = 0
self.q_inflight = 0
self._t_paused = False
self._q_paused = False
self._cond = asyncio.Condition()
async def acquire_t_production(self):
"""Reserve one T production attempt."""
async with self._cond:
while True:
total = self.inventory.t_depth + self.t_in_progress
if total >= self.t_high:
self._t_paused = True
if self._t_paused and total <= self.t_low:
self._t_paused = False
if not self._t_paused and total < self.t_high:
self.t_in_progress += 1
self._cond.notify_all()
return _TProductionLease(self)
await self._cond.wait()
async def acquire_q_batch(self, *, max_batch):
"""Reserve up to max_batch pending Q requests based on current demand."""
if max_batch <= 0:
raise ValueError("max_batch must be positive")
async with self._cond:
while True:
total = self.inventory.q_depth + self.q_inflight
if total >= self.q_high:
self._q_paused = True
if self._q_paused and total <= self.q_low:
self._q_paused = False
demand = self.q_high - total
if not self._q_paused and demand > 0:
count = min(max_batch, demand)
self.q_inflight += count
if self.inventory.q_depth + self.q_inflight >= self.q_high:
self._q_paused = True
self._cond.notify_all()
return _QBatchLease(self, count)
await self._cond.wait()
async def notify_changed(self):
"""Wake waiters after inventory depth changes."""
async with self._cond:
self._cond.notify_all()

View File

@@ -0,0 +1,61 @@
"""
ResourceEnvelope — T/Q 资源实体与库存 slot 的生命周期绑定
slot 获取与 envelope 创建必须通过 create_with_slot() 完成。
release_slot_once() 幂等:重复调用只释放一次。
"""
import time
import asyncio
class ResourceEnvelope:
"""绑定一个 T/Q 资源值与其对应的库存 slot。"""
__slots__ = ('kind', 'value', 'slot_sem', '_released', 'created_at', 'expires_at', 'meta')
def __init__(self, kind, value, slot_sem, *, created_at, expires_at=None, meta=None):
self.kind = kind # 'T' or 'Q'
self.value = value # T: turnstile token str; Q: {'email','password','code'}
self.slot_sem = slot_sem # T_Slot_Sem or Q_Slot_Sem
self._released = False
self.created_at = created_at
self.expires_at = expires_at
self.meta = meta or {}
@classmethod
async def create_with_slot(cls, kind, value, slot_sem, *, expires_at=None, meta=None):
"""先获取库存 slot,再构造 envelope。slot 获取失败则抛异常,不创建 envelope。"""
await slot_sem.acquire()
try:
return cls(
kind, value, slot_sem,
created_at=time.time(),
expires_at=expires_at,
meta=meta,
)
except BaseException:
slot_sem.release()
raise
def release_slot_once(self, reason=''):
"""幂等释放 slot。重复调用安全。"""
if not self._released:
self._released = True
self.slot_sem.release()
def discard(self, reason=''):
"""资源未被消费,释放库存占用。"""
self.release_slot_once(reason=f'discard:{reason}')
def is_expired(self, now=None):
"""检查资源是否过期。"""
if self.expires_at is None:
return False
return (now or time.time()) > self.expires_at
@property
def released(self):
return self._released
def __repr__(self):
return f'Envelope({self.kind}, released={self._released})'

View File

@@ -0,0 +1,169 @@
"""
Inventory — 唯一库存门面 + PairLease
put_t / put_q: 所有权从 Worker 转移到 Inventory
claim_pair(): 等待完整 T/Q pair,通过 PairLease 原子转移所有权
内部单锁 + Condition,不做分片,不做无锁。
"""
import asyncio
import time
from collections import deque
from .envelope import ResourceEnvelope
class PairLease:
"""claim_pair 成功后的 pair 所有权对象。
用法:
async with inventory.claim_pair() as pair:
t_val, q_val = pair.t.value, pair.q.value
...
离开上下文时无论成功/失败/取消,都核销 T/Q 并释放 slot。
"""
__slots__ = ('t', 'q', '_inventory')
def __init__(self, t_env, q_env, inventory):
self.t = t_env
self.q = q_env
self._inventory = inventory
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
# 第一版策略: 无论结果如何,均核销 T/Q
self.t.release_slot_once(reason='pair_settle')
self.q.release_slot_once(reason='pair_settle')
# 通知可能在等待的 claim_pair
async with self._inventory._lock:
self._inventory._cond.notify_all()
return False
class Inventory:
"""T/Q 库存门面。
内部维护两个 deque 和一个 Lock+Condition。
put_t/put_q 在锁内 O(1) 操作;claim_pair 在锁外等待 Condition。
"""
def __init__(self, metrics=None):
self._t_buf = deque()
self._q_buf = deque()
self._lock = asyncio.Lock()
self._cond = asyncio.Condition(self._lock)
self._metrics = metrics
# ── 入库 ──
async def put_t(self, env: ResourceEnvelope):
"""将 T envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
async with self._lock:
now = time.time()
if env.is_expired(now):
env.discard(reason='expired_on_put')
if self._metrics:
self._metrics.t_expired += 1
return
self._cleanup_expired(now)
self._t_buf.append(env)
if self._metrics:
self._metrics.t_admitted += 1
self._cond.notify_all()
async def put_q(self, env: ResourceEnvelope):
"""将 Q envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
async with self._lock:
now = time.time()
if env.is_expired(now):
env.discard(reason='expired_on_put')
if self._metrics:
self._metrics.q_expired += 1
return
self._cleanup_expired(now)
self._q_buf.append(env)
if self._metrics:
self._metrics.q_admitted += 1
self._cond.notify_all()
# ── Claim ──
def claim_pair(self):
"""返回 PairLease 的 async context manager。
等待期间可取消,取消不弹出资源。成功后 T/Q 原子弹出并转移给 PairLease。
"""
return _PairClaim(self, self._metrics)
# ── 内部 ──
def _try_pop_pair(self):
"""在锁内尝试弹出一对未过期的 T/Q。返回 (t_env, q_env) 或 None。"""
now = time.time()
# 清理过期
self._cleanup_expired(now)
if self._t_buf and self._q_buf:
t_env = self._t_buf.popleft()
q_env = self._q_buf.popleft()
return t_env, q_env
return None
def _cleanup_expired(self, now):
"""lazy cleanup: 清理已过期的 T/Q 并释放 slot。锁内调用。"""
self._cleanup_buf(self._t_buf, now, 't_expired')
self._cleanup_buf(self._q_buf, now, 'q_expired')
def _cleanup_buf(self, buf, now, metric_name):
"""扫描完整库存,保留未过期实体的原始 FIFO 顺序。"""
kept = deque()
while buf:
env = buf.popleft()
if env.is_expired(now):
env.discard(reason='expired_in_inventory')
if self._metrics:
setattr(self._metrics, metric_name, getattr(self._metrics, metric_name) + 1)
else:
kept.append(env)
buf.extend(kept)
@property
def t_depth(self):
return len(self._t_buf)
@property
def q_depth(self):
return len(self._q_buf)
class _PairClaim:
"""claim_pair() 返回的 async context manager。"""
__slots__ = ('_inventory', '_pair', '_metrics')
def __init__(self, inventory, metrics):
self._inventory = inventory
self._pair = None
self._metrics = metrics
async def __aenter__(self):
inv = self._inventory
async with inv._lock:
# 循环等待直到有 pair 或被取消
while True:
pair = inv._try_pop_pair()
if pair is not None:
t_env, q_env = pair
self._pair = PairLease(t_env, q_env, inv)
if self._metrics:
self._metrics.pair_claimed += 1
return self._pair
# 没有 pair,等待 notify
await inv._cond.wait()
async def __aexit__(self, exc_type, exc, tb):
if self._pair is not None:
# PairLease.__aexit__ 处理核销
return await self._pair.__aexit__(exc_type, exc, tb)
return False

View File

@@ -0,0 +1,215 @@
"""
Observer — 只读观测层
只记录指标、生成日志。不修改 Semaphore,不调度 Worker,不释放资源。
"""
import time
from collections import deque
class Metrics:
"""全局指标收集器。所有字段只在 asyncio 单线程中写入,无需加锁。"""
__slots__ = (
'start_time',
't_produced', 't_admitted', 't_claimed', 't_expired', 't_discarded',
't_solve_count', 't_solve_seconds', 't_solve_failed',
'solver_goto_seconds', 'solver_inject_seconds', 'solver_initial_seconds',
'solver_click_seconds', 'solver_wait_seconds', 'solver_reused_count',
'solver_visible_frame_count',
's_physical_count', 's_physical_wait_seconds', 's_physical_hold_seconds',
'p_physical_count', 'p_physical_wait_seconds', 'p_physical_hold_seconds',
'c_physical_count', 'c_physical_wait_seconds', 'c_physical_hold_seconds',
'p_email_create_count', 'p_email_create_seconds',
'p_page_prepare_count', 'p_page_prepare_seconds',
'p_send_count', 'p_send_seconds',
'c_page_acquire_count', 'c_page_acquire_seconds',
'c_verify_count', 'c_verify_seconds',
'c_register_count', 'c_register_seconds',
'c_hot_page_hits', 'c_hot_page_misses',
'q_sent', 'q_returned', 'q_admitted', 'q_claimed', 'q_expired', 'q_discarded',
'q_send_batches', 'q_send_batch_items',
'pair_claimed', 'pair_consumed_ok', 'pair_consumed_fail',
'success_count',
'registration_starts',
'_clock', 'started_monotonic', 'recent_success_times',
)
def __init__(self, clock=time.monotonic):
self._clock = clock
self.started_monotonic = clock()
self.recent_success_times = deque()
self.start_time = time.time()
# T 生命周期
self.t_produced = 0
self.t_admitted = 0
self.t_claimed = 0
self.t_expired = 0
self.t_discarded = 0
self.t_solve_count = 0
self.t_solve_seconds = 0.0
self.t_solve_failed = 0
self.solver_goto_seconds = 0.0
self.solver_inject_seconds = 0.0
self.solver_initial_seconds = 0.0
self.solver_click_seconds = 0.0
self.solver_wait_seconds = 0.0
self.solver_reused_count = 0
self.solver_visible_frame_count = 0
self.s_physical_count = 0
self.s_physical_wait_seconds = 0.0
self.s_physical_hold_seconds = 0.0
self.p_physical_count = 0
self.p_physical_wait_seconds = 0.0
self.p_physical_hold_seconds = 0.0
self.c_physical_count = 0
self.c_physical_wait_seconds = 0.0
self.c_physical_hold_seconds = 0.0
self.p_email_create_count = 0
self.p_email_create_seconds = 0.0
self.p_page_prepare_count = 0
self.p_page_prepare_seconds = 0.0
self.p_send_count = 0
self.p_send_seconds = 0.0
self.c_page_acquire_count = 0
self.c_page_acquire_seconds = 0.0
self.c_verify_count = 0
self.c_verify_seconds = 0.0
self.c_register_count = 0
self.c_register_seconds = 0.0
self.c_hot_page_hits = 0
self.c_hot_page_misses = 0
# Q 生命周期
self.q_sent = 0
self.q_returned = 0
self.q_admitted = 0
self.q_claimed = 0
self.q_expired = 0
self.q_discarded = 0
self.q_send_batches = 0
self.q_send_batch_items = 0
# Pair
self.pair_claimed = 0
self.pair_consumed_ok = 0
self.pair_consumed_fail = 0
# 成功数
self.success_count = 0
self.registration_starts = 0
def next_registration_task(self):
self.registration_starts += 1
return self.registration_starts
def record_success(self):
self.success_count += 1
self.recent_success_times.append(self._clock())
def five_minute_success_rate(self):
now = self._clock()
cutoff = now - 300.0
while self.recent_success_times and self.recent_success_times[0] < cutoff:
self.recent_success_times.popleft()
if not self.recent_success_times:
return None if self.success_count == 0 else 0.0
elapsed = max(1.0, min(300.0, now - self.started_monotonic))
return len(self.recent_success_times) * 60.0 / elapsed
def runtime_average_success_rate(self):
if self.success_count == 0:
return None
elapsed = max(1.0, self._clock() - self.started_monotonic)
return self.success_count * 60.0 / elapsed
def snapshot(self, inventory, sems):
"""生成一行监控日志。"""
elapsed = time.time() - self.start_time
rate = self.success_count / (elapsed / 60) if elapsed > 60 else 0
p_batch_avg = (
self.q_send_batch_items / self.q_send_batches
if self.q_send_batches else 0
)
t_solve_avg = (
self.t_solve_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_goto_avg = (
self.solver_goto_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_inject_avg = (
self.solver_inject_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_initial_avg = (
self.solver_initial_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_click_avg = (
self.solver_click_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_wait_avg = (
self.solver_wait_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_reuse_ratio = (
self.solver_reused_count / self.t_solve_count
if self.t_solve_count else 0
)
solver_visible_ratio = (
self.solver_visible_frame_count / self.t_solve_count
if self.t_solve_count else 0
)
s_phys_wait, s_phys_hold = self._avg_pair(
self.s_physical_wait_seconds, self.s_physical_hold_seconds, self.s_physical_count
)
p_phys_wait, p_phys_hold = self._avg_pair(
self.p_physical_wait_seconds, self.p_physical_hold_seconds, self.p_physical_count
)
c_phys_wait, c_phys_hold = self._avg_pair(
self.c_physical_wait_seconds, self.c_physical_hold_seconds, self.c_physical_count
)
p_email_create = self._avg(self.p_email_create_seconds, self.p_email_create_count)
p_page_prepare = self._avg(self.p_page_prepare_seconds, self.p_page_prepare_count)
p_send_stage = self._avg(self.p_send_seconds, self.p_send_count)
c_page_acquire = self._avg(self.c_page_acquire_seconds, self.c_page_acquire_count)
c_verify = self._avg(self.c_verify_seconds, self.c_verify_count)
c_register = self._avg(self.c_register_seconds, self.c_register_count)
p_send_sem = sems.get("p_send")
admission = sems.get("admission")
p_send_part = f' p_send:{p_send_sem._value}' if p_send_sem is not None else ''
admission_part = (
f' t_prog:{admission.t_in_progress} q_inflight:{admission.q_inflight}'
if admission is not None else ''
)
return (
f'[*] T:{inventory.t_depth} Q:{inventory.q_depth} '
f'phys:{sems["physical"]._value}{p_send_part} t_slot:{sems["t_slot"]._value} '
f'q_slot:{sems["q_slot"]._value} q_pend:{sems["q_pending"]._value} '
f'p_batch:{p_batch_avg:.1f}{admission_part} '
f's_phys:{s_phys_wait:.2f}/{s_phys_hold:.2f} '
f'p_phys:{p_phys_wait:.2f}/{p_phys_hold:.2f} '
f'c_phys:{c_phys_wait:.2f}/{c_phys_hold:.2f} '
f'p_stage:{p_email_create:.2f}/{p_page_prepare:.2f}/{p_send_stage:.2f} '
f'c_stage:{c_page_acquire:.2f}/{c_verify:.2f}/{c_register:.2f} '
f'c_hot:{self.c_hot_page_hits}/{self.c_hot_page_misses} '
f't_solve_avg:{t_solve_avg:.1f} t_solve_fail:{self.t_solve_failed} '
f'solver_goto:{solver_goto_avg:.2f} solver_inject:{solver_inject_avg:.2f} '
f'solver_initial:{solver_initial_avg:.2f} solver_click:{solver_click_avg:.2f} '
f'solver_wait:{solver_wait_avg:.2f} solver_reuse:{solver_reuse_ratio:.2f} '
f'solver_visible:{solver_visible_ratio:.2f} '
f't_prod:{self.t_produced} t_adm:{self.t_admitted} t_exp:{self.t_expired} '
f'q_sent:{self.q_sent} q_ret:{self.q_returned} q_adm:{self.q_admitted} q_exp:{self.q_expired} '
f'pair:{self.pair_claimed} ok:{self.pair_consumed_ok} fail:{self.pair_consumed_fail} '
f'rate:{rate:.1f}/min #{self.success_count}'
)
@staticmethod
def _avg(total, count):
return total / count if count else 0
@staticmethod
def _avg_pair(wait_total, hold_total, count):
if not count:
return 0, 0
return wait_total / count, hold_total / count

View File

@@ -0,0 +1,171 @@
"""
邮件接收 API 服务器
====================
接收 Cloudflare Email Routing 转发的邮件,供注册服务查询。
端点:
POST /webhook — Cloudflare 转发邮件到这里
GET /check/<email> — 查询某邮箱的验证码
GET /domains — 返回可用域名
GET /health — 健康检查
用法:
bash start.sh --email-service
EMAIL_DOMAIN=your.domain bash start.sh --email-service --port 8080
"""
import os, re, json, time, sys
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from threading import Lock
# 配置
DEFAULT_DOMAIN = os.environ.get("EMAIL_DOMAIN", "")
DEFAULT_PORT = 8080
# 存储
emails = {} # {email_address: [{"code": "ABC123", "time": timestamp, "raw": "..."}]}
emails_lock = Lock()
# 清理过期邮件5 分钟)
def cleanup_old():
now = time.time()
with emails_lock:
for addr in list(emails.keys()):
emails[addr] = [e for e in emails[addr] if now - e['time'] < 300]
if not emails[addr]:
del emails[addr]
def extract_code(text):
"""从邮件内容提取验证码ABC-DEF 或 ABCDEF 格式)"""
# 格式1: ABC-DEF
m = re.search(r'>([A-Z0-9]{3}-[A-Z0-9]{3})<', text)
if m:
return m.group(1).replace('-', '')
# 格式2: 直接 6 位
m = re.search(r'>([A-Z0-9]{6})<', text)
if m:
return m.group(1)
# 格式3: 正文中的 6 位
m = re.search(r'\b([A-Z0-9]{3}-?[A-Z0-9]{3})\b', text)
if m:
return m.group(1).replace('-', '')
return None
class EmailHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/webhook':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
data = json.loads(body)
except:
data = {}
# Cloudflare Email Routing 格式
to_addr = data.get('to', data.get('recipient', ''))
from_addr = data.get('from', data.get('sender', ''))
subject = data.get('subject', '')
text = data.get('text', '')
html = data.get('html', '')
# 提取验证码
content = f"{subject}\n{text}\n{html}"
code = extract_code(content)
if to_addr and code:
with emails_lock:
if to_addr not in emails:
emails[to_addr] = []
emails[to_addr].append({
'code': code,
'time': time.time(),
'from': from_addr,
'subject': subject
})
print(f'[+] {to_addr} code={code}', flush=True)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({"ok": True, "code": code}).encode())
else:
self.send_response(404)
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
if path == '/health':
self._json({"status": "ok", "emails": len(emails)})
elif path == '/domains':
self._json({"domains": [DEFAULT_DOMAIN]})
elif path.startswith('/check/'):
addr = path[7:] # 去掉 /check/
cleanup_old()
with emails_lock:
items = emails.get(addr, [])
if items:
# 返回最新的验证码
latest = items[-1]
self._json({"code": latest['code'], "from": latest['from']})
else:
self._json({"code": None})
elif path == '/list':
# 列出所有有邮件的地址(调试用)
cleanup_old()
with emails_lock:
result = {addr: len(msgs) for addr, msgs in emails.items()}
self._json(result)
else:
self.send_response(404)
self.end_headers()
def _json(self, data):
body = json.dumps(data).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
print(f"[HTTP] {args[0] if args else format}", flush=True)
def main():
global DEFAULT_DOMAIN
port = DEFAULT_PORT
domain = DEFAULT_DOMAIN
for i, arg in enumerate(sys.argv[1:]):
if arg == '--port' and i + 2 <= len(sys.argv):
port = int(sys.argv[i + 2])
if arg == '--domain' and i + 2 <= len(sys.argv):
domain = sys.argv[i + 2]
DEFAULT_DOMAIN = domain
print(f"[*] Email server starting on :{port}", flush=True)
print(f"[*] Domain: {domain}", flush=True)
print(f"[*] Webhook: http://0.0.0.0:{port}/webhook", flush=True)
print(f"[*] Check: http://localhost:{port}/check/<email>", flush=True)
server = ThreadingHTTPServer(('0.0.0.0', port), EmailHandler)
server.serve_forever()
if __name__ == "__main__":
main()

2308
grok_register/register.py Normal file

File diff suppressed because it is too large Load Diff

944
keys/acpa_watchdog.py Executable file
View File

@@ -0,0 +1,944 @@
#!/usr/bin/env python3
"""
acpa_watchdog.py — 自动 CPA 凭据整备驻守进程
职责:
1. 盯 xai_enroller (auth-service.sh) 的出货目录:
$XAI_ENROLLER_LOCAL_AUTH_DIR/authenticated/xai-*.json
默认 ~/Downloads/grok-free-register-auth/authenticated/
xai_enroller 产出的 CPA 文件 base_url=api.x.ai/v1 (付费口, 免费号必 402)
+ headers=None (缺 grok-cli 身份头) + email=None。
2. 每个新号原样不动地「整备」一份到 PROJECT/keys/cpa_ready/xai-*.json:
- base_url → https://cli-chat-proxy.grok.com/v1 (免费 Grok 4.5 口)
- headers → grok-cli 身份头(x-grok-client-version/identifier 等)
- email → 从 id_token /access_token JWT 里挖(便于辨识, 不影响通信用)
- 权限 0600 (合规: 含 refresh_token)
3. 探活: 打 cli-chat-proxy /v1/responses model=grok-4.5
请求对齐 new-api grok-cli 完整指纹session 族头 + input 数组 + max_output_tokens 极小)。
首探 ≤MAX_PROBES(默认 1省 free 2M 额度)200 → alive。
新号落盘后 ACPA_PROBE_WARMUP_SEC(默认 3s) 再探,避免 mint 后瞬时 permission-denied。
单次探针内 403 当场短重试 ACPA_403_IMMEDIATE_RETRIES×SLEEP默认 2×4s
仍 403 → chat_denied不丢RETEST_AFTER_SEC(默认 180s) 后回测 1 次;
回测活 → alive仍 403 → chat_dead 丢。
429 free-usage-exhausted / rate_limited / 401 → 立刻丢。
alive 永不复探。
4. 状态: keys/cpa_ready/_state.tsv
name\tsub\temail\tstatus\tts\tprobes
每轮 prunejson 在 cpa_ready/_discarded 都不存在的行直接从 TSV 删掉
(删库跑路后不会残留幽灵 alive盘上有 json 但无 state 会重新进探活)。
只读 AUTH_DIR, 只写 keys/cpa_ready/。源 CPA 文件不动; 不重铸 device flow;
不做 HTTP 灌库 (入库步骤留给灌库脚本, 此处只把号整备到可直接喂 cliproxy / new-api)。
CLI:
python3 acpa_watchdog.py # 常驻轮询 (默认 2s), Ctrl-C 退
python3 acpa_watchdog.py --once # 单次扫一轮现存号就退 (CLI 自检)
python3 acpa_watchdog.py --interval 4
"""
from __future__ import annotations
import argparse
import base64
import copy
import json
import os
import signal
import sys
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# paths — portable: follow project root of this script; AUTH overridable via env
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent.parent # .../grok-free-register
# Align with xai_enroller.service DEFAULT_LOCAL_AUTH_DIR
# (~/Downloads/grok-free-register-auth/authenticated by default)
_AUTH_BASE = Path(
os.environ.get(
"XAI_ENROLLER_LOCAL_AUTH_DIR",
os.environ.get(
"XAI_AUTH_SERVICE_LOCAL_DIR",
str(Path.home() / "Downloads" / "grok-free-register-auth"),
),
)
).expanduser()
AUTH_DIR = _AUTH_BASE / "authenticated"
OUT_DIR = PROJECT_ROOT / "keys" / "cpa_ready"
DISCARD_DIR = OUT_DIR / "_discarded"
STATE_FILE = OUT_DIR / "_state.tsv"
# grok-cli 静态身份头(对齐 new-api common.GrokCLI* / SetupRequestHeader
# session/req/agent 等 per-request 头只在探针里生成,不写进落盘 json。
CLIPROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
CLIPROXY_HEADERS = {
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-shell",
"x-compaction-at": "400000",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
}
RUN = True
def _sig(signum, _frame):
global RUN
RUN = False
signal.signal(signal.SIGINT, _sig)
signal.signal(signal.SIGTERM, _sig)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def log(msg: str) -> None:
print(f"[acpa {time.strftime('%H:%M:%S')}] {msg}", flush=True)
def b64url_decode(seg: str) -> bytes:
seg += "=" * (-len(seg) % 4)
return base64.urlsafe_b64decode(seg)
def jwt_payload(token: str) -> dict:
if not token or token.count(".") != 2:
return {}
try:
return json.loads(b64url_decode(token.split(".")[1]))
except Exception:
return {}
def load_source(path: Path) -> dict | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
log(f" ✗ 读 {path.name} 失败: {exc}")
return None
def ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
try:
os.chmod(p, 0o700)
except OSError:
pass
def fallback_email(src: dict) -> str:
"""xai_enroller 产的 file email 字段是 None。从 JWT 里挖一个标识。"""
for key in ("id_token", "access_token"):
pl = jwt_payload(src.get(key) or "")
for f in ("email", "preferred_username", "sub"):
v = pl.get(f)
if v:
return str(v)
return ""
def finalize(src: dict) -> tuple[str, dict]:
"""把 xai_enroller 产的 raw CPA 整备成 cli-chat-proxy 可用格式。返回 (sub, dst)。"""
dst = copy.deepcopy(src)
dst["base_url"] = CLIPROXY_BASE_URL
dst["headers"] = dict(CLIPROXY_HEADERS)
# token_endpoint 不变 (auth.x.ai/oauth2/token, refresh 用)
dst.setdefault("auth_kind", "oauth")
dst.setdefault("type", "xai")
if not dst.get("email"):
dst["email"] = fallback_email(src)
sub = dst.get("sub") or jwt_payload(dst.get("access_token", "")).get("sub", "")
dst["sub"] = sub
return sub, dst
def write_out(name: str, entry: dict) -> Path:
ensure_dir(OUT_DIR)
target = OUT_DIR / name
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(json.dumps(entry, separators=(",", ":"), ensure_ascii=False),
encoding="utf-8")
os.replace(tmp, target)
os.chmod(target, 0o600)
return target
# ---------------------------------------------------------------------------
# probe liveness — 使用项目自带 .venv(已装 curl_cffi)
# 完整 grok-cli 指纹;默认每号只首探 1 次free ~2M禁止连 ping
# ---------------------------------------------------------------------------
_VENV_PY = PROJECT_ROOT / ".venv" / "bin" / "python"
_PROBE_SRC = r'''
import json, sys, uuid
try:
from curl_cffi import requests
except Exception as e:
print("NO_CURL_CFFI", str(e)); sys.exit(3)
fp = sys.argv[1]
d = json.load(open(fp))
at = d.get("access_token") or ""
if not at:
print(json.dumps({"status": "token_dead", "http": 401, "snippet": "no access_token"}))
sys.exit(0)
# 对齐 new-api-fusion relay/channel/grok_cli SetupRequestHeader + common.GrokCLI*
sid = str(uuid.uuid4())
rid = str(uuid.uuid4())
hdrs = {
"Authorization": "Bearer " + at,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-shell",
"x-grok-client-version": "0.2.93",
"x-authenticateresponse": "authenticate-response",
"x-compaction-at": "400000",
"Connection": "Keep-Alive",
"x-grok-session-id": sid,
"x-grok-conv-id": sid,
"x-grok-req-id": rid,
"x-grok-turn-idx": "1",
"x-grok-agent-id": "agent-" + rid[:8],
"x-grok-model-override": "grok-4.5",
}
email = d.get("email") or ""
sub = d.get("sub") or ""
if email:
hdrs["x-email"] = str(email)
if sub:
hdrs["x-userid"] = str(sub)
base = (d.get("base_url") or "https://cli-chat-proxy.grok.com/v1").rstrip("/")
# 兼容 base 已带 /v1 或只有 host
if base.endswith("/responses"):
url = base
else:
url = base + "/responses"
# input 必须是 items 数组;裸字符串会被上游拒 / 误 403
# max_output_tokens 压到极小free 号额度只花探针这一枪
body = {
"model": "grok-4.5",
"store": False,
"stream": False,
"max_output_tokens": 16,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ok"}],
}
],
}
try:
r = requests.post(url, json=body, headers=hdrs, impersonate="chrome", timeout=45)
txt = r.text or ""
code = r.status_code
except Exception as e:
print("EXC", str(e)[:200]); sys.exit(2)
status = "unknown"
low = txt.lower()
if code == 200 and ("output" in txt or "completed" in low or '"status"' in low):
# 200 即视为通路可用;不要求模型真回 "ok"(省解码/推理)
status = "alive"
elif (
code == 429
or "free-usage-exhausted" in low
or "used all the include" in low
or "rate limit" in low
or "rate_limit" in low
):
status = "free_exhausted" if ("free-usage" in low or "used all" in low) else "rate_limited"
elif code == 402 or "personal-team-blocked" in txt or "spending-limit" in txt:
status = "no_quota_paid_end"
elif code == 403 or "permission-denied" in txt or "chat endpoint is denied" in low:
status = "chat_denied"
elif code == 401:
status = "token_dead"
elif code == 422:
# body 形态问题:标 unknown 便于发现探针回归,不直接当号死
status = "unknown"
elif "forbidden" in low:
status = "chat_denied"
print(json.dumps({"status": status, "http": code, "snippet": txt[:200]}))
sys.exit(0)
'''
# 省额度:默认首探 1 次403 当场短重试后再标 soft默认 3 分钟后延迟回测 1 次。
# alive 永不复探。429 free-usage-exhausted / token_dead 等真坏号立刻丢。
MAX_PROBES = int(
os.environ.get(
"ACPA_MAX_PROBES",
os.environ.get("ACPA_PROBE_RETRIES", "1"),
)
)
PROBE_RETRY_SLEEP = float(os.environ.get("ACPA_PROBE_RETRY_SLEEP", "1.0"))
# 新号 finalize 后稍等再首探:上游常对刚 mint 的 access 回瞬时 permission-denied
PROBE_WARMUP_SEC = float(os.environ.get("ACPA_PROBE_WARMUP_SEC", "3.0"))
# 单次 probe_one 内 403 当场短重试(不算额外 MAX_PROBES 预算)
IMMEDIATE_403_RETRIES = int(os.environ.get("ACPA_403_IMMEDIATE_RETRIES", "2"))
IMMEDIATE_403_SLEEP = float(os.environ.get("ACPA_403_IMMEDIATE_SLEEP", "4.0"))
RETEST_AFTER_SEC = int(os.environ.get("ACPA_RETEST_AFTER_SEC", "180")) # 3 分钟
RETEST_MAX = int(os.environ.get("ACPA_RETEST_MAX", "1")) # 403 延迟回测次数
# 终态不再探alive 保留;下列坏号 discard
TERMINAL_OK = frozenset({"alive"})
TERMINAL_BAD = frozenset({
"free_exhausted", # 429 免费额度耗尽 → 丢
"rate_limited", # 其它 429 → 丢
"token_dead", # 401
"no_quota_paid_end",
"chat_dead", # 5 分钟回测后仍 403 → 真坏,丢
"discarded",
})
TERMINAL_STATUSES = TERMINAL_OK | TERMINAL_BAD
# 仅这些立刻/确认后丢出 cpa_ready403 首探不在此列)
DISCARD_STATUSES = frozenset({
"free_exhausted",
"rate_limited",
"token_dead",
"no_quota_paid_end",
"chat_dead",
})
# 等 5 分钟回测的软状态(保留 json不进 acc不丢
SOFT_RETEST_STATUSES = frozenset({
"chat_denied",
"forbidden_domain_ban", # 历史误标,按 403 软状态回测
"probe_cap", # 历史:首探 2 次 403 被钉死,捞回可回测
})
def probe_one(path: Path) -> dict:
"""用子进程跑探活(隔离 curl_cffi 环境)。返回 {status, http, snippet}."""
import subprocess
cp = subprocess.run(
[str(_VENV_PY), "-c", _PROBE_SRC, str(path)],
capture_output=True, text=True, timeout=60,
)
out = cp.stdout.strip()
if out.startswith("alive") or out.lstrip().startswith("{"):
try:
idx = out.find("{")
if idx >= 0:
return json.loads(out[idx:])
except Exception:
pass
return {"status": "probe_err", "http": -1, "snippet": (out or cp.stderr[:200])[:200]}
def probe_one_soft(path: Path) -> dict:
"""
一次「逻辑探针」:底层请求 + 403/瞬时错误当场短重试。
仍计为 probe_budgeted 的 1 次 used不因短重试烧额外额度记账
"""
last = {"status": "probe_err", "http": -1, "snippet": ""}
attempts = 1 + max(0, IMMEDIATE_403_RETRIES)
for i in range(attempts):
last = probe_one(path)
st = last.get("status")
if st in (
"alive", "free_exhausted", "rate_limited",
"no_quota_paid_end", "token_dead",
):
return last
if st in ("chat_denied", "forbidden_domain_ban", "probe_err", "unknown"):
if i + 1 < attempts:
time.sleep(IMMEDIATE_403_SLEEP)
continue
break
if last.get("status") == "forbidden_domain_ban":
last = {
"status": "chat_denied",
"http": last.get("http", 403),
"snippet": last.get("snippet", "")[:200],
}
return last
def probe_budgeted(path: Path, budget: int) -> tuple[dict, int]:
"""
在 budget 次内探活。返回 (最后结果, 实际探活次数)。
alive / 额度终态立刻停403 可在预算内连探;最终 403 保持 chat_denied不改 probe_cap
"""
budget = max(0, int(budget))
if budget <= 0:
return {"status": "chat_denied", "http": -1, "snippet": "no probe budget"}, 0
last = {"status": "probe_err", "http": -1, "snippet": ""}
used = 0
for i in range(budget):
last = probe_one_soft(path)
used += 1
st = last.get("status")
if st in (
"alive", "free_exhausted", "rate_limited",
"no_quota_paid_end", "token_dead",
):
return last, used
if i + 1 < budget and st in (
"chat_denied", "forbidden_domain_ban", "probe_err", "unknown",
):
time.sleep(PROBE_RETRY_SLEEP)
continue
break
# 首探/回测结束仍是 403 → 保持 chat_denied交给延迟回测或 chat_dead
if last.get("status") in ("forbidden_domain_ban",):
last = {
"status": "chat_denied",
"http": last.get("http", 403),
"snippet": last.get("snippet", "")[:200],
}
return last, used
# ---------------------------------------------------------------------------
# state — name \t sub \t email \t status \t ts [\t probes]
# ---------------------------------------------------------------------------
def load_state() -> dict:
"""{name: {sub, email, status, ts, probes}}"""
if not STATE_FILE.exists():
return {}
st = {}
for ln in STATE_FILE.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) >= 5:
name, sub, email, status, ts = parts[:5]
probes = 0
if len(parts) >= 6:
try:
probes = int(parts[5] or 0)
except ValueError:
probes = 0
# 历史行没有 probes已有终态按已探满处理避免重启后重烧额度
if probes <= 0 and status in TERMINAL_STATUSES | {
"chat_denied", "forbidden_domain_ban", "unknown", "probe_err",
}:
probes = MAX_PROBES if status != "alive" else 1
if probes <= 0 and status == "alive":
probes = 1
st[name] = {
"sub": sub, "email": email, "status": status,
"ts": ts, "probes": probes,
}
return st
def write_state(st: dict) -> None:
ensure_dir(OUT_DIR)
lines = []
for name, v in sorted(st.items()):
lines.append("\t".join([
name, v.get("sub", ""), v.get("email", ""),
v.get("status", ""), str(v.get("ts", "")),
str(int(v.get("probes") or 0)),
]))
tmp = STATE_FILE.with_suffix(".tsv.tmp")
tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
os.replace(tmp, STATE_FILE)
os.chmod(STATE_FILE, 0o600)
def json_exists_for(name: str) -> bool:
"""cpa_ready 主目录或 _discarded 里是否还有该号 json。"""
return (OUT_DIR / name).exists() or (DISCARD_DIR / name).exists()
def prune_orphan_state(st: dict) -> int:
"""
删库跑路 / 手工清 json 后TSV 里既无 cpa_ready 也无 _discarded 文件的行直接 drop。
避免幽灵 alive 把 sync/账本钉死。返回删除行数。
"""
if not st:
return 0
drop = [name for name in list(st.keys()) if not json_exists_for(name)]
if not drop:
return 0
for name in drop:
del st[name]
write_state(st)
log(f" 🧹 prune orphan state {len(drop)} 行(盘上无 json")
return len(drop)
def should_discard(status: str, probes: int = 0) -> bool:
"""仅真坏号丢429 额度耗尽 / 401 / 回测后仍 403(chat_dead)。首探 403 不丢。"""
return status in DISCARD_STATUSES
def discard_bad(name: str, st: dict, *, reason: str = "") -> bool:
"""
坏号移出 keys/cpa_ready/ → keys/cpa_ready/_discarded/
state 标记 discardedsync_acc 只认 alive 故 acc.md 自动剔除。
返回是否执行了丢弃。
"""
entry = st.get(name) or {}
status = entry.get("status", reason or "bad")
probes = _probes(entry) if entry else MAX_PROBES
if status == "discarded":
# 已丢过:确保 json 不在主目录
p = OUT_DIR / name
if p.exists():
ensure_dir(DISCARD_DIR)
dest = DISCARD_DIR / name
try:
os.replace(p, dest)
except OSError:
try:
p.unlink()
except OSError:
pass
return False
if not should_discard(status, probes) and not reason:
return False
ensure_dir(DISCARD_DIR)
src = OUT_DIR / name
moved = False
if src.exists():
dest = DISCARD_DIR / name
try:
if dest.exists():
dest.unlink()
os.replace(src, dest)
os.chmod(dest, 0o600)
moved = True
except OSError as exc:
log(f" ✗ 丢弃移动失败 {name}: {exc}")
try:
src.unlink()
moved = True
except OSError:
pass
st[name] = {
"sub": entry.get("sub", ""),
"email": entry.get("email", ""),
"status": "discarded",
"ts": int(time.time()),
"probes": probes if probes else MAX_PROBES,
}
# 旁路账本:便于扫为何丢
try:
ledger = DISCARD_DIR / "_discarded.tsv"
with ledger.open("a", encoding="utf-8") as fh:
fh.write(
f"{int(time.time())}\t{name}\t{entry.get('email','')}\t"
f"{status}\t{reason or status}\n"
)
os.chmod(ledger, 0o600)
except OSError:
pass
log(
f" 🗑 discard {name} [{status}]"
f"{' → _discarded/' if moved else ' (no json)'}"
f" email={entry.get('email','')}"
)
return True
def sweep_discard(st: dict) -> int:
"""扫 state + cpa_ready丢掉所有真坏号 json。返回丢弃个数。"""
n = 0
for name in list(st.keys()):
if not RUN:
break
v = st[name]
if v.get("status") == "discarded":
discard_bad(name, st) # 清残留 json
continue
# 软 403 已用完延迟回测次数 → 升格 chat_dead 再丢
if v.get("status") in SOFT_RETEST_STATUSES:
if _retests_done(v) >= RETEST_MAX and _age_sec(v) >= RETEST_AFTER_SEC:
v["status"] = "chat_dead"
st[name] = v
if should_discard(v.get("status", ""), _probes(v)):
if discard_bad(name, st):
n += 1
write_state(st)
return n
def rescue_soft_discards(st: dict) -> int:
"""
把误丢的 403/probe_cap 从 _discarded 捞回主目录,标 chat_denied
立刻可做一次延迟回测ts 回拨到已到期)。
账本原因为 soft或 state 已 discarded 但 json 仍在 _discarded 且无硬坏标记。
"""
ensure_dir(DISCARD_DIR)
ensure_dir(OUT_DIR)
soft_reasons = SOFT_RETEST_STATUSES | {"probe_cap", "chat_denied", "forbidden_domain_ban"}
soft_names: set[str] = set()
ledger = DISCARD_DIR / "_discarded.tsv"
if ledger.exists():
for ln in ledger.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) >= 5:
name, prev_st, reason = parts[1], parts[3], parts[4]
if prev_st in soft_reasons or reason in soft_reasons:
soft_names.add(name)
elif len(parts) >= 4:
name, prev_st = parts[1], parts[3]
if prev_st in soft_reasons:
soft_names.add(name)
n = 0
for p in sorted(DISCARD_DIR.glob("xai-*.json")):
if not RUN:
break
name = p.name
# 账本标 soft或 state 缺/已 discarded 时默认按 soft 捞(旧误丢)
entry = st.get(name) or {}
prev = entry.get("status", "discarded")
if name not in soft_names and prev not in ("discarded", "") and prev not in soft_reasons:
continue
if name not in soft_names and prev == "discarded":
# 无账本线索:仍捞(用户确认 403 常误杀);真 429 已不在 soft 集合时靠账本
soft_names.add(name)
if name not in soft_names:
continue
dest = OUT_DIR / name
try:
if dest.exists():
dest.unlink()
os.replace(p, dest)
os.chmod(dest, 0o600)
except OSError as exc:
log(f" ✗ rescue 移动失败 {name}: {exc}")
continue
# ts 回拨:立刻进入可回测窗口(不额外空等 RETEST_AFTER_SEC
st[name] = {
"sub": entry.get("sub", ""),
"email": entry.get("email", ""),
"status": "chat_denied",
"ts": int(time.time()) - RETEST_AFTER_SEC,
"probes": MAX_PROBES, # 留给 1 次回测额度(与当前 MAX_PROBES 对齐)
}
n += 1
log(f" ↩ rescue {name} → chat_denied (retest due) probes={MAX_PROBES}")
if n:
write_state(st)
return n
# ---------------------------------------------------------------------------
# main loop
# ---------------------------------------------------------------------------
def _probes(entry: dict) -> int:
try:
return int(entry.get("probes") or 0)
except (TypeError, ValueError):
return 0
def _age_sec(entry: dict) -> int:
try:
return max(0, int(time.time()) - int(entry.get("ts") or 0))
except (TypeError, ValueError):
return RETEST_AFTER_SEC
def _retests_done(entry: dict) -> int:
"""首探预算用 MAX_PROBES超出部分算延迟回测次数。"""
return max(0, _probes(entry) - MAX_PROBES)
def should_probe(name: str, st: dict) -> bool:
"""
新号首探≤MAX_PROBES 次当场连探)。
403 软状态:满首探后等 RETEST_AFTER_SEC再给 RETEST_MAX 次回测。
429/真坏/终态:不探。
"""
if name not in st:
return True
cur = st[name].get("status", "")
if cur == "discarded" or cur in TERMINAL_STATUSES:
return False
probes = _probes(st[name])
if cur in SOFT_RETEST_STATUSES:
if _retests_done(st[name]) >= RETEST_MAX:
return False
# 首探未打满(异常中断)→ 先补完首探,不算延迟回测
if probes < MAX_PROBES:
return True
return _age_sec(st[name]) >= RETEST_AFTER_SEC
# 新号 / pending / 临时错误:未满首探预算可探
if probes < MAX_PROBES and cur in ("", "pending", "probe_err", "unknown"):
return True
return False
def process_file(name: str, st: dict, *, force_probe: bool = False) -> None:
src_path = AUTH_DIR / name
# 回测/整备也可只读 cpa_ready 已有 jsonrescue 后 AUTH 可能仍在)
out_existing = OUT_DIR / name
if not src_path.exists() and not out_existing.exists():
return
src = None
if src_path.exists():
src = load_source(src_path)
if (not src or not src.get("access_token")) and out_existing.exists():
src = load_source(out_existing)
if not src:
return
if not src.get("access_token"):
log(f"{name} 无 access_token, 跳过")
return
if name in st and st[name].get("status") == "discarded":
return
sub, entry = finalize(src)
out_path = write_out(name, entry)
already = _probes(st[name]) if name in st else 0
prev_status = st[name].get("status", "") if name in st else ""
is_soft_retest = (
prev_status in SOFT_RETEST_STATUSES
and already >= MAX_PROBES
and _retests_done(st.get(name, {})) < RETEST_MAX
)
if not force_probe and not should_probe(name, st):
if name in st and should_discard(st[name].get("status", ""), already):
discard_bad(name, st)
write_state(st)
return
# 软状态未到点:静默整备
if prev_status in SOFT_RETEST_STATUSES:
left = max(0, RETEST_AFTER_SEC - _age_sec(st[name]))
if already >= MAX_PROBES and _retests_done(st[name]) < RETEST_MAX:
log(
f"{name} 403 待回测 "
f"({left}s / retest={_retests_done(st[name])}/{RETEST_MAX})"
)
return
log(
f"{name} 整备覆盖 "
f"({prev_status or '-'}/probes={already}) → {out_path.name}"
)
return
if force_probe and already >= MAX_PROBES + RETEST_MAX and not os.environ.get("ACPA_FORCE_IGNORE_CAP"):
log(f"{name} 已 probes={already},跳过强制重探(省额度)")
if should_discard(st.get(name, {}).get("status", ""), already):
discard_bad(name, st)
write_state(st)
return
if is_soft_retest:
budget = 1
phase = f"403回测×1 (after {RETEST_AFTER_SEC}s, done={_retests_done(st[name])}/{RETEST_MAX})"
else:
budget = max(0, MAX_PROBES - already)
phase = f"首探×≤{budget} (used={already}/{MAX_PROBES})"
# 新号首探前 warmup刚 mint 的 access 常被上游瞬时 403
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
time.sleep(PROBE_WARMUP_SEC)
# 新号首探前 warmup刚 mint 的 access 常被上游瞬时 403
if not is_soft_retest and PROBE_WARMUP_SEC > 0:
time.sleep(PROBE_WARMUP_SEC)
log(f"{name} 整备完成, {phase} ...")
r, used = probe_budgeted(out_path, budget)
status = r.get("status", "probe_err")
snip = r.get("snippet", "")
new_probes = already + used
# 延迟回测仍 403 → 真坏 chat_dead
if is_soft_retest and status in (
"chat_denied", "forbidden_domain_ban", "probe_cap", "probe_err", "unknown",
):
status = "chat_dead"
log(
f" {name}: [chat_dead] 回测仍 403/失败 http={r.get('http')} "
f"probes={new_probes} {snip[:70]}"
)
else:
log(
f" {name}: [{status}] http={r.get('http')} "
f"probes={new_probes} {snip[:70]}"
)
st[name] = {
"sub": sub,
"email": entry.get("email", ""),
"status": status,
"ts": int(time.time()),
"probes": new_probes,
}
if should_discard(status, new_probes):
discard_bad(name, st)
write_state(st)
def scan_once(st: dict) -> int:
"""扫一轮新号首探403 到期回测429 等真坏丢弃alive 只刷整备。"""
if not AUTH_DIR.exists():
log(f"⚠ 出货目录不存在: {AUTH_DIR} (auth-service.sh 启动后才会建)")
# 仍处理 cpa_ready 里待回测/rescue 的号
pruned = prune_orphan_state(st)
if pruned:
log(f"本轮 prune 幽灵 state {pruned}")
discarded = sweep_discard(st)
if discarded:
log(f"本轮丢弃坏号 {discarded} 个 → {DISCARD_DIR.name}/")
names: set[str] = set()
if AUTH_DIR.exists():
names.update(p.name for p in AUTH_DIR.glob("xai-*.json"))
# cpa_ready 里所有现存 json 都纳入扫描:
# - soft 到期回测
# - 无 state删 TSV 后残留 / 手工丢入)→ 当新号首探
# - alive 走 should_probe=False 分支只刷整备
if OUT_DIR.exists():
names.update(p.name for p in OUT_DIR.glob("xai-*.json"))
n = 0
for name in sorted(names):
if not RUN:
break
if name in st and st[name].get("status") == "discarded":
continue
if name in st and not should_probe(name, st):
if should_discard(st[name].get("status", ""), _probes(st[name])):
discard_bad(name, st)
write_state(st)
continue
src_path = AUTH_DIR / name if (AUTH_DIR / name).exists() else OUT_DIR / name
src = load_source(src_path) if src_path.exists() else None
if src and src.get("access_token"):
_, entry = finalize(src)
write_out(name, entry)
continue
process_file(name, st)
n += 1
return n
def main() -> int:
ap = argparse.ArgumentParser(description="auto CPA 整备驻守")
ap.add_argument("--interval", type=float, default=2.0)
ap.add_argument("--once", action="store_true", help="扫一轮就退")
ap.add_argument(
"--reprobe-soft",
action="store_true",
help="立刻对 chat_denied/probe_cap 等软 403 做回测(仍 1 次/号,受 RETEST_MAX",
)
ap.add_argument(
"--no-rescue",
action="store_true",
help="启动时不从 _discarded 捞回误丢的 403",
)
args = ap.parse_args()
ensure_dir(OUT_DIR)
ensure_dir(DISCARD_DIR)
st = load_state()
n_prune = prune_orphan_state(st)
# 历史 probe_cap 并入 soft可走延迟回测
for name, v in st.items():
if v.get("status") == "probe_cap":
v["status"] = "chat_denied"
soft_n = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
bad_n = sum(
1 for v in st.values()
if v.get("status") != "discarded"
and should_discard(v.get("status", ""), _probes(v))
)
log(
f"启动 | 出货={AUTH_DIR} | 落盘={OUT_DIR} | state={len(st)}"
f"(启动 prune {n_prune}) | 首探≤{MAX_PROBES}/号 "
f"| warmup={PROBE_WARMUP_SEC}s 403即重试={IMMEDIATE_403_RETRIES}×{IMMEDIATE_403_SLEEP}s "
f"| 403回测 after={RETEST_AFTER_SEC}s ×{RETEST_MAX} "
f"| soft_403={soft_n} | pending_discard={bad_n}"
)
if not args.no_rescue:
n_res = rescue_soft_discards(st)
if n_res:
log(f"启动捞回误丢 403 {n_res} 个(将回测)")
n0 = sweep_discard(st)
if n0:
log(f"启动丢弃真坏号 {n0} 个 → {DISCARD_DIR}/")
if st and not args.reprobe_soft:
log(
f"(state 已有: 刷 alive 整备403 满 {RETEST_AFTER_SEC}s 回测×{RETEST_MAX}"
f"429 free_exhausted 立刻丢)"
)
for name in list(st.keys()):
if not RUN:
break
if st[name].get("status") != "alive":
continue
src_path = AUTH_DIR / name
if src_path.exists():
src = load_source(src_path)
if src and src.get("access_token"):
_, entry = finalize(src)
write_out(name, entry)
if args.reprobe_soft:
# soft 立刻回测一次:拨到期 + 把 probes 收成 MAX_PROBES
# 避免旧版 probes=2 在新默认 MAX_PROBES=1 下被算成 retest 已用尽。
# 只动 softalive 绝不复探(省 free 额度)。
now = int(time.time())
targets = []
for name, v in st.items():
if v.get("status") in SOFT_RETEST_STATUSES:
v["ts"] = now - RETEST_AFTER_SEC
v["probes"] = MAX_PROBES
targets.append(name)
write_state(st)
log(f"--reprobe-soft: {len(targets)} 个 403 软状态立刻回测×1alive 不动)")
for name in targets:
if not RUN:
break
process_file(name, st, force_probe=True)
write_state(st)
alive = sum(1 for v in st.values() if v.get("status") == "alive")
dead = sum(
1 for v in st.values()
if v.get("status") in DISCARD_STATUSES or v.get("status") == "discarded"
)
soft = sum(1 for v in st.values() if v.get("status") in SOFT_RETEST_STATUSES)
log(f"回测结束 | alive={alive} soft_403={soft} dead/discard={dead} state={len(st)}")
return 0
while RUN:
try:
n = scan_once(st)
if n:
log(f"本轮处理 {n} 个号(新号/回测)")
except Exception as exc:
log(f"轮询异常: {type(exc).__name__} {exc}")
if args.once:
break
slept = 0.0
while slept < args.interval and RUN:
time.sleep(0.25)
slept += 0.25
log(f"退出 | state={len(st)} 条 写 {STATE_FILE}")
write_state(st)
return 0
if __name__ == "__main__":
sys.exit(main())

188
keys/sync_acc.py Executable file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
sync_acc.py — 把 keys/cpa_ready/ 里「探活通过」号的 access_token 同步进 keys/acc.md
默认只入库 acpa_watchdog 记为 alive 的号(读 keys/cpa_ready/_state.tsv
403 forbidden / token_dead / probe_err 等不进 acc.md避免坏号灌库。
用法:
python3 sync_acc.py # 常驻轮询(默认 5s), Ctrl-C 退
python3 sync_acc.py --once # 同步一次就退
python3 sync_acc.py --all # 忽略探活状态,全量抽 token排障用
python3 sync_acc.py --interval 10
acc.md 每轮全量重写(去重)0600。
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import signal
import sys
import time
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent # grok-free-register/
SRC_DIR = PROJECT / "keys" / "cpa_ready"
STATE_FILE = SRC_DIR / "_state.tsv"
OUT_FILE = PROJECT / "keys" / "acc.md"
# 默认可入库的探活状态discarded/坏号不在 cpa_ready 主目录,也不在此集合)
ALIVE_STATUSES = frozenset({"alive"})
SKIP_STATUSES = frozenset({
"discarded", "free_exhausted", "rate_limited", "token_dead",
"no_quota_paid_end", "probe_cap", "chat_denied", "chat_dead",
"forbidden_domain_ban", "probe_err", "unknown",
})
RUN = True
def _sig(_s, _f):
global RUN
RUN = False
signal.signal(signal.SIGINT, _sig)
signal.signal(signal.SIGTERM, _sig)
def log(msg: str) -> None:
print(f"[sync_acc {time.strftime('%H:%M:%S')}] {msg}", flush=True)
def load_alive_names() -> set[str] | None:
"""
读 _state.tsv → 状态为 alive 且盘上仍有 json 的文件名集合。
无 state 文件返回 None。幽灵 aliveTSV 有、json 无)不计。
"""
if not STATE_FILE.exists():
return None
alive: set[str] = set()
for ln in STATE_FILE.read_text(encoding="utf-8").splitlines():
parts = ln.split("\t")
if len(parts) < 4:
continue
name, status = parts[0], parts[3]
if status in ALIVE_STATUSES:
# 只认盘上真实存在的号,避免删库后 state 幽灵把计数钉死
if (SRC_DIR / name).exists():
alive.add(name)
# discarded / 坏状态明确不进(双保险;主目录 json 也应已被 watchdog 挪走)
elif status in SKIP_STATUSES:
continue
return alive
def collect(*, require_alive: bool) -> tuple[list[str], dict[str, int]]:
"""
返回 (去重 token 列表, 计数)。
require_alive=True 时只收 _state.tsv 标为 alive 且文件仍在的 xai-*.json。
"""
files = sorted(glob.glob(str(SRC_DIR / "xai-*.json")))
alive_names = load_alive_names() if require_alive else None
seen: set[str] = set()
out: list[str] = []
stats = {
"files": len(files),
"alive_state": len(alive_names) if alive_names is not None else -1,
"skipped_not_alive": 0,
"no_tok": 0,
"bad": 0,
"no_state_skip_all": 0,
}
if require_alive and alive_names is None:
# 有 cpa json 但还没 state宁可不灌避免 403 进库
if files:
log(f"⚠ 无 {STATE_FILE.name},拒绝全量入库(等 watchdog 探活)")
stats["no_state_skip_all"] = len(files)
return [], stats
for f in files:
name = Path(f).name
if require_alive and alive_names is not None and name not in alive_names:
stats["skipped_not_alive"] += 1
continue
try:
d = json.loads(Path(f).read_text(encoding="utf-8"))
except Exception:
stats["bad"] += 1
continue
tok = d.get("access_token")
if not isinstance(tok, str) or not tok:
stats["no_tok"] += 1
continue
if tok in seen:
continue
seen.add(tok)
out.append(tok)
return out, stats
def write_acc(tokens: list[str]) -> None:
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
# 固定同目录临时文件,避免 with_suffix 边界情况 + 保证 replace 原子
tmp = OUT_FILE.parent / (OUT_FILE.name + ".tmp")
tmp.write_text("\n".join(tokens) + ("\n" if tokens else ""), encoding="utf-8")
os.replace(tmp, OUT_FILE)
try:
os.chmod(OUT_FILE, 0o600)
except OSError:
pass
def sync_once(*, require_alive: bool) -> int:
toks, st = collect(require_alive=require_alive)
write_acc(toks)
mode = "alive-only" if require_alive else "all"
log(
f"sync → {OUT_FILE.name}: {len(toks)} tokens [{mode}] "
f"(files={st['files']} state_alive={st['alive_state']} "
f"skip_dead={st['skipped_not_alive']} no_tok={st['no_tok']} bad={st['bad']})"
)
return len(toks)
def main() -> int:
ap = argparse.ArgumentParser(
description="cpa_ready access_token → keys/acc.md (default: alive only)"
)
ap.add_argument("--interval", type=float, default=5.0)
ap.add_argument("--once", action="store_true", help="刷一次就退")
ap.add_argument(
"--all",
action="store_true",
help="忽略探活,全量抽 token排障会把 403 也写入 acc.md",
)
args = ap.parse_args()
require_alive = not args.all
if not SRC_DIR.exists():
log(f"⚠ 目录不存在: {SRC_DIR} (watchdog 整备后才会建)")
if args.once:
return 0
sync_once(require_alive=require_alive)
if args.once:
return 0
log(f"常驻轮询 interval={args.interval}s alive_only={require_alive} (Ctrl-C 退)")
while RUN:
slept = 0.0
while slept < args.interval and RUN:
time.sleep(0.25)
slept += 0.25
if not RUN:
break
sync_once(require_alive=require_alive)
log("退出")
return 0
if __name__ == "__main__":
sys.exit(main())

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
cloakbrowser>=0.3.0
requests>=2.31.0
python-dotenv>=1.0.0
httpx>=0.28
playwright>=1.55
curl_cffi>=0.7.0
curl_cffi>=0.7.0

222
reset_pipeline.sh Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# 一键清零注册 → 认证 → CPA 整条流水线的运行时数据,方便重开。
#
# 你只清 keys/acc.md / accounts.txt 不够:
# acpa_watchdog 会从 ~/Downloads/grok-free-register-auth/authenticated/
# 把旧号再整备进 keys/cpa_ready/sync_acc 再写回 acc.md。
#
# 用法(本文件在项目根):
# bash reset_pipeline.sh # dry-run只列将删内容
# bash reset_pipeline.sh --yes # 真正删除
# bash reset_pipeline.sh --yes --keep-register
# # 只清认证/CPA保留 keys 里新注册的 SSO 源
# bash reset_pipeline.sh --yes --keep-cpa
# # 只清认证账本/源快照,保留 cpa_ready一般不需要
#
# 建议先停 auth-service / register再跑本脚本。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
KEYS="$ROOT/keys"
AUTH_DIR="${XAI_AUTH_SERVICE_LOCAL_DIR:-$HOME/Downloads/grok-free-register-auth}"
YES=0
KEEP_REGISTER=0
KEEP_CPA=0
KEEP_AUTH_SALT=1
for arg in "$@"; do
case "$arg" in
--yes|-y) YES=1 ;;
--keep-register) KEEP_REGISTER=1 ;;
--keep-cpa) KEEP_CPA=1 ;;
--wipe-salt) KEEP_AUTH_SALT=0 ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0
;;
*)
echo "未知参数: $arg" >&2
exit 2
;;
esac
done
# 运行时产物清单(脚本本身永不删)
REGISTER_FILES=(
"$KEYS/accounts.txt"
"$KEYS/grok.txt"
"$KEYS/auth-sessions.jsonl"
"$KEYS/acc.md"
)
CPA_GLOBS=(
"$KEYS/cpa_ready/xai-"*.json
"$KEYS/cpa_ready/_state.tsv"
)
AUTH_PATHS=(
"$AUTH_DIR/authenticated"
"$AUTH_DIR/claimed"
"$AUTH_DIR/enrollment-ledger.db"
"$AUTH_DIR/source-snapshot.jsonl"
)
AUTH_SALT="$AUTH_DIR/.ledger-salt"
count_matches() {
local n=0
local p
for p in "$@"; do
# shellcheck disable=SC2086
if compgen -G "$p" > /dev/null 2>&1; then
while IFS= read -r -d '' f; do
n=$((n + 1))
done < <(find $p -type f -print0 2>/dev/null || true)
# also count plain files matched by glob/file
if [[ -f "$p" ]]; then
n=$((n + 1))
fi
elif [[ -f "$p" ]]; then
n=$((n + 1))
elif [[ -d "$p" ]]; then
while IFS= read -r -d '' f; do
n=$((n + 1))
done < <(find "$p" -type f -print0 2>/dev/null || true)
fi
done
echo "$n"
}
size_of() {
local p="$1"
if [[ -e "$p" ]]; then
du -sh "$p" 2>/dev/null | awk '{print $1}'
else
echo "-"
fi
}
echo "=== grok-free-register 流水线清零 ==="
echo "ROOT = $ROOT"
echo "KEYS = $KEYS"
echo "AUTH_DIR = $AUTH_DIR"
echo
# 检测可能还在跑的服务
if pgrep -af 'xai_enroller|acpa_watchdog|sync_acc|grok_register.register' 2>/dev/null | grep -v "reset_pipeline\|pgrep\|grep" >/dev/null; then
echo "[!] 检测到相关进程仍在运行:"
pgrep -af 'xai_enroller|acpa_watchdog|sync_acc|grok_register.register' 2>/dev/null | grep -v "reset_pipeline\|pgrep" || true
echo " 建议先 Ctrl-C 停掉 auth-service / register再 --yes"
echo
fi
echo "-- 将处理的目标 --"
if [[ "$KEEP_REGISTER" -eq 0 ]]; then
for f in "${REGISTER_FILES[@]}"; do
if [[ -e "$f" ]]; then
echo " [register] $(size_of "$f") $f"
fi
done
else
echo " [register] 跳过 (--keep-register)"
fi
if [[ "$KEEP_CPA" -eq 0 ]]; then
cpa_n=$(find "$KEYS/cpa_ready" -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')
echo " [cpa] ${cpa_n} 个 xai-*.json @ $KEYS/cpa_ready/"
[[ -f "$KEYS/cpa_ready/_state.tsv" ]] && echo " [cpa] _state.tsv"
else
echo " [cpa] 跳过 (--keep-cpa)"
fi
auth_n=$(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ')
echo " [auth] authenticated/ ${auth_n} 文件 ($(size_of "$AUTH_DIR/authenticated"))"
[[ -d "$AUTH_DIR/claimed" ]] && echo " [auth] claimed/ $(find "$AUTH_DIR/claimed" -type f 2>/dev/null | wc -l | tr -d ' ') 文件"
[[ -f "$AUTH_DIR/enrollment-ledger.db" ]] && echo " [auth] enrollment-ledger.db $(size_of "$AUTH_DIR/enrollment-ledger.db")"
[[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo " [auth] source-snapshot.jsonl $(size_of "$AUTH_DIR/source-snapshot.jsonl") lines=$(wc -l < "$AUTH_DIR/source-snapshot.jsonl" 2>/dev/null | tr -d ' ')"
if [[ "$KEEP_AUTH_SALT" -eq 0 && -f "$AUTH_SALT" ]]; then
echo " [auth] .ledger-salt (将删,指纹会重算)"
else
echo " [auth] .ledger-salt 保留"
fi
echo
if [[ "$YES" -ne 1 ]]; then
echo "dry-run 模式,未删除任何东西。"
echo "确认后执行: bash reset_pipeline.sh --yes"
exit 0
fi
rm_file() {
local f="$1"
if [[ -f "$f" || -L "$f" ]]; then
rm -f -- "$f"
echo " removed file $f"
fi
}
wipe_dir_contents() {
local d="$1"
if [[ -d "$d" ]]; then
# 只清内容,保留目录本身
find "$d" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
echo " wiped dir $d/"
fi
}
echo "-- 执行删除 --"
if [[ "$KEEP_REGISTER" -eq 0 ]]; then
for f in "${REGISTER_FILES[@]}"; do
rm_file "$f"
done
# 重建空占位,避免下游 open 报错
: > "$KEYS/accounts.txt"
: > "$KEYS/grok.txt"
: > "$KEYS/auth-sessions.jsonl"
: > "$KEYS/acc.md"
chmod 600 "$KEYS/acc.md" "$KEYS/auth-sessions.jsonl" 2>/dev/null || true
echo " recreated empty accounts.txt / grok.txt / auth-sessions.jsonl / acc.md"
fi
if [[ "$KEEP_CPA" -eq 0 ]]; then
mkdir -p "$KEYS/cpa_ready"
find "$KEYS/cpa_ready" -type f \( -name 'xai-*.json' -o -name '_state.tsv' \) -delete 2>/dev/null || true
echo " wiped $KEYS/cpa_ready/xai-*.json + _state.tsv"
fi
# 认证侧:这是旧号回灌的真正源头
mkdir -p "$AUTH_DIR/authenticated" "$AUTH_DIR/claimed"
wipe_dir_contents "$AUTH_DIR/authenticated"
wipe_dir_contents "$AUTH_DIR/claimed"
rm_file "$AUTH_DIR/enrollment-ledger.db"
rm_file "$AUTH_DIR/source-snapshot.jsonl"
# 偶发的 sqlite 旁路文件
rm_file "$AUTH_DIR/enrollment-ledger.db-wal"
rm_file "$AUTH_DIR/enrollment-ledger.db-shm"
rm_file "$AUTH_DIR/enrollment-ledger.db-journal"
if [[ "$KEEP_AUTH_SALT" -eq 0 ]]; then
rm_file "$AUTH_SALT"
fi
# 兜底:项目内若误放了同名目录也清
for extra in "$KEYS/authenticated" "$KEYS/claimed"; do
if [[ -d "$extra" ]]; then
wipe_dir_contents "$extra"
fi
done
echo
echo "-- 清零后状态 --"
echo " accounts.txt lines = $(wc -l < "$KEYS/accounts.txt" 2>/dev/null | tr -d ' ')"
echo " auth-sessions = $(wc -l < "$KEYS/auth-sessions.jsonl" 2>/dev/null | tr -d ' ')"
echo " acc.md tokens = $(wc -l < "$KEYS/acc.md" 2>/dev/null | tr -d ' ')"
echo " cpa_ready json = $(find "$KEYS/cpa_ready" -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " authenticated json = $(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " ledger.db = $([[ -f "$AUTH_DIR/enrollment-ledger.db" ]] && echo present || echo gone)"
echo " snapshot = $([[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo present || echo gone)"
echo
echo "完成。重开顺序建议:"
echo " 1) bash start.sh # 注册出 SSO → keys/auth-sessions.jsonl + accounts.txt"
echo " 2) bash auth-service.sh # device-flow → AUTH_DIR/authenticated + acpa→cpa_ready + sync_acc"
echo " 若只想清 CPA/认证、保留刚注的 SSO: 下次加 --keep-register"

140
scripts/clean_history.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# 快速清历史归档(不碰现网号池)。
#
# 只删这些「历史」:
# keys/cpa_ready/cpa_ready_*.zip 导出包
# keys/cpa_ready/_discarded/** 已丢弃号
# keys/*.bak* / keys/*~ / keys/__pycache__
# keys/async_auth.log / logs/*
# keys/212.zip 等杂包
#
# 默认保留:
# keys/cpa_ready/xai-*.json
# keys/cpa_ready/_state.tsv
# keys/acc.md / accounts.txt / auth-sessions.jsonl
# AUTH_DIR/authenticated 现网出货
#
# 用法(项目根):
# bash scripts/clean_history.sh # dry-run
# bash scripts/clean_history.sh --yes # 真删
# bash scripts/clean_history.sh --yes --deep # 再清 source-snapshot / 旧 .bak 脚本
#
# 全量清零号池请用: bash reset_pipeline.sh --yes
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
KEYS="$ROOT/keys"
CPA="$KEYS/cpa_ready"
AUTH_DIR="${XAI_AUTH_SERVICE_LOCAL_DIR:-${XAI_ENROLLER_LOCAL_AUTH_DIR:-$HOME/Downloads/grok-free-register-auth}}"
LOGS="$ROOT/logs"
YES=0
DEEP=0
for arg in "$@"; do
case "$arg" in
--yes|-y) YES=1 ;;
--deep) DEEP=1 ;;
-h|--help)
sed -n '2,24p' "$0"
exit 0
;;
*)
echo "未知参数: $arg" >&2
exit 2
;;
esac
done
size_of() {
local p="$1"
if [[ -e "$p" ]]; then
du -sh "$p" 2>/dev/null | awk '{print $1}'
else
echo "-"
fi
}
list_targets() {
# zips / archives under cpa_ready and keys
find "$CPA" -maxdepth 1 -type f \( -name 'cpa_ready_*.zip' -o -name '*.zip' -o -name '*.tar' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null || true
find "$KEYS" -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar' -o -name '*.tar.gz' -o -name '*.tgz' \) 2>/dev/null || true
# discarded
if [[ -d "$CPA/_discarded" ]]; then
find "$CPA/_discarded" -mindepth 1 2>/dev/null || true
fi
# logs / pyc / bak
find "$KEYS" -maxdepth 1 -type f \( -name '*.log' -o -name '*.bak' -o -name '*.bak.*' -o -name '*~' \) 2>/dev/null || true
find "$KEYS" -maxdepth 1 -type d -name '__pycache__' 2>/dev/null || true
if [[ -d "$LOGS" ]]; then
find "$LOGS" -type f 2>/dev/null || true
fi
if [[ "$DEEP" -eq 1 ]]; then
# deep: old snapshot growth + script backups; still keep live pool
[[ -f "$AUTH_DIR/source-snapshot.jsonl" ]] && echo "$AUTH_DIR/source-snapshot.jsonl"
find "$KEYS" -maxdepth 1 -type f -name 'acpa_watchdog.py.bak*' 2>/dev/null || true
fi
}
mapfile -t TARGETS < <(list_targets | awk 'NF' | sort -u)
echo "=== grok-free-register 快速清历史 ==="
echo "ROOT = $ROOT"
echo "KEYS = $KEYS"
echo "AUTH_DIR = $AUTH_DIR"
echo "MODE = $([[ $YES -eq 1 ]] && echo APPLY || echo dry-run)$([[ $DEEP -eq 1 ]] && echo ' +deep' || true)"
echo
if [[ "${#TARGETS[@]}" -eq 0 ]]; then
echo "没有可清的历史文件。"
exit 0
fi
echo "-- 将处理 --"
total=0
for f in "${TARGETS[@]}"; do
if [[ -e "$f" ]]; then
echo " $(size_of "$f") $f"
total=$((total + 1))
fi
done
echo "$total"
echo
# live pool summary (never touched)
echo "-- 现网号池(保留)--"
echo " cpa xai-*.json = $(find "$CPA" -maxdepth 1 -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " acc.md lines = $(wc -l < "$KEYS/acc.md" 2>/dev/null | tr -d ' ' || echo 0)"
echo " sessions = $(wc -l < "$KEYS/auth-sessions.jsonl" 2>/dev/null | tr -d ' ' || echo 0)"
echo " authenticated = $(find "$AUTH_DIR/authenticated" -type f 2>/dev/null | wc -l | tr -d ' ' || echo 0)"
echo
if [[ "$YES" -ne 1 ]]; then
echo "dry-run。确认后: bash scripts/clean_history.sh --yes"
echo "更深一层(含 snapshot): bash scripts/clean_history.sh --yes --deep"
exit 0
fi
echo "-- 执行删除 --"
for f in "${TARGETS[@]}"; do
if [[ -d "$f" ]]; then
rm -rf -- "$f"
echo " removed dir $f"
elif [[ -e "$f" ]]; then
rm -f -- "$f"
echo " removed file $f"
fi
done
# keep empty discarded dir
mkdir -p "$CPA/_discarded" 2>/dev/null || true
mkdir -p "$LOGS" 2>/dev/null || true
echo
echo "-- 清后 --"
echo " cpa zip left = $(find "$CPA" -maxdepth 1 -name '*.zip' -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " discarded files= $(find "$CPA/_discarded" -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " cpa xai-*.json = $(find "$CPA" -maxdepth 1 -name 'xai-*.json' -type f 2>/dev/null | wc -l | tr -d ' ')"
echo " keys size = $(size_of "$KEYS")"
echo "完成。"

32
scripts/ensure_runtime.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
ensure_runtime() {
local lock_dir=".setup.lock"
local acquired=0
local attempt
for attempt in {1..300}; do
if mkdir "$lock_dir" 2>/dev/null; then
acquired=1
break
fi
sleep 0.2
done
if [ "$acquired" -ne 1 ]; then
echo "[!] 另一个安装进程长时间未结束,请稍后重试。" >&2
return 1
fi
trap 'rmdir .setup.lock 2>/dev/null || true' EXIT INT TERM
if [ ! -d .venv ]; then
echo "[*] 首次运行,安装依赖..."
if ! bash setup.sh; then
rmdir "$lock_dir"
trap - EXIT INT TERM
return 1
fi
fi
rmdir "$lock_dir"
trap - EXIT INT TERM
}

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""通过 SSH 导出不含密码的注册会话;兼容历史裸 SSO 记录。"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
COOKIE_FIELDS = frozenset(
{
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
)
MAX_RECORD_BYTES = 256 * 1024
LEGACY_COOKIE_SCOPE = {
"name": "sso",
"domain": "accounts.x.ai",
"path": "/",
"secure": True,
"httpOnly": True,
"sameSite": "Lax",
}
def _decode_json_line(raw, label):
if len(raw) > MAX_RECORD_BYTES:
raise ValueError(f"invalid {label} record")
try:
document = json.loads(raw.decode("utf-8"))
email = document["email"]
cookies = document["cookies"]
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
raise ValueError(f"invalid {label} record") from exc
if not isinstance(email, str) or not email or not isinstance(cookies, list) or not cookies:
raise ValueError(f"invalid {label} record")
try:
email.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError(f"invalid {label} record") from exc
normalized = []
for cookie in cookies:
if not isinstance(cookie, dict):
raise ValueError(f"invalid {label} record")
filtered = {key: cookie[key] for key in COOKIE_FIELDS if key in cookie}
if not all(
isinstance(filtered.get(key), str) and filtered[key]
for key in ("name", "value")
):
raise ValueError(f"invalid {label} record")
scope = filtered.get("domain") or filtered.get("url")
if not isinstance(scope, str) or not scope:
raise ValueError(f"invalid {label} record")
try:
filtered["name"].encode("utf-8")
filtered["value"].encode("utf-8")
scope.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError(f"invalid {label} record") from exc
normalized.append(filtered)
return {"email": email, "cookies": normalized}
def _complete_lines(data):
"""Return newline-terminated records and the unconsumed trailing bytes."""
parts = data.split(b"\n")
return parts[:-1], parts[-1]
def _read_complete_file(path):
if not path.exists():
return []
lines, _incomplete = _complete_lines(path.read_bytes())
return [line for line in lines if line]
def load_snapshots(path, *, raw_lines=None):
snapshots = {}
scopes = {}
lines = _read_complete_file(path) if raw_lines is None else raw_lines
for line in lines:
document = _decode_json_line(line, "session")
email = document["email"]
cookies = document["cookies"]
if email in snapshots:
continue
snapshots[email] = {"email": email, "cookies": cookies}
for cookie in cookies:
name = cookie.get("name")
domain = cookie.get("domain")
if name in {"sso", "sso-rw"} and domain:
scopes[(name, domain)] = {
"name": name,
"domain": domain,
"path": cookie.get("path", "/"),
"secure": bool(cookie.get("secure", True)),
"httpOnly": bool(cookie.get("httpOnly", True)),
"sameSite": cookie.get("sameSite", "Lax"),
}
return snapshots, scopes
def export_sessions(sessions_path, accounts_path, *, session_lines=None):
snapshots, scopes = load_snapshots(sessions_path, raw_lines=session_lines)
for document in snapshots.values():
yield document
if not accounts_path.exists():
return
legacy_scopes = list(scopes.values()) or [LEGACY_COOKIE_SCOPE]
for raw in _read_complete_file(accounts_path):
try:
email, _password, sso = raw.decode("utf-8").rsplit(":", 2)
except (UnicodeDecodeError, ValueError) as exc:
raise ValueError("invalid account record") from exc
if not email or not sso or email in snapshots:
continue
cookies = [{**scope, "value": sso} for scope in legacy_scopes]
yield {"email": email, "cookies": cookies}
def _write_document(document):
payload = json.dumps(document, separators=(",", ":"))
if len(payload.encode("utf-8")) > MAX_RECORD_BYTES:
raise ValueError("invalid session record")
print(payload, flush=False)
def export_and_follow(sessions_path, accounts_path, *, poll_seconds=0.25):
"""Emit a complete snapshot, then losslessly follow the same JSONL fd."""
sessions_path.parent.mkdir(parents=True, exist_ok=True)
if not sessions_path.exists():
fd = os.open(sessions_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.close(fd)
os.chmod(sessions_path, 0o600)
stream = sessions_path.open("rb")
with stream:
opened = os.fstat(stream.fileno())
initial_lines, pending = _complete_lines(stream.read())
if len(pending) > MAX_RECORD_BYTES:
raise ValueError("invalid session record")
for document in export_sessions(
sessions_path,
accounts_path,
session_lines=[line for line in initial_lines if line],
):
_write_document(document)
sys.stdout.flush()
while True:
chunk = stream.read()
if chunk:
complete, pending = _complete_lines(pending + chunk)
if len(pending) > MAX_RECORD_BYTES:
raise ValueError("invalid session record")
for raw in complete:
if raw:
_write_document(_decode_json_line(raw, "session"))
sys.stdout.flush()
continue
try:
current = sessions_path.stat()
except FileNotFoundError:
return 3
if (
current.st_dev != opened.st_dev
or current.st_ino != opened.st_ino
or current.st_size < stream.tell()
):
return 3
time.sleep(poll_seconds)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--follow", action="store_true")
parser.add_argument("sessions_path", type=Path)
parser.add_argument("accounts_path", type=Path)
args = parser.parse_args()
try:
if args.follow:
raise SystemExit(export_and_follow(args.sessions_path, args.accounts_path))
for document in export_sessions(args.sessions_path, args.accounts_path):
_write_document(document)
except ValueError:
raise SystemExit(4) from None
except BrokenPipeError:
try:
sys.stdout.close()
finally:
raise SystemExit(0)
if __name__ == "__main__":
main()

153
scripts/push_keys_to_auth.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# 本机注册 → 推 SSO 源到远端 auth 机local 模式吃 keys/
#
# 动态公网 IP 不适合「远端 SSH 拉本机」;改由本机主动推:
# keys/auth-sessions.jsonl
# keys/accounts.txt
# → AUTH_HOST:REMOTE_ROOT/keys/
#
# 用法(本机项目根或任意 cwd:
# bash scripts/push_keys_to_auth.sh # 推一次
# bash scripts/push_keys_to_auth.sh --watch # 常驻,文件变了再推
# bash scripts/push_keys_to_auth.sh --interval 15
#
# 环境变量(可写本机 .env本脚本会 source:
# AUTH_SSH_HOST=user@auth-server.example
# AUTH_REMOTE_ROOT=/opt/grok-free-register
# AUTH_SSH_IDENTITY= # 可选 -i 路径
# AUTH_PUSH_INTERVAL=20 # --watch 轮询秒
#
# 无内置默认主机/路径:必须显式设置 AUTH_SSH_HOST 与 AUTH_REMOTE_ROOT。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# 调用方已 export 的 AUTH_* 优先(.env 不得盖掉),方便并行推多台 auth
_PRE_AUTH_SSH_HOST="${AUTH_SSH_HOST-}"
_PRE_AUTH_REMOTE_ROOT="${AUTH_REMOTE_ROOT-}"
_PRE_AUTH_SSH_IDENTITY="${AUTH_SSH_IDENTITY-}"
_PRE_AUTH_PUSH_INTERVAL="${AUTH_PUSH_INTERVAL-}"
if [[ -f "$ROOT/.env" ]]; then
set -a
# shellcheck disable=SC1091
. "$ROOT/.env"
set +a
fi
AUTH_SSH_HOST="${_PRE_AUTH_SSH_HOST:-${AUTH_SSH_HOST:-}}"
AUTH_REMOTE_ROOT="${_PRE_AUTH_REMOTE_ROOT:-${AUTH_REMOTE_ROOT:-}}"
AUTH_SSH_IDENTITY="${_PRE_AUTH_SSH_IDENTITY:-${AUTH_SSH_IDENTITY:-}}"
AUTH_PUSH_INTERVAL="${_PRE_AUTH_PUSH_INTERVAL:-${AUTH_PUSH_INTERVAL:-20}}"
if [[ -z "$AUTH_SSH_HOST" || -z "$AUTH_REMOTE_ROOT" ]]; then
echo "push_keys_to_auth: set AUTH_SSH_HOST and AUTH_REMOTE_ROOT (env or .env)" >&2
echo " e.g. AUTH_SSH_HOST=user@auth-server.example AUTH_REMOTE_ROOT=/opt/grok-free-register" >&2
exit 2
fi
WATCH=0
INTERVAL="$AUTH_PUSH_INTERVAL"
for a in "$@"; do
case "$a" in
--watch) WATCH=1 ;;
--interval)
# next arg handled below if present as --interval=N form preferred
;;
--interval=*) INTERVAL="${a#--interval=}" ;;
-h|--help)
sed -n '2,22p' "$0"
exit 0
;;
esac
done
# support: --interval 15
prev=""
for a in "$@"; do
if [[ "$prev" == "--interval" ]]; then
INTERVAL="$a"
fi
prev="$a"
done
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=15)
if [[ -n "$AUTH_SSH_IDENTITY" ]]; then
SSH_OPTS+=(-i "$AUTH_SSH_IDENTITY")
fi
KEYS_SRC="$ROOT/keys"
REMOTE_KEYS="${AUTH_REMOTE_ROOT}/keys"
# 只推 auth 源,不推 cpa_ready/acc远端自己整备
FILES=(auth-sessions.jsonl accounts.txt)
log() { printf '[push_keys %s] %s\n' "$(date +%H:%M:%S)" "$*"; }
fingerprint() {
local f
for f in "${FILES[@]}"; do
if [[ -f "$KEYS_SRC/$f" ]]; then
# size + mtime + sha256 head — cheap change detect
stat -c '%s %Y' "$KEYS_SRC/$f" 2>/dev/null || stat -f '%z %m' "$KEYS_SRC/$f"
sha256sum "$KEYS_SRC/$f" 2>/dev/null | awk '{print $1}'
else
echo "missing:$f"
fi
done
}
push_once() {
local missing=0 f
for f in "${FILES[@]}"; do
if [[ ! -f "$KEYS_SRC/$f" ]]; then
log "⚠ 缺 $KEYS_SRC/$f"
missing=1
fi
done
if [[ "$missing" -eq 1 ]]; then
return 1
fi
# 远端原子替换:先推 .push.tmp 再 mv
ssh "${SSH_OPTS[@]}" "$AUTH_SSH_HOST" "mkdir -p $(printf %q "$REMOTE_KEYS") && chmod 700 $(printf %q "$REMOTE_KEYS") 2>/dev/null || true"
local remote_tmp remote_final
for f in "${FILES[@]}"; do
remote_tmp="${REMOTE_KEYS}/.${f}.push.tmp"
remote_final="${REMOTE_KEYS}/${f}"
# rsync over ssh when available; fallback scp
if command -v rsync >/dev/null 2>&1; then
rsync -az -e "ssh ${SSH_OPTS[*]}" \
"$KEYS_SRC/$f" \
"${AUTH_SSH_HOST}:${remote_tmp}"
else
scp "${SSH_OPTS[@]}" "$KEYS_SRC/$f" "${AUTH_SSH_HOST}:${remote_tmp}"
fi
ssh "${SSH_OPTS[@]}" "$AUTH_SSH_HOST" \
"chmod 600 $(printf %q "$remote_tmp") && mv -f $(printf %q "$remote_tmp") $(printf %q "$remote_final")"
done
local n_sess n_acc
n_sess=$(wc -l < "$KEYS_SRC/auth-sessions.jsonl" | tr -d ' ')
n_acc=$(wc -l < "$KEYS_SRC/accounts.txt" | tr -d ' ')
log "${AUTH_SSH_HOST}:${REMOTE_KEYS}/ sessions=${n_sess} accounts=${n_acc}"
}
LAST_FP=""
if [[ "$WATCH" -eq 0 ]]; then
push_once
exit $?
fi
log "watch host=${AUTH_SSH_HOST} root=${AUTH_REMOTE_ROOT} interval=${INTERVAL}s"
while true; do
FP="$(fingerprint)"
if [[ "$FP" != "$LAST_FP" ]]; then
if push_once; then
LAST_FP="$FP"
else
log "推送失败,${INTERVAL}s 后重试"
fi
fi
sleep "$INTERVAL"
done

50
setup.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Grok Free Register — 一键安装脚本
# 用法: bash setup.sh
set -e
echo "=== Grok Free Register 安装 ==="
# 检测系统
if [ -f /etc/debian_version ]; then
echo "[1/4] 安装系统依赖 (Debian/Ubuntu)..."
sudo apt update -qq
sudo apt install -y -qq \
python3 python3-pip python3-venv \
libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 \
libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \
libcairo2 libasound2t64 libnspr4 libnss3 libxshmfence1 \
2>/dev/null || true
# 兼容旧版 Ubuntu
sudo apt install -y -qq libatk1.0-0 libatk-bridge2.0-0 libcups2 libasound2 2>/dev/null || true
elif [ -f /etc/redhat-release ]; then
echo "[1/4] 安装系统依赖 (RHEL/CentOS)..."
sudo yum install -y -q \
python3 python3-pip \
atk cups-libs libdrm libXcomposite libXdamage libXfixes libXrandr \
mesa-libgbm pango cairo alsa-lib nspr nss libxshmfence \
2>/dev/null || true
else
echo "[1/4] 未知系统,跳过系统依赖(如 Chrome 启动失败请手动安装)"
fi
# Python 虚拟环境
echo "[2/4] 创建 Python 环境..."
python3 -m venv .venv
.venv/bin/pip install -q --upgrade pip
.venv/bin/pip install -q -r requirements.txt
# 本项目直接由 Playwright 启动二进制,因此显式准备 CloakBrowser Chromium。
echo "[3/4] 下载 CloakBrowser Chromium..."
.venv/bin/python -m cloakbrowser install
# 创建输出目录
mkdir -p keys
echo "[4/4] 安装完成!"
echo ""
echo "运行注册服务: bash start.sh"
echo "运行自建邮箱服务: bash start.sh --email-service"
echo "运行认证服务: bash auth-service.sh"

80
start.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
# 一键启动:自动装依赖 → 引导配置 → 运行
# 用法:
# bash start.sh # 首次会引导选模式,之后直接启动
# bash start.sh --reconfig # 重新选择邮箱模式
# bash start.sh --debug # 保留完整调试面板
set -e
cd "$(dirname "$0")"
. scripts/ensure_runtime.sh
ensure_runtime
if [ "${1:-}" = "--email-service" ]; then
shift
if command -v flock >/dev/null 2>&1; then
mkdir -p logs
exec 8>logs/email-service.lock
if ! flock -n 8; then
echo "[!] 邮箱服务已经在运行。"
exit 1
fi
fi
echo "[*] 启动邮箱服务... (Ctrl-C 停止)"
exec .venv/bin/python -m grok_register.email_server "$@"
fi
reconfig=0
register_args=()
for arg in "$@"; do
if [ "$arg" = "--reconfig" ]; then
reconfig=1
else
register_args+=("$arg")
fi
done
# 同一工作目录只允许一个注册进程,避免重复启动同时写账号和日志。
if command -v flock >/dev/null 2>&1; then
mkdir -p logs
exec 9>logs/register.lock
if ! flock -n 9; then
echo "[!] 注册机已经在运行。"
exit 1
fi
fi
# 1) 配置:无 .env 或显式 --reconfig 时进入引导
if [ ! -f .env ] || [ "$reconfig" -eq 1 ]; then
echo ""
echo "选择邮箱模式:"
echo " [1] 免费临时邮箱 (默认 · 零配置 · 直接回车 · 多 provider 自动 fallback)"
echo " [2] 自建域名邮箱 (需 Cloudflare Email Routing + 本地 webhook)"
read -rp "输入 1 或 2 [1]: " mode || mode=1
if [ "$mode" = "2" ]; then
read -rp " 你的域名 (如 example.com): " domain
read -rp " webhook 地址 [http://127.0.0.1:8080]: " api
api=${api:-http://127.0.0.1:8080}
cat > .env <<ENV
EMAIL_MODE=custom
EMAIL_DOMAIN=${domain}
EMAIL_API=${api}
# CSP 容量(可选,0=按 CPU/内存启动期静态派生)
# PHYSICAL_CAP=0
# PHYSICAL_PER_CPU=2
# PHYSICAL_MEM_MB=512
# MIN_FREE_MEM_MB=500
ENV
echo ""
echo "[!] custom 模式还需在另一终端运行收信服务:"
echo " bash start.sh --email-service"
echo " 并按 README「自建邮箱模式」配置 Cloudflare Email Worker。"
else
echo "EMAIL_MODE=tempmail" > .env
fi
echo "[*] 已写入 .env"
fi
# 2) 运行
echo "[*] 启动注册服务... (Ctrl-C 停止)"
exec .venv/bin/python -m grok_register.register "${register_args[@]}"

0
tests/__init__.py Normal file
View File

92
tests/conftest.py Normal file
View File

@@ -0,0 +1,92 @@
"""
共享 fixtures — 供四层测试共用
"""
import sys
import os
import asyncio
import pytest
# 确保项目根目录在 sys.path 中
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
from tests.fakes import (
FakeTurnstile, FakeEmailService, FakeRegisterAPI,
Conservation, EventLog,
)
@pytest.fixture
def metrics():
return Metrics()
@pytest.fixture
def inventory(metrics):
return Inventory(metrics=metrics)
@pytest.fixture
def sems():
"""标准 Semaphore 集合(小容量,便于测试)。"""
return {
'physical': asyncio.Semaphore(4),
't_slot': asyncio.Semaphore(4),
'q_slot': asyncio.Semaphore(4),
'q_pending': asyncio.Semaphore(6),
}
@pytest.fixture
def large_sems():
"""大容量 Semaphore(压力测试用)。"""
return {
'physical': asyncio.Semaphore(32),
't_slot': asyncio.Semaphore(64),
'q_slot': asyncio.Semaphore(64),
'q_pending': asyncio.Semaphore(48),
}
@pytest.fixture
def conservation(sems, inventory):
return Conservation(
sems['t_slot'], sems['q_slot'], sems['q_pending'], inventory
)
@pytest.fixture
def fake_turnstile():
return FakeTurnstile()
@pytest.fixture
def fake_email():
return FakeEmailService()
@pytest.fixture
def fake_register():
return FakeRegisterAPI()
@pytest.fixture
def event_log():
return EventLog()
@pytest.fixture
def stop():
return asyncio.Event()
@pytest.fixture
def file_lock():
return asyncio.Lock()
# ── 跳过需要网络的测试(本地开发时) ──
def pytest_configure(config):
config.addinivalue_line("markers", "slow: 压力测试(较慢)")
config.addinivalue_line("markers", "needs_network: 需要外部网络")

317
tests/fakes.py Normal file
View File

@@ -0,0 +1,317 @@
"""
Fake 服务 + 测试工具
FakeTurnstile — 可控 T 生成器:可注入延迟、失败、取消点
FakeEmailService — 可控 Q 提供者:可注入延迟、超时、验证码
FakeConsumer — 可控 C 消费器:可注入延迟、失败
Conservation — slot 守恒断言器
CancelPoint — 在指定 await 边界注入取消
"""
import asyncio
import time
import dataclasses
from typing import Optional, Callable, Any
# ═══════════════════════════════════════════
# Fake T 生成器 (替代 solve_one_turnstile)
# ═══════════════════════════════════════════
class FakeTurnstile:
"""可控 token 生成器。
config:
delay: 生成延迟(秒)
fail_rate: 0.0~1.0,随机失败概率
token_seq: 预设 token 序列,None 则自动递增
"""
def __init__(self, delay=0.01, fail_rate=0.0, token_seq=None):
self.delay = delay
self._fail_rate = fail_rate
self._seq = token_seq or []
self._idx = 0
self._call_count = 0
self._fail_count = 0
self._cancel_at: Optional[int] = None # 第 N 次调用时注入取消
def set_cancel_at(self, n: int):
"""第 n 次调用时注入 CancelledError。"""
self._cancel_at = n
async def generate(self) -> Optional[str]:
"""生成一个 token。返回 None 表示失败,raise CancelledError 表示取消。"""
self._call_count += 1
if self._cancel_at is not None and self._call_count == self._cancel_at:
raise asyncio.CancelledError(f'FakeTurnstile cancel at call {self._call_count}')
await asyncio.sleep(self.delay)
if self._fail_count < self._fail_rate * self._call_count:
return None
# 简单随机: fail_rate 作为概率
import random
if random.random() < self._fail_rate:
self._fail_count += 1
return None
if self._idx < len(self._seq):
tok = self._seq[self._idx]
self._idx += 1
return tok
self._idx += 1
return f'fake_token_{self._idx}'
@property
def call_count(self):
return self._call_count
# ═══════════════════════════════════════════
# Fake 邮箱服务 (替代 create_email + poll_code)
# ═══════════════════════════════════════════
@dataclasses.dataclass
class FakeEmailResult:
handle: str
email: str
password: str
code: Optional[str] = None # None = 轮询超时
create_delay: float = 0.01
poll_delay: float = 0.01
class FakeEmailService:
"""可控邮箱 + 验证码服务。
results: 预设结果队列,每次 create+poll 消费一个。
"""
def __init__(self, results=None):
self._results = list(results or [])
self._idx = 0
self._create_count = 0
self._poll_count = 0
self._cancel_create_at: Optional[int] = None
self._cancel_poll_at: Optional[int] = None
def set_cancel_create_at(self, n: int):
self._cancel_create_at = n
def set_cancel_poll_at(self, n: int):
self._cancel_poll_at = n
async def create_email(self):
"""创建邮箱。返回 (handle, email, password)。"""
self._create_count += 1
if self._cancel_create_at and self._create_count == self._cancel_create_at:
raise asyncio.CancelledError(f'FakeEmail.create cancel at {self._create_count}')
if self._idx < len(self._results):
r = self._results[self._idx]
await asyncio.sleep(r.create_delay)
return r.handle, r.email, r.password
self._idx += 1
n = self._idx
await asyncio.sleep(0.01)
return f'handle_{n}', f'user_{n}@test.com', f'pass_{n}'
async def poll_code(self, handle: str, max_wait=90):
"""轮询验证码。返回 code 或 None。"""
self._poll_count += 1
if self._cancel_poll_at and self._poll_count == self._cancel_poll_at:
raise asyncio.CancelledError(f'FakeEmail.poll cancel at {self._poll_count}')
# 找匹配的预设结果
for r in self._results:
if r.handle == handle:
await asyncio.sleep(r.poll_delay)
return r.code
await asyncio.sleep(0.01)
return '000000'
@property
def create_count(self):
return self._create_count
@property
def poll_count(self):
return self._poll_count
# ═══════════════════════════════════════════
# Fake 注册 API (替代 grpc_verify + server_action_register)
# ═══════════════════════════════════════════
class FakeRegisterAPI:
"""可控注册 API。"""
def __init__(self, success_rate=1.0, delay=0.01):
self.success_rate = success_rate
self.delay = delay
self._register_count = 0
self._cancel_at: Optional[int] = None
self._fail_at: Optional[int] = None
def set_cancel_at(self, n: int):
self._cancel_at = n
def set_fail_at(self, n: int):
self._fail_at = n
async def register(self, email, password, code, token) -> Optional[str]:
"""执行注册。返回 SSO 或 None。"""
self._register_count += 1
if self._cancel_at and self._register_count == self._cancel_at:
raise asyncio.CancelledError(f'FakeRegister cancel at {self._register_count}')
if self._fail_at and self._register_count == self._fail_at:
return None
await asyncio.sleep(self.delay)
import random
if random.random() < self.success_rate:
return f'sso_{email}_{int(time.time())}'
return None
@property
def register_count(self):
return self._register_count
# ═══════════════════════════════════════════
# Slot 守恒断言器
# ═══════════════════════════════════════════
class Conservation:
"""检查 T/Q slot 守恒不变量。
守恒公式:
slot_total = free_slots + inventory_depth + pairleased_held + worker_held
"""
def __init__(self, t_slot_sem, q_slot_sem, q_pending_sem, inventory):
self.t_slot_sem = t_slot_sem
self.q_slot_sem = q_slot_sem
self.q_pending_sem = q_pending_sem
self.inventory = inventory
# 追踪 Worker 持有的未发布资源
self._worker_held_t = 0
self._worker_held_q = 0
def worker_acquire_t(self):
self._worker_held_t += 1
def worker_release_t(self):
self._worker_held_t -= 1
def worker_acquire_q(self):
self._worker_held_q += 1
def worker_release_q(self):
self._worker_held_q -= 1
def check_t_conservation(self, t_slot_total: int, pairleased_t: int = 0):
"""检查 T slot 守恒。
Args:
t_slot_total: T_Slot_Sem 初始容量
pairleased_t: 当前 PairLease 持有的 T 数量
"""
free = self.t_slot_sem._value
inv = self.inventory.t_depth
held = self._worker_held_t
total = free + inv + pairleased_t + held
assert total == t_slot_total, (
f'T slot 守恒违反: free({free}) + inv({inv}) + pairleased({pairleased_t}) '
f'+ worker_held({held}) = {total} != total({t_slot_total})'
)
def check_q_conservation(self, q_slot_total: int, pairleased_q: int = 0):
"""检查 Q slot 守恒。"""
free = self.q_slot_sem._value
inv = self.inventory.q_depth
held = self._worker_held_q
total = free + inv + pairleased_q + held
assert total == q_slot_total, (
f'Q slot 守恒违反: free({free}) + inv({inv}) + pairleased({pairleased_q}) '
f'+ worker_held({held}) = {total} != total({q_slot_total})'
)
def check_pending_conservation(self, pending_total: int, in_flight: int = 0):
"""检查 Q pending 守恒。"""
free = self.q_pending_sem._value
total = free + in_flight
assert total == pending_total, (
f'Q pending 守恒违反: free({free}) + in_flight({in_flight}) = {total} '
f'!= total({pending_total})'
)
def check_all(self, t_total, q_total, pend_total, pairleased_t=0, pairleased_q=0, in_flight=0):
"""一次性检查所有守恒。"""
self.check_t_conservation(t_total, pairleased_t)
self.check_q_conservation(q_total, pairleased_q)
self.check_pending_conservation(pend_total, in_flight)
# ═══════════════════════════════════════════
# 取消注入器
# ═══════════════════════════════════════════
class CancelInjector:
"""在指定 await 边界前后注入取消。"""
def __init__(self):
self._points: dict[str, asyncio.Event] = {}
self._cancel_task: Optional[asyncio.Task] = None
def register(self, name: str):
"""注册一个取消点。"""
self._points[name] = asyncio.Event()
async def wait_at(self, name: str):
"""Worker 在此挂起,等待外部触发取消。"""
if name in self._points:
await self._points[name].wait()
def trigger(self, name: str, task: asyncio.Task):
"""触发指定取消点,取消目标 task。"""
if name in self._points:
self._points[name].set()
task.cancel()
async def cancel_after(self, delay: float, task: asyncio.Task):
"""延迟后取消目标 task。"""
await asyncio.sleep(delay)
task.cancel()
# ═══════════════════════════════════════════
# 事件记录器(用于性质测试)
# ═══════════════════════════════════════════
@dataclasses.dataclass
class Event:
ts: float
kind: str # 't_produced', 't_admitted', 't_claimed', 'q_sent', 'q_returned',
# 'q_admitted', 'q_claimed', 'pair_claimed', 'pair_ok', 'pair_fail',
# 't_expired', 'q_expired', 't_discarded', 'q_discarded',
# 'sem_t_free', 'sem_q_free', 'sem_pend_free', 'inv_t', 'inv_q'
detail: Any = None
class EventLog:
"""记录所有事件,用于事后分析。"""
def __init__(self):
self.events: list[Event] = []
self._lock = asyncio.Lock()
async def record(self, kind: str, detail=None):
async with self._lock:
self.events.append(Event(ts=time.time(), kind=kind, detail=detail))
def count(self, kind: str) -> int:
return sum(1 for e in self.events if e.kind == kind)
def filter(self, kind: str) -> list[Event]:
return [e for e in self.events if e.kind == kind]
def dump(self) -> str:
lines = []
for e in self.events:
lines.append(f'{e.ts:.3f} {e.kind} {e.detail or ""}')
return '\n'.join(lines)

2
tests/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pytest>=8.0.0
pytest-asyncio>=0.24.0

View File

@@ -0,0 +1,82 @@
import asyncio
import unittest
from grok_register.core.admission import AdmissionGate
class FakeInventory:
def __init__(self):
self.t_depth = 0
self.q_depth = 0
class AdmissionGateTests(unittest.IsolatedAsyncioTestCase):
async def test_t_production_blocks_at_high_water_and_wakes_at_low_water(self):
inventory = FakeInventory()
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=1, q_high=3)
inventory.t_depth = 3
blocked = asyncio.create_task(gate.acquire_t_production())
await asyncio.sleep(0.02)
self.assertFalse(blocked.done())
inventory.t_depth = 1
await gate.notify_changed()
lease = await asyncio.wait_for(blocked, timeout=1)
self.assertEqual(gate.t_in_progress, 1)
await lease.release()
self.assertEqual(gate.t_in_progress, 0)
async def test_q_batch_reserves_only_current_demand(self):
inventory = FakeInventory()
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=2, q_high=5)
lease = await gate.acquire_q_batch(max_batch=4)
self.assertEqual(lease.count, 4)
self.assertEqual(gate.q_inflight, 4)
second = await gate.acquire_q_batch(max_batch=4)
self.assertEqual(second.count, 1)
self.assertEqual(gate.q_inflight, 5)
blocked = asyncio.create_task(gate.acquire_q_batch(max_batch=4))
await asyncio.sleep(0.02)
self.assertFalse(blocked.done())
await lease.release_all()
await second.release_all()
inventory.q_depth = 1
await gate.notify_changed()
third = await asyncio.wait_for(blocked, timeout=1)
self.assertEqual(third.count, 4)
await third.release_all()
async def test_q_batch_waits_for_low_water_after_reaching_high_water(self):
inventory = FakeInventory()
gate = AdmissionGate(inventory, t_low=1, t_high=3, q_low=2, q_high=5)
lease = await gate.acquire_q_batch(max_batch=5)
self.assertEqual(lease.count, 5)
self.assertEqual(gate.q_inflight, 5)
await lease.release_one()
self.assertEqual(gate.q_inflight, 4)
blocked = asyncio.create_task(gate.acquire_q_batch(max_batch=5))
await asyncio.sleep(0.02)
self.assertFalse(blocked.done())
await lease.release_one()
await lease.release_one()
await lease.release_one()
resumed = await asyncio.wait_for(blocked, timeout=1)
self.assertEqual(resumed.count, 4)
await lease.release_all()
await resumed.release_all()
if __name__ == "__main__":
unittest.main()

475
tests/test_cancel.py Normal file
View File

@@ -0,0 +1,475 @@
"""
Layer 2: 取消注入测试
在每个 await 边界前后强制取消 Worker,验证:
- S 取消后 Physical_Sem 和 T_Slot_Sem 不泄漏
- P 取消后 Physical_Sem、Q_Pending_Sem、Q_Slot_Sem 不泄漏
- C 取消后 Physical_Sem 不泄漏,已 claim 的 T/Q 被核销
- 库存守恒: 取消不导致 slot 无界堆积或耗尽
"""
import asyncio
import time
import pytest
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI, Conservation
# ═══════════════════════════════════════════
# S_Worker 取消测试
# ═══════════════════════════════════════════
async def _s_worker_impl(browser, inventory, physical_sem, t_slot_sem, metrics, stop):
"""S_Worker 简化实现,用于测试。"""
while not stop.is_set():
await physical_sem.acquire()
try:
token = await browser.generate()
finally:
physical_sem.release()
if token is None:
metrics.t_discarded += 1
await asyncio.sleep(0.05)
continue
metrics.t_produced += 1
now = time.time()
try:
t_env = await ResourceEnvelope.create_with_slot(
'T', token, t_slot_sem, expires_at=now + 300
)
except asyncio.CancelledError:
metrics.t_discarded += 1
raise
try:
await inventory.put_t(t_env)
except Exception:
t_env.discard(reason='put_failed')
metrics.t_discarded += 1
await asyncio.sleep(0.02)
class TestSCancel:
"""S_Worker 取消语义测试。"""
@pytest.mark.asyncio
async def test_cancel_during_generate_no_leak(self):
"""S 在 generate() 期间被取消,sem 不泄漏。"""
ft = FakeTurnstile(delay=0.5) # 慢生成
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
task = asyncio.create_task(
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
)
await asyncio.sleep(0.05) # 等它进入 generate
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Physical_Sem 应完全释放
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
# T_Slot_Sem 不变(没生成成功)
assert t_slot._value == 4
@pytest.mark.asyncio
async def test_cancel_during_put_t_no_leak(self):
"""S 在 put_t 期间被取消(极端: put 内部取消),envelope 被 discard。"""
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
# 快速生成
ft = FakeTurnstile(delay=0.001)
task = asyncio.create_task(
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
)
# 在 put_t 之前的极短窗口取消
await asyncio.sleep(0.002)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert phys._value == 2
# t_slot 可能是 4 或 3,但不应是负数
assert t_slot._value >= 0
assert t_slot._value <= 4
@pytest.mark.asyncio
async def test_s_repeated_cancel_no_accumulation(self):
"""反复取消 S 多次,sem 计数不累积。"""
ft = FakeTurnstile(delay=0.01)
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
for _ in range(20):
task = asyncio.create_task(
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
)
await asyncio.sleep(0.02)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
# 守恒: free + inventory == total (成功入库的 envelope 占着 slot)
assert t_slot._value + inv.t_depth == 4, f'T_Slot 守恒违反: free={t_slot._value} inv={inv.t_depth}'
# ═══════════════════════════════════════════
# P_Worker 取消测试
# ═══════════════════════════════════════════
async def _p_worker_impl(email_svc, inventory, physical_sem, q_pending_sem,
q_slot_sem, metrics, stop):
"""P_Worker 简化实现,用于测试。"""
loop = asyncio.get_event_loop()
while not stop.is_set():
await q_pending_sem.acquire()
_pending_released = False
try:
await physical_sem.acquire()
try:
handle, email, password = await email_svc.create_email()
sent = True # fake 总是成功
finally:
physical_sem.release()
if not sent:
q_pending_sem.release()
_pending_released = True
continue
metrics.q_sent += 1
try:
code = await asyncio.wait_for(
email_svc.poll_code(handle), timeout=95
)
except asyncio.TimeoutError:
code = None
if code is None:
metrics.q_discarded += 1
q_pending_sem.release()
_pending_released = True
continue
metrics.q_returned += 1
now = time.time()
q_env = await ResourceEnvelope.create_with_slot(
'Q', {'email': email, 'password': password, 'code': code},
q_slot_sem, expires_at=now + 120,
)
try:
await inventory.put_q(q_env)
except Exception:
q_env.discard(reason='put_failed')
metrics.q_discarded += 1
except asyncio.CancelledError:
if not _pending_released:
q_pending_sem.release()
_pending_released = True
raise
except Exception:
metrics.q_discarded += 1
finally:
if not _pending_released:
q_pending_sem.release()
await asyncio.sleep(0.02)
class TestPCancel:
"""P_Worker 取消语义测试。"""
@pytest.mark.asyncio
async def test_cancel_during_create_email(self):
"""P 在 create_email 期间被取消,所有 sem 正确释放。"""
es = FakeEmailService()
es.set_cancel_create_at(1)
inv = Inventory()
phys = asyncio.Semaphore(2)
q_pend = asyncio.Semaphore(4)
q_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
task = asyncio.create_task(
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
)
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
assert q_slot._value == 4
@pytest.mark.asyncio
async def test_cancel_during_poll_code(self):
"""P 在 poll_code 期间被取消,不持有 Physical_Sem。"""
es = FakeEmailService()
es.set_cancel_poll_at(1)
inv = Inventory()
phys = asyncio.Semaphore(2)
q_pend = asyncio.Semaphore(4)
q_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
task = asyncio.create_task(
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
)
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# P 等 Q 时不持有 Physical_Sem
assert phys._value == 2, f'Physical_Sem 应完全释放: {phys._value}'
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
@pytest.mark.asyncio
async def test_p_repeated_cancel_no_accumulation(self):
"""反复取消 P 多次,sem 计数不累积。"""
es = FakeEmailService()
inv = Inventory()
phys = asyncio.Semaphore(2)
q_pend = asyncio.Semaphore(4)
q_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
for _ in range(20):
task = asyncio.create_task(
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
)
await asyncio.sleep(0.03)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
assert q_pend._value == 4, f'Q_Pending 泄漏: {q_pend._value}'
# 守恒: free + inventory == total
assert q_slot._value + inv.q_depth == 4, f'Q_Slot 守恒违反: free={q_slot._value} inv={inv.q_depth}'
# ═══════════════════════════════════════════
# C_Worker 取消测试
# ═══════════════════════════════════════════
async def _c_worker_impl(register_api, browser_unused, inventory, physical_sem,
metrics, stop, file_lock):
"""C_Worker 简化实现,用于测试。"""
while not stop.is_set():
try:
async with inventory.claim_pair() as pair:
t_val = pair.t.value
q_val = pair.q.value
await physical_sem.acquire()
try:
sso = await register_api.register(
q_val['email'], q_val['password'], q_val['code'], t_val
)
if sso:
metrics.pair_consumed_ok += 1
metrics.success_count += 1
else:
metrics.pair_consumed_fail += 1
finally:
physical_sem.release()
except asyncio.CancelledError:
raise
except Exception:
metrics.pair_consumed_fail += 1
await asyncio.sleep(0.02)
class TestCCancel:
"""C_Worker 取消语义测试。"""
@pytest.mark.asyncio
async def test_cancel_during_register_pair_settled(self):
"""C 在 register 期间被取消,pair 已 claim,必须核销。"""
fra = FakeRegisterAPI(delay=0.5)
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(4)
q_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
fl = asyncio.Lock()
# 用带 metrics 的 inventory
inv_with_metrics = Inventory(metrics=Metrics())
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_slot)
q_env = await ResourceEnvelope.create_with_slot('Q', {'email': 'a@b.com', 'password': 'p', 'code': '123'}, q_slot)
await inv_with_metrics.put_t(t_env)
await inv_with_metrics.put_q(q_env)
task = asyncio.create_task(
_c_worker_impl(fra, None, inv_with_metrics, phys, metrics, stop, fl)
)
await asyncio.sleep(0.1) # 等它 claim 进入 register
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# pair 已核销: slot 应释放
assert t_slot._value == 4, f'T slot 未核销: {t_slot._value}'
assert q_slot._value == 4, f'Q slot 未核销: {q_slot._value}'
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
@pytest.mark.asyncio
async def test_cancel_during_claim_wait_no_pop(self):
"""C 在 claim_pair 等待时取消,不弹出资源。"""
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(4)
q_slot = asyncio.Semaphore(4)
metrics = Metrics()
stop = asyncio.Event()
fl = asyncio.Lock()
fra = FakeRegisterAPI()
# 放一个 T 但不放 Q,让 C 等待
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_slot)
await inv.put_t(t_env)
task = asyncio.create_task(
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
)
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# T 仍在 inventory 中
assert inv.t_depth == 1, 'claim 等待时取消不应弹出 T'
assert phys._value == 2
@pytest.mark.asyncio
async def test_c_repeated_cancel_no_accumulation(self):
"""反复取消 C 多次,sem 计数不累积。"""
fra = FakeRegisterAPI(delay=0.01)
inv = Inventory()
phys = asyncio.Semaphore(2)
t_slot = asyncio.Semaphore(10)
q_slot = asyncio.Semaphore(10)
metrics = Metrics()
stop = asyncio.Event()
fl = asyncio.Lock()
for _ in range(10):
# 每次放一对
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{_}', t_slot)
q_env = await ResourceEnvelope.create_with_slot('Q', {'email': f'e@{_}', 'password': 'p', 'code': '123'}, q_slot)
await inv.put_t(t_env)
await inv.put_q(q_env)
task = asyncio.create_task(
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
)
await asyncio.sleep(0.03)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert phys._value == 2, f'Physical_Sem 泄漏: {phys._value}'
# 所有 slot 应已核销
assert t_slot._value == 10, f'T_Slot 泄漏: {t_slot._value}'
assert q_slot._value == 10, f'Q_Slot 泄漏: {q_slot._value}'
# ═══════════════════════════════════════════
# 端到端取消: S+P+C 同时运行时取消
# ═══════════════════════════════════════════
class TestE2ECancel:
"""端到端取消测试: S/P/C 同时运行时取消,验证全局守恒。"""
@pytest.mark.asyncio
async def test_cancel_all_workers_sem_reset(self):
"""同时启动 S/P/C,然后全部取消,所有 sem 恢复初始值。"""
ft = FakeTurnstile(delay=0.05)
es = FakeEmailService()
fra = FakeRegisterAPI(delay=0.05)
inv = Inventory()
phys = asyncio.Semaphore(4)
t_slot = asyncio.Semaphore(8)
q_slot = asyncio.Semaphore(8)
q_pend = asyncio.Semaphore(12)
metrics = Metrics()
stop = asyncio.Event()
fl = asyncio.Lock()
tasks = []
for _ in range(3):
tasks.append(asyncio.create_task(
_s_worker_impl(ft, inv, phys, t_slot, metrics, stop)
))
tasks.append(asyncio.create_task(
_p_worker_impl(es, inv, phys, q_pend, q_slot, metrics, stop)
))
tasks.append(asyncio.create_task(
_c_worker_impl(fra, None, inv, phys, metrics, stop, fl)
))
await asyncio.sleep(0.2)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
# 全部停止后,守恒检查: free + inventory == total
assert phys._value >= 0 and phys._value <= 4
assert t_slot._value + inv.t_depth == 8, f'T 守恒违反: free={t_slot._value} inv={inv.t_depth}'
assert q_slot._value + inv.q_depth == 8, f'Q 守恒违反: free={q_slot._value} inv={inv.q_depth}'
assert q_pend._value >= 0 and q_pend._value <= 12
# 守恒检查
cons = Conservation(t_slot, q_slot, q_pend, inv)
cons.check_t_conservation(8)
cons.check_q_conservation(8)
cons.check_pending_conservation(12)

491
tests/test_core.py Normal file
View File

@@ -0,0 +1,491 @@
"""
Layer 1: 单元测试
测 ResourceEnvelope、PairLease、Inventory 的局部语义:
- release_slot_once() 幂等
- create_with_slot() slot 获取失败不创建 envelope
- is_expired() 正确判断
- put_t/put_q 成功前所有权属于 Worker,成功后属于 Inventory
- claim_pair 等待时取消不弹资源
- claim_pair 成功后 pair 属于 PairLease
- PairLease 退出时核销 T/Q
- 过期资源不会交付给 C
- lazy cleanup 只在事件触发时发生
- discard() 幂等释放
"""
import asyncio
import time
import pytest
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory, PairLease
# ═══════════════════════════════════════════
# ResourceEnvelope 测试
# ═══════════════════════════════════════════
class TestResourceEnvelope:
"""ResourceEnvelope 语义测试。"""
@pytest.mark.asyncio
async def test_create_with_slot_acquires_sem(self):
"""create_with_slot 成功后 semaphore 值减 1。"""
sem = asyncio.Semaphore(3)
env = await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
assert sem._value == 2
assert env.value == 'tok1'
assert env.kind == 'T'
assert not env.released
@pytest.mark.asyncio
async def test_create_with_slot_fails_no_envelope(self):
"""slot 获取失败(取消)时不创建 envelope,sem 不变。"""
sem = asyncio.Semaphore(0)
with pytest.raises(asyncio.CancelledError):
# 手动取消
task = asyncio.current_task()
task.cancel()
try:
await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
except asyncio.CancelledError:
# sem 不应改变(因为 acquire 被取消了)
raise
# sem 仍为 0
assert sem._value == 0
@pytest.mark.asyncio
async def test_release_slot_once_idempotent(self):
"""release_slot_once() 幂等:重复调用只释放一次。"""
sem = asyncio.Semaphore(3)
env = await ResourceEnvelope.create_with_slot('T', 'tok1', sem)
assert sem._value == 2
env.release_slot_once(reason='test1')
assert sem._value == 3
assert env.released
# 第二次调用不应再次释放
env.release_slot_once(reason='test2')
assert sem._value == 3
@pytest.mark.asyncio
async def test_discard_releases_slot(self):
"""discard() 释放 slot。"""
sem = asyncio.Semaphore(2)
env = await ResourceEnvelope.create_with_slot('Q', {'code': '123'}, sem)
assert sem._value == 1
env.discard(reason='test')
assert sem._value == 2
assert env.released
@pytest.mark.asyncio
async def test_discard_idempotent(self):
"""discard() 幂等。"""
sem = asyncio.Semaphore(2)
env = await ResourceEnvelope.create_with_slot('Q', 'val', sem)
env.discard(reason='first')
env.discard(reason='second')
assert sem._value == 2
@pytest.mark.asyncio
async def test_is_expired_with_expires_at(self):
"""有 expires_at 时正确判断过期。"""
sem = asyncio.Semaphore(2)
now = time.time()
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem, expires_at=now - 1)
assert env.is_expired(now=now)
env2 = await ResourceEnvelope.create_with_slot('T', 'tok2', sem, expires_at=now + 100)
assert not env2.is_expired(now=now)
@pytest.mark.asyncio
async def test_is_expired_without_expires_at(self):
"""没有 expires_at 时永远不过期。"""
sem = asyncio.Semaphore(2)
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem)
assert not env.is_expired(now=time.time() + 99999)
@pytest.mark.asyncio
async def test_create_with_slot_meta(self):
"""meta 字段正确传递。"""
sem = asyncio.Semaphore(1)
env = await ResourceEnvelope.create_with_slot('T', 'tok', sem, meta={'source': 'test'})
assert env.meta == {'source': 'test'}
# ═══════════════════════════════════════════
# Inventory 测试
# ═══════════════════════════════════════════
class TestInventory:
"""Inventory 语义测试。"""
@pytest.mark.asyncio
async def test_put_t_then_claim(self):
"""put_t 后 claim_pair 能拿到 pair。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'token1', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'abc'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
assert inv.t_depth == 1
assert inv.q_depth == 1
# claim
async with inv.claim_pair() as pair:
assert pair.t.value == 'token1'
assert pair.q.value == {'code': 'abc'}
# claim 后 inventory 应为空
assert inv.t_depth == 0
assert inv.q_depth == 0
# PairLease 退出后 slot 应被释放
assert t_sem._value == 2
assert q_sem._value == 2
@pytest.mark.asyncio
async def test_claim_waits_for_both(self):
"""只有 T 没有 Q 时,claim_pair 应等待。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
await inv.put_t(t_env)
claimed = False
async def claimer():
nonlocal claimed
async with inv.claim_pair() as pair:
claimed = True
task = asyncio.create_task(claimer())
await asyncio.sleep(0.1)
assert not claimed, 'claim 不应在缺少 Q 时成功'
# 现在放入 Q
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
await inv.put_q(q_env)
await asyncio.wait_for(task, timeout=2)
assert claimed
@pytest.mark.asyncio
async def test_claim_cancel_no_pop(self):
"""claim_pair 等待时取消,不应弹出任何资源。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
await inv.put_t(t_env)
assert inv.t_depth == 1
async def claimer():
async with inv.claim_pair() as pair:
pass # 不应到达
task = asyncio.create_task(claimer())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# T 仍在 inventory 中
assert inv.t_depth == 1
@pytest.mark.asyncio
async def test_pair_lease_settles_on_success(self):
"""PairLease 成功退出时核销 T/Q 并释放 slot。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
async with inv.claim_pair() as pair:
# slot 还在 pair 中
assert t_sem._value == 1
assert q_sem._value == 1
# 退出后 slot 释放
assert t_sem._value == 2
assert q_sem._value == 2
@pytest.mark.asyncio
async def test_pair_lease_settles_on_exception(self):
"""PairLease 异常退出时也核销 T/Q。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
with pytest.raises(ValueError):
async with inv.claim_pair() as pair:
raise ValueError('boom')
# slot 仍被核销
assert t_sem._value == 2
assert q_sem._value == 2
@pytest.mark.asyncio
async def test_pair_lease_settles_on_cancel(self):
"""PairLease 取消退出时也核销 T/Q。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
async def consumer():
async with inv.claim_pair() as pair:
# 在 pair 内部被取消
await asyncio.sleep(999)
task = asyncio.create_task(consumer())
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# slot 仍被核销
assert t_sem._value == 2
assert q_sem._value == 2
@pytest.mark.asyncio
async def test_expired_t_not_delivered(self):
"""过期的 T 不会交付给 C。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
now = time.time()
# T 已过期
t_env = await ResourceEnvelope.create_with_slot('T', 'expired_tok', t_sem, expires_at=now - 10)
# Q 有效
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now + 100)
await inv.put_t(t_env)
await inv.put_q(q_env)
# claim 应该把过期的 T 清理掉,然后等待新的 T
claimed = False
async def claimer():
nonlocal claimed
async with inv.claim_pair() as pair:
claimed = True
task = asyncio.create_task(claimer())
await asyncio.sleep(0.2)
assert not claimed, '过期 T 不应交付'
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 过期 T 被清理,slot 被释放
assert t_sem._value == 2
# Q 仍在 inventory 中
assert inv.q_depth == 1
@pytest.mark.asyncio
async def test_expired_q_not_delivered(self):
"""过期的 Q 不会交付给 C。"""
inv = Inventory()
t_sem = asyncio.Semaphore(2)
q_sem = asyncio.Semaphore(2)
now = time.time()
t_env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem, expires_at=now + 100)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now - 10)
await inv.put_t(t_env)
await inv.put_q(q_env)
claimed = False
async def claimer():
nonlocal claimed
async with inv.claim_pair() as pair:
claimed = True
task = asyncio.create_task(claimer())
await asyncio.sleep(0.2)
assert not claimed, '过期 Q 不应交付'
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert q_sem._value == 2
assert inv.t_depth == 1
@pytest.mark.asyncio
async def test_lazy_cleanup_on_put(self):
"""lazy cleanup: put 时清理已过期的库存。"""
inv = Inventory()
t_sem = asyncio.Semaphore(4)
now = time.time()
# 放入 3 个过期的 T
for i in range(3):
env = await ResourceEnvelope.create_with_slot(
'T', f'expired_{i}', t_sem, expires_at=now - 10
)
await inv.put_t(env)
# 由于 put 时 lazy cleanup 已清理过期项,库存应为空
assert inv.t_depth == 0
# slot 应已释放
assert t_sem._value == 4
@pytest.mark.asyncio
async def test_lazy_cleanup_silent_until_next_event(self):
"""lazy cleanup 不在静默期主动释放,下一次 put 才触发清理。"""
inv = Inventory()
t_sem = asyncio.Semaphore(4)
env = await ResourceEnvelope.create_with_slot(
'T', 'will_expire', t_sem, expires_at=time.time() + 0.05
)
await inv.put_t(env)
await asyncio.sleep(0.1)
assert inv.t_depth == 1
assert t_sem._value == 3
fresh = await ResourceEnvelope.create_with_slot(
'T', 'fresh', t_sem, expires_at=time.time() + 100
)
await inv.put_t(fresh)
assert inv.t_depth == 1
assert t_sem._value == 3
@pytest.mark.asyncio
async def test_lazy_cleanup_on_claim(self):
"""lazy cleanup: claim_pair 时清理过期库存。"""
inv = Inventory()
t_sem = asyncio.Semaphore(4)
q_sem = asyncio.Semaphore(4)
now = time.time()
# 直接注入过期项到 inventory 内部(绕过 put 的清理)
env = await ResourceEnvelope.create_with_slot('T', 'expired', t_sem, expires_at=now - 10)
inv._t_buf.append(env)
# 有效的 Q
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': 'x'}, q_sem, expires_at=now + 100)
await inv.put_q(q_env)
# claim 时应清理过期的 T
claimed = False
async def claimer():
nonlocal claimed
async with inv.claim_pair() as pair:
claimed = True
task = asyncio.create_task(claimer())
await asyncio.sleep(0.2)
assert not claimed # T 被清了,没有 pair
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert inv.t_depth == 0
assert t_sem._value == 4 # slot 已释放
@pytest.mark.asyncio
async def test_fifo_order(self):
"""Inventory 按 FIFO 顺序配对。"""
inv = Inventory()
t_sem = asyncio.Semaphore(10)
q_sem = asyncio.Semaphore(10)
for i in range(3):
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{i}', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': f'c_{i}'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
pairs = []
for _ in range(3):
async with inv.claim_pair() as pair:
pairs.append((pair.t.value, pair.q.value['code']))
assert pairs == [('tok_0', 'c_0'), ('tok_1', 'c_1'), ('tok_2', 'c_2')]
@pytest.mark.asyncio
async def test_multiple_concurrent_claims(self):
"""多个 C 同时 claim,不应重复消费同一个 T/Q。"""
inv = Inventory()
t_sem = asyncio.Semaphore(10)
q_sem = asyncio.Semaphore(10)
n = 5
for i in range(n):
t_env = await ResourceEnvelope.create_with_slot('T', f'tok_{i}', t_sem)
q_env = await ResourceEnvelope.create_with_slot('Q', {'code': f'c_{i}'}, q_sem)
await inv.put_t(t_env)
await inv.put_q(q_env)
results = []
async def claimer(idx):
async with inv.claim_pair() as pair:
results.append((idx, pair.t.value))
tasks = [asyncio.create_task(claimer(i)) for i in range(n)]
await asyncio.gather(*tasks)
# 每个 T 只被消费一次
t_values = [r[1] for r in results]
assert len(set(t_values)) == n, f'重复消费: {t_values}'
@pytest.mark.asyncio
async def test_metrics_admitted(self):
"""put 成功时 metrics.t_admitted / q_admitted 递增。"""
from grok_register.core.observer import Metrics
m = Metrics()
inv = Inventory(metrics=m)
t_sem = asyncio.Semaphore(2)
env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem)
await inv.put_t(env)
assert m.t_admitted == 1
@pytest.mark.asyncio
async def test_metrics_expired(self):
"""过期资源 put 时 metrics.t_expired 递增。"""
from grok_register.core.observer import Metrics
m = Metrics()
inv = Inventory(metrics=m)
t_sem = asyncio.Semaphore(2)
env = await ResourceEnvelope.create_with_slot('T', 'tok', t_sem, expires_at=time.time() - 10)
await inv.put_t(env)
assert m.t_expired == 1
assert m.t_admitted == 0

View File

@@ -0,0 +1,58 @@
import asyncio
import time
import unittest
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
class InventoryUnitTests(unittest.IsolatedAsyncioTestCase):
async def test_create_with_slot_releases_slot_if_constructor_fails(self):
class BrokenEnvelope(ResourceEnvelope):
def __init__(self, *args, **kwargs):
raise RuntimeError("boom")
sem = asyncio.Semaphore(1)
with self.assertRaises(RuntimeError):
await BrokenEnvelope.create_with_slot("T", "tok", sem)
self.assertEqual(sem._value, 1)
async def test_lazy_cleanup_scans_past_fresh_head(self):
metrics = Metrics()
inventory = Inventory(metrics=metrics)
t_sem = asyncio.Semaphore(3)
q_sem = asyncio.Semaphore(1)
now = time.time()
fresh_head = await ResourceEnvelope.create_with_slot(
"T", "fresh_head", t_sem, expires_at=now + 100
)
stale_tail = await ResourceEnvelope.create_with_slot(
"T", "stale_tail", t_sem, expires_at=now + 100
)
fresh_tail = await ResourceEnvelope.create_with_slot(
"T", "fresh_tail", t_sem, expires_at=now + 100
)
await inventory.put_t(fresh_head)
await inventory.put_t(stale_tail)
await inventory.put_t(fresh_tail)
self.assertEqual(t_sem._value, 0)
stale_tail.expires_at = time.time() - 10
q_env = await ResourceEnvelope.create_with_slot(
"Q", "q", q_sem, expires_at=now + 100
)
await inventory.put_q(q_env)
self.assertEqual(t_sem._value, 1)
self.assertEqual(metrics.t_expired, 1)
self.assertEqual(inventory.t_depth, 2)
if __name__ == "__main__":
unittest.main()

482
tests/test_property.py Normal file
View File

@@ -0,0 +1,482 @@
"""
Layer 3: 性质测试 (Property Tests)
随机生成 S/P/C 成功、失败、取消、超时、过期事件,持续检查:
- slot 守恒 (T/Q 总量恒定)
- 无重复 claim (同一个 T/Q 不被两个 PairLease 拿到)
- 无单边持有等待 (C 不先拿 T 再等 Q)
- 过期资源不交付
- 取消后不泄漏
"""
import asyncio
import time
import random
import pytest
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI, Conservation, EventLog
# ═══════════════════════════════════════════
# 随机性质测试引擎
# ═══════════════════════════════════════════
class PropertyEngine:
"""驱动 S/P/C 随机行为,持续检查不变量。"""
def __init__(self, t_slot_cap=6, q_slot_cap=6, q_pending_cap=8, phys_cap=4,
t_max_age=2.0, q_max_age=2.0, seed=None):
self.rng = random.Random(seed)
self.t_slot_sem = asyncio.Semaphore(t_slot_cap)
self.q_slot_sem = asyncio.Semaphore(q_slot_cap)
self.q_pending_sem = asyncio.Semaphore(q_pending_cap)
self.phys_sem = asyncio.Semaphore(phys_cap)
self.t_slot_cap = t_slot_cap
self.q_slot_cap = q_slot_cap
self.q_pending_cap = q_pending_cap
self.t_max_age = t_max_age
self.q_max_age = q_max_age
self.metrics = Metrics()
self.inventory = Inventory(metrics=self.metrics)
self.conservation = Conservation(
self.t_slot_sem, self.q_slot_sem, self.q_pending_sem, self.inventory
)
self.events = EventLog()
self._claimed_tokens = set() # 检测重复 claim
self._pairleased_t = 0 # 当前 PairLease 持有的 T
self._pairleased_q = 0 # 当前 PairLease 持有的 Q
def check_invariants_soft(self):
"""运行时软检查: 非负、不超限。
注意: Worker 运行期间 slot 可能在 create_with_slot 和 put_t 之间的
envelope 中(worker 持有但无法从外部追踪)。严格守恒检查用
check_invariants_final() 在所有 Worker 停止后执行。
"""
assert self.t_slot_sem._value >= 0, 'T slot 为负'
assert self.q_slot_sem._value >= 0, 'Q slot 为负'
assert self.q_pending_sem._value >= 0, 'Q pending 为负'
assert self.q_pending_sem._value <= self.q_pending_cap, 'Q pending 超限'
assert self.phys_sem._value >= 0, 'Physical 为负'
# inventory 深度不超过 slot 总量
assert self.inventory.t_depth <= self.t_slot_cap, 'T inventory 超限'
assert self.inventory.q_depth <= self.q_slot_cap, 'Q inventory 超限'
def check_invariants_final(self):
"""最终严格守恒检查: 所有 Worker 停止后执行。
此时没有 Worker 持有中间状态的 slot,也没有 PairLease。
free + inventory == total
"""
self.conservation.check_t_conservation(self.t_slot_cap)
self.conservation.check_q_conservation(self.q_slot_cap)
self.conservation.check_pending_conservation(self.q_pending_cap)
async def s_produce(self):
"""S 产生一个 T。返回 True/False/None(cancelled)。"""
# 模拟物理资源使用
await self.phys_sem.acquire()
delay = self.rng.uniform(0.001, 0.02)
try:
await asyncio.sleep(delay)
finally:
self.phys_sem.release()
# 模拟随机失败
if self.rng.random() < 0.1:
self.metrics.t_discarded += 1
await self.events.record('t_discarded', 'gen_fail')
return False
token = f'tok_{self.metrics.t_produced}'
self.metrics.t_produced += 1
await self.events.record('t_produced', token)
# 创建 envelope + 入库
# 关键: create_with_slot 成功后 slot 在 envelope 中,
# 必须追踪 worker_held 直到 put_t 成功或 discard。
now = time.time()
expire = now + self.t_max_age if self.rng.random() > 0.15 else now - 1
t_env = None
try:
t_env = await ResourceEnvelope.create_with_slot(
'T', token, self.t_slot_sem, expires_at=expire
)
# slot 已获取,标记 worker 持有
self.conservation.worker_acquire_t()
await self.inventory.put_t(t_env)
# put 成功,所有权转移到 inventory
self.conservation.worker_release_t()
except Exception:
if t_env is not None and not t_env.released:
t_env.discard(reason='error_cleanup')
self.conservation.worker_release_t()
self.metrics.t_discarded += 1
return False
except asyncio.CancelledError:
if t_env is not None and not t_env.released:
t_env.discard(reason='cancel_cleanup')
self.conservation.worker_release_t()
self.metrics.t_discarded += 1
raise
self.check_invariants_soft()
return True
async def p_produce(self):
"""P 产生一个 Q。返回 True/False/None(cancelled)。"""
await self.q_pending_sem.acquire()
_pend_released = False
try:
# 创建邮箱 + 发请求(持有 Physical)
await self.phys_sem.acquire()
try:
delay = self.rng.uniform(0.001, 0.02)
await asyncio.sleep(delay)
finally:
self.phys_sem.release()
# 模拟随机失败
if self.rng.random() < 0.1:
self.metrics.q_discarded += 1
self.q_pending_sem.release()
_pend_released = True
await self.events.record('q_discarded', 'send_fail')
return False
self.metrics.q_sent += 1
# 等待 Q 返回(不持有 Physical)
poll_delay = self.rng.uniform(0.001, 0.05)
await asyncio.sleep(poll_delay)
# 模拟超时/失败
if self.rng.random() < 0.1:
self.metrics.q_discarded += 1
self.q_pending_sem.release()
_pend_released = True
await self.events.record('q_discarded', 'poll_timeout')
return False
code = f'{self.rng.randint(100000, 999999)}'
self.metrics.q_returned += 1
await self.events.record('q_returned', code)
# 创建 envelope + 入库
now = time.time()
expire = now + self.q_max_age if self.rng.random() > 0.15 else now - 1
q_env = None
try:
q_env = await ResourceEnvelope.create_with_slot(
'Q', {'email': f'e@{code}', 'password': 'p', 'code': code},
self.q_slot_sem, expires_at=expire,
)
self.conservation.worker_acquire_q()
await self.inventory.put_q(q_env)
self.conservation.worker_release_q()
except Exception:
if q_env is not None and not q_env.released:
q_env.discard(reason='error_cleanup')
self.conservation.worker_release_q()
self.metrics.q_discarded += 1
except asyncio.CancelledError:
if q_env is not None and not q_env.released:
q_env.discard(reason='cancel_cleanup')
self.conservation.worker_release_q()
self.metrics.q_discarded += 1
raise
except asyncio.CancelledError:
if not _pend_released:
self.q_pending_sem.release()
_pend_released = True
raise
except Exception:
self.metrics.q_discarded += 1
finally:
if not _pend_released:
self.q_pending_sem.release()
self.check_invariants_soft()
return True
async def c_consume(self):
"""C 消费一个 pair。返回 True/False/None(cancelled)。"""
try:
async with self.inventory.claim_pair() as pair:
t_val = pair.t.value
q_val = pair.q.value
# 检测重复 claim
assert t_val not in self._claimed_tokens, f'重复 claim: {t_val}'
self._claimed_tokens.add(t_val)
# pairleased 计数 — 必须在 claim 后立即标记,
# 否则在下一个 await 前被取消会导致守恒违反
self._pairleased_t += 1
self._pairleased_q += 1
# 消费(持有 Physical)
await self.phys_sem.acquire()
try:
delay = self.rng.uniform(0.001, 0.02)
await asyncio.sleep(delay)
# 模拟随机失败
if self.rng.random() < 0.15:
self.metrics.pair_consumed_fail += 1
await self.events.record('pair_fail', t_val)
else:
self.metrics.pair_consumed_ok += 1
self.metrics.success_count += 1
await self.events.record('pair_ok', t_val)
finally:
self.phys_sem.release()
# PairLease 退出后(正常路径)
self._pairleased_t -= 1
self._pairleased_q -= 1
except asyncio.CancelledError:
# PairLease.__aexit__ 已核销 slot。
# 如果 _pairleased 已 +1,则 -1;否则不操作。
if self._pairleased_t > 0:
self._pairleased_t -= 1
if self._pairleased_q > 0:
self._pairleased_q -= 1
raise
except Exception:
self.metrics.pair_consumed_fail += 1
if self._pairleased_t > 0:
self._pairleased_t -= 1
if self._pairleased_q > 0:
self._pairleased_q -= 1
self.check_invariants_soft()
return True
@pytest.mark.slow
class TestProperty:
"""性质测试: 随机事件流 + 持续不变量检查。"""
@pytest.mark.asyncio
async def test_random_events_10s(self):
"""随机 S/P/C 事件流 10 秒,持续检查不变量。"""
engine = PropertyEngine(seed=42)
stop = asyncio.Event()
errors = []
async def s_loop():
while not stop.is_set():
try:
await engine.s_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'S: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
async def p_loop():
while not stop.is_set():
try:
await engine.p_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'P: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
async def c_loop():
while not stop.is_set():
try:
await engine.c_consume()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'C: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
tasks = []
for _ in range(2):
tasks.append(asyncio.create_task(s_loop()))
tasks.append(asyncio.create_task(p_loop()))
tasks.append(asyncio.create_task(c_loop()))
await asyncio.sleep(10)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
# 最终不变量检查
engine.check_invariants_final()
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
print(f'\n[Property] t_produced={engine.metrics.t_produced} '
f'q_sent={engine.metrics.q_sent} q_returned={engine.metrics.q_returned} '
f'pair_ok={engine.metrics.pair_consumed_ok} pair_fail={engine.metrics.pair_consumed_fail} '
f'success={engine.metrics.success_count}')
@pytest.mark.asyncio
async def test_random_with_cancellations(self):
"""随机事件 + 随机取消 Worker,不变量仍成立。"""
engine = PropertyEngine(seed=123)
stop = asyncio.Event()
errors = []
async def s_loop():
while not stop.is_set():
try:
await engine.s_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'S: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
async def p_loop():
while not stop.is_set():
try:
await engine.p_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'P: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
async def c_loop():
while not stop.is_set():
try:
await engine.c_consume()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'C: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.005, 0.03))
async def cancel_loop():
"""随机取消并重启 Worker。"""
while not stop.is_set():
await asyncio.sleep(engine.rng.uniform(0.5, 2.0))
if tasks:
idx = engine.rng.randint(0, len(tasks) - 1)
t = tasks[idx]
if not t.done():
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
# 重启
role = idx % 3
if role == 0:
tasks[idx] = asyncio.create_task(s_loop())
elif role == 1:
tasks[idx] = asyncio.create_task(p_loop())
else:
tasks[idx] = asyncio.create_task(c_loop())
tasks = []
for _ in range(2):
tasks.append(asyncio.create_task(s_loop()))
tasks.append(asyncio.create_task(p_loop()))
tasks.append(asyncio.create_task(c_loop()))
tasks.append(asyncio.create_task(cancel_loop()))
await asyncio.sleep(10)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
engine.check_invariants_final()
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
print(f'\n[Property+Cancel] t_produced={engine.metrics.t_produced} '
f'pair_ok={engine.metrics.pair_consumed_ok} '
f'success={engine.metrics.success_count}')
@pytest.mark.asyncio
async def test_extreme_ttl_pressure(self):
"""极端 TTL: 所有资源几乎立即过期,验证过期清理不破坏守恒。"""
engine = PropertyEngine(
t_slot_cap=4, q_slot_cap=4, q_pending_cap=6, phys_cap=3,
t_max_age=0.05, q_max_age=0.05, # 50ms 过期
seed=789,
)
stop = asyncio.Event()
errors = []
async def s_loop():
while not stop.is_set():
try:
await engine.s_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'S: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
async def p_loop():
while not stop.is_set():
try:
await engine.p_produce()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'P: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
async def c_loop():
while not stop.is_set():
try:
await engine.c_consume()
except asyncio.CancelledError:
break
except AssertionError as e:
errors.append(f'C: {e}')
break
await asyncio.sleep(engine.rng.uniform(0.01, 0.05))
tasks = []
for _ in range(2):
tasks.append(asyncio.create_task(s_loop()))
tasks.append(asyncio.create_task(p_loop()))
tasks.append(asyncio.create_task(c_loop()))
await asyncio.sleep(5)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
engine.check_invariants_final()
assert not errors, f'不变量违反:\n' + '\n'.join(errors)
print(f'\n[TTL Pressure] t_produced={engine.metrics.t_produced} '
f't_expired={engine.metrics.t_expired} q_expired={engine.metrics.q_expired} '
f'pair_ok={engine.metrics.pair_consumed_ok}')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
import unittest
from tools.runtime_log_analyzer import (
parse_monitor_lines,
parse_solver_timelines,
summarize_monitor_rows,
summarize_solver_timelines,
)
class RuntimeLogAnalyzerTests(unittest.TestCase):
def test_parses_csp_monitor_rows_and_recent_rates(self):
text = "\n".join(
[
"[*] T:0 Q:3 phys:0 t_slot:3 q_slot:0 q_pend:0 "
"t_prod:76 t_adm:76 t_exp:0 q_sent:87 q_ret:87 q_adm:79 q_exp:0 "
"pair:76 ok:71 fail:0 rate:8.6/min #71",
"[*] T:0 Q:3 phys:0 t_slot:3 q_slot:0 q_pend:0 "
"t_prod:91 t_adm:91 t_exp:0 q_sent:101 q_ret:101 q_adm:94 q_exp:0 "
"pair:91 ok:86 fail:0 rate:8.8/min #86",
]
)
rows = parse_monitor_lines(text)
summary = summarize_monitor_rows(rows)
self.assertEqual(rows[0].kind, "csp")
self.assertEqual(summary["last_ok"], 86)
self.assertAlmostEqual(summary["last_cumulative_rate"], 8.8)
self.assertAlmostEqual(summary["recent_ok_per_min"], 9.89, places=2)
self.assertAlmostEqual(summary["recent_t_prod_per_min"], 9.89, places=2)
self.assertEqual(summary["last_q_minus_t"], 3)
self.assertEqual(summary["last_q_return_minus_t_prod"], 10)
def test_parses_state_machine_monitor_rows(self):
text = "\n".join(
[
"[*] slots:7/8 act:7 cpu:83% avg:76%/85 mem:4841M "
"T:4 Q:0 sent:0 got:0(0%) rate:0.0/min #0",
"[*] slots:8/8 act:8 cpu:91% avg:88%/85 mem:5012M "
"T:6 Q:5 sent:61 got:48(79%) rate:7.8/min #40",
"[*] slots:6/8 act:6 cpu:100% avg:90%/85 mem:5351M "
"T:1 Q:6 sent:68 got:68(100%) rate:8.8/min #59",
]
)
rows = parse_monitor_lines(text)
summary = summarize_monitor_rows(rows)
self.assertEqual(rows[-1].kind, "state_machine")
self.assertEqual(summary["last_ok"], 59)
self.assertAlmostEqual(summary["last_cumulative_rate"], 8.8)
self.assertEqual(summary["last_q_minus_t"], 5)
self.assertEqual(summary["last_slots"], 6)
def test_parses_csp_v2_monitor_rows_with_admission_fields(self):
text = (
"[*] T:1 Q:2 phys:3 p_send:1 t_slot:4 q_slot:5 q_pend:6 "
"p_batch:3.5 t_prog:1 q_inflight:4 "
"s_phys:0.50/3.00 p_phys:0.20/1.40 c_phys:0.30/2.50 "
"p_stage:0.50/0.80/0.40 c_stage:0.30/0.40/1.50 c_hot:4/1 "
"solver_goto:0.10 solver_inject:0.20 solver_initial:0.50 "
"solver_click:0.01 solver_wait:11.80 solver_reuse:0.75 solver_visible:0.00 "
"t_prod:20 t_adm:18 t_exp:1 q_sent:24 q_ret:22 q_adm:20 q_exp:0 "
"pair:17 ok:16 fail:1 rate:9.1/min #16"
)
rows = parse_monitor_lines(text)
summary = summarize_monitor_rows(rows)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].kind, "csp")
self.assertEqual(summary["last_ok"], 16)
self.assertEqual(summary["last_q_return_minus_t_prod"], 2)
self.assertEqual(summary["last_solver_wait"], 11.8)
self.assertEqual(summary["last_solver_reuse"], 0.75)
self.assertEqual(summary["last_phys"], 3)
self.assertEqual(summary["last_p_batch"], 3.5)
self.assertEqual(summary["last_t_prog"], 1)
self.assertEqual(summary["last_q_inflight"], 4)
self.assertEqual(summary["last_s_phys_hold"], 3.0)
self.assertEqual(summary["last_p_phys_wait"], 0.2)
self.assertEqual(summary["last_c_phys_hold"], 2.5)
self.assertEqual(summary["last_physical_hold_leader"], "s")
self.assertEqual(summary["last_physical_wait_leader"], "s")
self.assertEqual(summary["last_p_email_create"], 0.5)
self.assertEqual(summary["last_p_page_prepare"], 0.8)
self.assertEqual(summary["last_p_send"], 0.4)
self.assertEqual(summary["last_c_page_acquire"], 0.3)
self.assertEqual(summary["last_c_verify"], 0.4)
self.assertEqual(summary["last_c_register"], 1.5)
self.assertEqual(summary["last_c_hot_hits"], 4)
self.assertEqual(summary["last_c_hot_misses"], 1)
def test_summarizes_solver_timeline_events(self):
text = (
'[solver_timeline] [{"t":0.0,"event":"page_trace_after_inject",'
'"page_trace":{"created_at":0.0,"render_called_at":100.0,"render_returned_at":120.0,'
'"token_written_at":null}},'
'{"t":0.5,"event":"click_before","attempt":1,"token_len":0,'
'"dom":{"widget":{"present":true,"visible":true},'
'"turnstile_iframe_count":0,'
'"element_at_center":{"tag":"DIV","is_iframe":false}}},'
'{"t":3.0,"event":"click_after","attempt":1,"token_len":0,'
'"click_call_ms":2500.0,'
'"click_trace":{"mouse_move1_ms":500.0,"mouse_move2_ms":1500.0,'
'"mouse_down_ms":300.0,"mouse_up_ms":100.0}},'
'{"t":3.0,"event":"poll_start"},'
'{"t":4.0,"event":"poll_done","ok":true,"token_len":730,'
'"poll_attempts":2,"first_token_attempt":2,"poll_read_ms_avg":12.0,'
'"poll_read_ms_max":20.0,'
'"page_trace":{"created_at":0.0,"render_called_at":100.0,"render_returned_at":120.0,'
'"token_written_at":3900.0}}]'
)
timelines = parse_solver_timelines(text)
summary = summarize_solver_timelines(timelines)
self.assertEqual(len(timelines), 1)
self.assertEqual(summary["solver_timeline_count"], 1)
self.assertEqual(summary["ok_count"], 1)
self.assertEqual(summary["avg_click_call_ms"], 2500.0)
self.assertEqual(summary["avg_click_mouse_move_ms"], 2000.0)
self.assertEqual(summary["avg_render_to_token_ms"], 3800.0)
self.assertEqual(summary["avg_token_write_to_poll_done_ms"], 100.0)
self.assertEqual(summary["avg_poll_attempts"], 2.0)
self.assertEqual(summary["center_iframe_hit_ratio"], 0.0)
self.assertEqual(summary["turnstile_iframe_seen_ratio"], 0.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
import os
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _entrypoint_workspace(tmp_path):
for name in ("start.sh", "auth-service.sh", "setup.sh"):
shutil.copy2(ROOT / name, tmp_path / name)
scripts = tmp_path / "scripts"
scripts.mkdir()
shutil.copy2(ROOT / "scripts" / "ensure_runtime.sh", scripts / "ensure_runtime.sh")
python = tmp_path / ".venv" / "bin" / "python"
python.parent.mkdir(parents=True)
python.write_text(
"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ENTRY_CAPTURE\"\n",
encoding="utf-8",
)
python.chmod(0o755)
return tmp_path
def _run_entry(workspace, script, *args):
capture = workspace / "capture.txt"
environment = {**os.environ, "ENTRY_CAPTURE": str(capture)}
completed = subprocess.run(
["bash", script, *args],
cwd=workspace,
env=environment,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
return capture.read_text(encoding="utf-8").splitlines()
def test_start_dispatches_registration_and_email_as_independent_modules(tmp_path):
workspace = _entrypoint_workspace(tmp_path)
(workspace / ".env").write_text("EMAIL_MODE=tempmail\n", encoding="utf-8")
assert _run_entry(workspace, "start.sh", "--target", "3") == [
"-m",
"grok_register.register",
"--target",
"3",
]
assert _run_entry(workspace, "start.sh", "--email-service", "--port", "9090") == [
"-m",
"grok_register.email_server",
"--port",
"9090",
]
def test_auth_service_keeps_the_supported_python_module_internal(tmp_path):
workspace = _entrypoint_workspace(tmp_path)
assert _run_entry(workspace, "auth-service.sh", "--debug") == [
"-m",
"xai_enroller.service",
"--debug",
]

411
tests/test_stress.py Normal file
View File

@@ -0,0 +1,411 @@
"""
Layer 4: 压力测试
真实 asyncio 并发跑较长时间,检查:
- pending 泄漏
- 库存卡死
- claim 饥饿
- 等待时间周期性尖峰
- slot 守恒在长时间运行后仍成立
"""
import asyncio
import time
import statistics
import pytest
from grok_register.core.envelope import ResourceEnvelope
from grok_register.core.inventory import Inventory
from grok_register.core.observer import Metrics
from tests.fakes import Conservation, EventLog
# ═══════════════════════════════════════════
# S/P/C 真实并发 Worker (使用 fake 服务)
# ═══════════════════════════════════════════
async def stress_s_worker(wid, t_gen, inventory, phys, t_slot, metrics, stop):
"""压力测试 S_Worker。"""
while not stop.is_set():
await phys.acquire()
try:
token = await t_gen.generate()
finally:
phys.release()
if token is None:
metrics.t_discarded += 1
continue
metrics.t_produced += 1
now = time.time()
try:
t_env = await ResourceEnvelope.create_with_slot(
'T', token, t_slot, expires_at=now + 60
)
except asyncio.CancelledError:
metrics.t_discarded += 1
raise
try:
await inventory.put_t(t_env)
except Exception:
t_env.discard(reason='put_fail')
metrics.t_discarded += 1
await asyncio.sleep(0.01)
async def stress_p_worker(wid, email_svc, inventory, phys, q_pend, q_slot, metrics, stop):
"""压力测试 P_Worker。"""
loop = asyncio.get_event_loop()
while not stop.is_set():
await q_pend.acquire()
_pend_released = False
try:
await phys.acquire()
try:
handle, email, password = await email_svc.create_email()
sent = True
finally:
phys.release()
if not sent:
q_pend.release()
_pend_released = True
continue
metrics.q_sent += 1
try:
code = await asyncio.wait_for(email_svc.poll_code(handle), timeout=95)
except (asyncio.TimeoutError, Exception):
code = None
if code is None:
metrics.q_discarded += 1
q_pend.release()
_pend_released = True
continue
metrics.q_returned += 1
now = time.time()
q_env = await ResourceEnvelope.create_with_slot(
'Q', {'email': email, 'password': password, 'code': code},
q_slot, expires_at=now + 60,
)
try:
await inventory.put_q(q_env)
except Exception:
q_env.discard(reason='put_fail')
metrics.q_discarded += 1
except asyncio.CancelledError:
if not _pend_released:
q_pend.release()
_pend_released = True
raise
except Exception:
metrics.q_discarded += 1
finally:
if not _pend_released:
q_pend.release()
await asyncio.sleep(0.01)
async def stress_c_worker(wid, register_api, inventory, phys, metrics, stop, file_lock):
"""压力测试 C_Worker。"""
claim_wait_times = []
while not stop.is_set():
t0 = time.time()
try:
async with inventory.claim_pair() as pair:
claim_wait_times.append(time.time() - t0)
t_val = pair.t.value
q_val = pair.q.value
await phys.acquire()
try:
sso = await register_api.register(
q_val['email'], q_val['password'], q_val['code'], t_val
)
if sso:
metrics.pair_consumed_ok += 1
metrics.success_count += 1
else:
metrics.pair_consumed_fail += 1
finally:
phys.release()
except asyncio.CancelledError:
raise
except Exception:
metrics.pair_consumed_fail += 1
await asyncio.sleep(0.01)
return claim_wait_times
@pytest.mark.slow
class TestStress:
"""压力测试: 长时间真实并发。"""
@pytest.mark.asyncio
async def test_steady_state_30s(self):
"""稳态运行 30 秒,检查守恒、无泄漏、无饥饿。"""
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
t_cap, q_cap, pend_cap, phys_cap = 16, 16, 24, 8
t_slot_sem = asyncio.Semaphore(t_cap)
q_slot_sem = asyncio.Semaphore(q_cap)
q_pend_sem = asyncio.Semaphore(pend_cap)
phys_sem = asyncio.Semaphore(phys_cap)
ft = FakeTurnstile(delay=0.005, fail_rate=0.05)
es = FakeEmailService()
fra = FakeRegisterAPI(success_rate=0.9, delay=0.005)
metrics = Metrics()
inv = Inventory(metrics=metrics)
cons = Conservation(t_slot_sem, q_slot_sem, q_pend_sem, inv)
stop = asyncio.Event()
fl = asyncio.Lock()
tasks = []
all_claim_waits = []
# S workers
for i in range(4):
tasks.append(asyncio.create_task(
stress_s_worker(i, ft, inv, phys_sem, t_slot_sem, metrics, stop)
))
# P workers
for i in range(6):
tasks.append(asyncio.create_task(
stress_p_worker(i, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop)
))
# C workers
for i in range(4):
tasks.append(asyncio.create_task(
stress_c_worker(i, fra, inv, phys_sem, metrics, stop, fl)
))
# 监控 + 守恒检查
async def monitor():
snapshots = []
while not stop.is_set():
await asyncio.sleep(2)
try:
cons.check_t_conservation(t_cap)
cons.check_q_conservation(q_cap)
# Q_Pending: 只做软检查(非负、不超限),不做守恒检查
# 因为 P worker 正常 acquire/release 期间 free 值会波动
assert q_pend_sem._value >= 0, 'Q pending 为负'
assert q_pend_sem._value <= pend_cap, 'Q pending 超限'
except AssertionError as e:
snapshots.append(f'INVARIANT VIOLATION: {e}')
snapshots.append(
f't={metrics.t_produced} q_sent={metrics.q_sent} q_ret={metrics.q_returned} '
f'pair_ok={metrics.pair_consumed_ok} inv_t={inv.t_depth} inv_q={inv.q_depth} '
f'phys={phys_sem._value} t_slot={t_slot_sem._value} q_slot={q_slot_sem._value}'
)
return snapshots
mon_task = asyncio.create_task(monitor())
await asyncio.sleep(30)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
snapshots = await mon_task
# 最终守恒检查(所有 Worker 已停止)
cons.check_t_conservation(t_cap)
cons.check_q_conservation(q_cap)
# Q_Pending: 软检查
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
# 检查是否有 INVARIANT VIOLATION
violations = [s for s in snapshots if 'INVARIANT' in s]
assert not violations, f'守恒违反:\n' + '\n'.join(violations)
print(f'\n[Stress 30s] t_produced={metrics.t_produced} '
f'q_sent={metrics.q_sent} q_returned={metrics.q_returned} '
f'pair_ok={metrics.pair_consumed_ok} pair_fail={metrics.pair_consumed_fail} '
f'success={metrics.success_count}')
print(f'Snapshots ({len(snapshots)}):')
for s in snapshots[-5:]:
print(f' {s}')
@pytest.mark.asyncio
async def test_no_claim_starvation(self):
"""检查 C 不会永远等不到 pair (claim 饥饿)。"""
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
t_cap, q_cap, pend_cap, phys_cap = 4, 4, 6, 2
t_slot_sem = asyncio.Semaphore(t_cap)
q_slot_sem = asyncio.Semaphore(q_cap)
q_pend_sem = asyncio.Semaphore(pend_cap)
phys_sem = asyncio.Semaphore(phys_cap)
ft = FakeTurnstile(delay=0.002, fail_rate=0.0)
es = FakeEmailService()
fra = FakeRegisterAPI(success_rate=1.0, delay=0.002)
metrics = Metrics()
inv = Inventory(metrics=metrics)
stop = asyncio.Event()
fl = asyncio.Lock()
claim_wait_samples = []
last_success_time = time.time()
async def c_worker(wid):
nonlocal last_success_time
while not stop.is_set():
t0 = time.time()
try:
async with inv.claim_pair() as pair:
wait = time.time() - t0
claim_wait_samples.append(wait)
await phys_sem.acquire()
try:
sso = await fra.register(
pair.q.value['email'], pair.q.value['password'],
pair.q.value['code'], pair.t.value
)
if sso:
metrics.pair_consumed_ok += 1
metrics.success_count += 1
last_success_time = time.time()
finally:
phys_sem.release()
except asyncio.CancelledError:
break
except Exception:
metrics.pair_consumed_fail += 1
await asyncio.sleep(0.005)
tasks = []
for i in range(3):
tasks.append(asyncio.create_task(stress_s_worker(i, ft, inv, phys_sem, t_slot_sem, metrics, stop)))
for i in range(4):
tasks.append(asyncio.create_task(stress_p_worker(i, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop)))
for i in range(3):
tasks.append(asyncio.create_task(c_worker(i)))
await asyncio.sleep(15)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
# 检查: claim 等待时间不应有极端尖峰
if claim_wait_samples:
p50 = statistics.median(claim_wait_samples)
p95 = sorted(claim_wait_samples)[int(len(claim_wait_samples) * 0.95)]
p99 = sorted(claim_wait_samples)[int(len(claim_wait_samples) * 0.99)]
print(f'\n[Starvation] claim_wait p50={p50:.3f}s p95={p95:.3f}s p99={p99:.3f}s')
print(f' samples={len(claim_wait_samples)} pair_ok={metrics.pair_consumed_ok}')
# p99 不应超过 5 秒(在 fake 服务下)
assert p99 < 5.0, f'claim 饥饿: p99={p99:.3f}s 太高'
# 检查: 最近成功消费不应太久
time_since_last = time.time() - last_success_time
assert time_since_last < 5.0, f'太久没有成功消费: {time_since_last:.1f}s'
@pytest.mark.asyncio
async def test_high_cancellation_churn(self):
"""高取消翻转: Worker 频繁取消重启,系统仍保持守恒。"""
from tests.fakes import FakeTurnstile, FakeEmailService, FakeRegisterAPI
t_cap, q_cap, pend_cap, phys_cap = 8, 8, 12, 4
t_slot_sem = asyncio.Semaphore(t_cap)
q_slot_sem = asyncio.Semaphore(q_cap)
q_pend_sem = asyncio.Semaphore(pend_cap)
phys_sem = asyncio.Semaphore(phys_cap)
ft = FakeTurnstile(delay=0.003, fail_rate=0.1)
es = FakeEmailService()
fra = FakeRegisterAPI(success_rate=0.85, delay=0.003)
metrics = Metrics()
inv = Inventory(metrics=metrics)
cons = Conservation(t_slot_sem, q_slot_sem, q_pend_sem, inv)
stop = asyncio.Event()
fl = asyncio.Lock()
tasks = []
violations = []
def spawn_worker(kind, idx):
if kind == 'S':
return asyncio.create_task(stress_s_worker(idx, ft, inv, phys_sem, t_slot_sem, metrics, stop))
elif kind == 'P':
return asyncio.create_task(stress_p_worker(idx, es, inv, phys_sem, q_pend_sem, q_slot_sem, metrics, stop))
else:
return asyncio.create_task(stress_c_worker(idx, fra, inv, phys_sem, metrics, stop, fl))
# 初始 workers
for i in range(3):
tasks.append(spawn_worker('S', i))
tasks.append(spawn_worker('P', i))
tasks.append(spawn_worker('C', i))
async def churn():
import random
while not stop.is_set():
await asyncio.sleep(0.3)
# 随机取消一个,重启一个
if tasks:
idx = random.randint(0, len(tasks) - 1)
t = tasks[idx]
if not t.done():
t.cancel()
tasks[idx] = spawn_worker(
random.choice(['S', 'P', 'C']),
random.randint(0, 9)
)
# 定期守恒检查(软检查)
try:
cons.check_t_conservation(t_cap)
cons.check_q_conservation(q_cap)
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
except AssertionError as e:
violations.append(str(e))
churn_task = asyncio.create_task(churn())
tasks.append(churn_task)
await asyncio.sleep(15)
stop.set()
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except asyncio.CancelledError:
pass
# 最终守恒检查
try:
cons.check_t_conservation(t_cap)
cons.check_q_conservation(q_cap)
assert q_pend_sem._value >= 0 and q_pend_sem._value <= pend_cap
except AssertionError as e:
violations.append(f'FINAL: {e}')
assert not violations, f'守恒违反:\n' + '\n'.join(violations[:5])
print(f'\n[High Churn] t_produced={metrics.t_produced} '
f'pair_ok={metrics.pair_consumed_ok} success={metrics.success_count}')

View File

@@ -0,0 +1,298 @@
import asyncio
import threading
from xai_enroller.auth_pipeline import AuthPipeline
from xai_enroller.ledger import Ledger
from xai_enroller.models import (
AuthorizationResult,
AuthorizationStatus,
DeviceFlow,
JobStatus,
OAuthCredential,
PipelineState,
SinkReceipt,
SourceRecord,
)
class EmptySource:
async def records(self):
if False:
yield None
async def close(self):
return None
class NoopExecutor:
async def close(self):
return None
def _pipeline(tmp_path):
return AuthPipeline(
source=EmptySource(),
protocol=object(),
executor=NoopExecutor(),
sink=object(),
ledger=Ledger(tmp_path / "ledger.db", b"salt"),
)
def test_cancel_active_is_idempotent_while_cleanup_is_in_progress(tmp_path):
async def scenario():
pipeline = _pipeline(tmp_path)
async def wait_forever():
await asyncio.Future()
task = asyncio.create_task(wait_forever())
pipeline._authorization_task = task
pipeline._authorization_cancellable = True
assert await pipeline.cancel_active()
assert not await pipeline.cancel_active()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
class SingleSource:
async def records(self):
yield SourceRecord("stock", "opaque")
async def close(self):
return None
class ImmediateProtocol:
async def start_device_flow(self):
return DeviceFlow(
"device",
"code",
"https://accounts.x.ai/oauth2/device",
600,
0,
)
async def poll_token(self, **_kwargs):
return OAuthCredential(
"access",
"refresh",
None,
"Bearer",
3600,
"later",
"now",
"subject",
"https://auth.x.ai/oauth2/token",
)
class ImmediateExecutor:
def __init__(self):
self.calls = 0
async def start(self):
return None
async def close(self):
return None
async def confirm(self, *_args):
self.calls += 1
return AuthorizationResult(AuthorizationStatus.AUTHORIZED, "confirmed")
class BlockingThreadSink:
def __init__(self):
self.entered = threading.Event()
self.release = threading.Event()
self.pipeline = None
self.calls = 0
def _store(self):
self.calls += 1
self.entered.set()
self.release.wait(2)
return SinkReceipt("opaque")
async def store(self, _credential):
receipt = await asyncio.to_thread(self._store)
self.pipeline.request_stop()
return receipt
def test_sink_and_ledger_commit_cannot_be_cancelled_mid_commit(tmp_path):
async def scenario():
executor = ImmediateExecutor()
sink = BlockingThreadSink()
ledger = Ledger(tmp_path / "ledger.db", b"salt")
events = []
pipeline = AuthPipeline(
source=SingleSource(),
protocol=ImmediateProtocol(),
executor=executor,
sink=sink,
ledger=ledger,
timeout=2,
event_callback=lambda kind, data: events.append((kind, data)),
)
sink.pipeline = pipeline
run = asyncio.create_task(pipeline.run())
assert await asyncio.to_thread(sink.entered.wait, 1)
assert not await pipeline.cancel_active()
sink.release.set()
await asyncio.wait_for(run, 2)
assert executor.calls == 1
assert sink.calls == 1
assert ledger.aggregate_counts()["imported_unique"] == 1
started = next(data for kind, data in events if kind == "authorization_started")
imported = next(
data
for kind, data in events
if kind == "result" and data["status"] == "imported"
)
assert imported["task_number"] == started["task_number"]
asyncio.run(scenario())
def test_pending_total_uses_current_snapshot_set_difference(tmp_path):
class SnapshotSource(EmptySource):
snapshot_fingerprints = frozenset({"a", "b", "c"})
ledger = Ledger(tmp_path / "ledger.db", b"salt")
imported = ledger.start_fingerprint("b")
ledger.finish(imported, JobStatus.IMPORTED, "imported", "receipt-b")
outside = ledger.start_fingerprint("outside")
ledger.finish(outside, JobStatus.IMPORTED, "imported", "receipt-outside")
pipeline = AuthPipeline(
source=SnapshotSource(),
protocol=object(),
executor=NoopExecutor(),
sink=object(),
ledger=ledger,
)
assert pipeline.status()["pending_total"] == 2
assert pipeline.status()["five_minute_imports_per_minute"] is None
assert pipeline.status()["lifetime_imports_per_minute"] is None
def test_pending_total_is_unknown_before_first_valid_snapshot(tmp_path):
pipeline = _pipeline(tmp_path)
assert pipeline.status()["pending_total"] is None
def test_rate_limit_gate_grows_cooldown_on_consecutive_trips():
class Clock:
def __init__(self):
self.now = 0.0
def __call__(self):
return self.now
async def scenario():
from xai_enroller.auth_pipeline import GlobalRateLimitGate
clock = Clock()
gate = GlobalRateLimitGate(clock=clock, base_cooldown=60.0)
assert await gate.rate_limited()
assert gate.COOLDOWN_SECONDS == 60.0
assert gate.snapshot()["cooldown_seconds"] == 60.0
resume = asyncio.Event()
resume.set()
clock.now = 60.0
probe = await gate.wait_for_permission(resume)
assert probe is not None
assert await gate.rate_limited(probe)
assert gate.COOLDOWN_SECONDS == 90.0
assert gate.snapshot()["cooldown_remaining_seconds"] == 90.0
clock.now = 150.0
probe = await gate.wait_for_permission(resume)
elapsed = await gate.authorized(probe)
assert elapsed == 150.0
assert gate.COOLDOWN_SECONDS == 60.0
assert gate.snapshot()["rate_limit_trips"] == 0
asyncio.run(scenario())
def test_minimum_start_interval_raises_after_rate_limit_and_decays():
from xai_enroller.auth_pipeline import MinimumStartInterval
def approx(value, rel=1e-6):
class Approx:
def __eq__(self, other):
return abs(float(other) - float(value)) <= rel * max(1.0, abs(value))
def __repr__(self):
return f"approx({value})"
return Approx()
interval = MinimumStartInterval(10.0)
assert interval.seconds == 10.0
raised = interval.note_rate_limited()
assert raised == 18.0
raised = interval.note_rate_limited()
assert raised == approx(18.0 * 1.55)
for _ in range(MinimumStartInterval.SUCCESS_STREAK_TO_DECAY):
interval.note_success()
assert interval.seconds < raised
# Keep decaying until the base floor is restored.
for _ in range(40):
interval.note_success()
assert interval.seconds == 10.0
def test_queued_source_imported_while_paused_is_not_authorized(tmp_path):
async def scenario():
executor = ImmediateExecutor()
ledger = Ledger(tmp_path / "ledger.db", b"salt")
pipeline = AuthPipeline(
source=EmptySource(),
protocol=ImmediateProtocol(),
executor=executor,
sink=object(),
ledger=ledger,
timeout=2,
)
source = SourceRecord("stock", "opaque")
fingerprint = ledger.fingerprint(source.source_id)
assert await pipeline.admit(source)
pipeline.pause()
await pipeline.rate_gate.rate_limited()
run = asyncio.create_task(pipeline.run())
try:
async with asyncio.timeout(1):
while pipeline._authorization_task is None:
await asyncio.sleep(0)
external_job_id = ledger.start_fingerprint(fingerprint)
ledger.finish(
external_job_id,
JobStatus.IMPORTED,
"imported",
"external-receipt",
)
pipeline.resume()
async with asyncio.timeout(1):
while (
executor.calls == 0
and pipeline._states.get(fingerprint) is not PipelineState.IMPORTED
):
await asyncio.sleep(0)
assert executor.calls == 0
assert pipeline._states[fingerprint] is PipelineState.IMPORTED
finally:
pipeline.request_stop()
await asyncio.wait_for(run, 2)
asyncio.run(scenario())

View File

@@ -0,0 +1,391 @@
import asyncio
import io
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from xai_enroller.models import JobStatus
from xai_enroller.service import (
AuthServiceSettings,
AuthPipelineRunner,
EventTerminal,
InteractiveCommandPrompt,
resolve_auth_log_mode,
)
from xai_enroller.ledger import Ledger
from xai_enroller.inventory import InventoryError
def test_auth_log_mode_prefers_cli_and_rejects_invalid_values():
assert resolve_auth_log_mode([], {}) == "user"
assert resolve_auth_log_mode(
[], {"XAI_AUTH_SERVICE_LOG_MODE": "debug"}
) == "debug"
assert resolve_auth_log_mode(
["--debug"], {"XAI_AUTH_SERVICE_LOG_MODE": "user"}
) == "debug"
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_LOG_MODE"):
resolve_auth_log_mode([], {"XAI_AUTH_SERVICE_LOG_MODE": "verbose"})
def test_user_terminal_reports_progress_without_internal_or_secret_fields():
messages = []
terminal = EventTerminal(mode="user", output=messages.append)
terminal.emit(
(
"startup",
{
"available": 7,
"claimed": 3,
"destination": "authenticated/",
"source_kind": "local",
"ssh_host": "do-not-print.example",
},
)
)
terminal.emit(
(
"authorization_started",
{
"task_number": 3,
"attempt_number": 1,
"pending_total": 41,
"source_queue": 64,
"source_id": "secret@example.test",
},
)
)
terminal.emit(
(
"result",
{
"status": "imported",
"reason": "imported",
"task_number": 3,
"five_minute_imports_per_minute": 4.25,
"lifetime_imports_per_minute": 1.75,
"imported_unique": 120,
"available": 117,
"source_id": "secret@example.test",
},
)
)
assert messages == [
"[✓] 本地认证服务已启动 | 来源 本机 | 输出 authenticated/ | 待处理 — | 可用 7",
"[→] 开始认证 #3 | 待处理 41",
"[✓] 认证成功 #3 | 运行平均 1.75/分 | 累计 120 | 可用 117",
]
rendered = "\n".join(messages)
assert "source_queue" not in rendered
assert "secret@example.test" not in rendered
assert "do-not-print.example" not in rendered
def test_user_status_distinguishes_unknown_runtime_rate_from_zero_rate():
base = {
"state": "running",
"pending_total": None,
"active_stage": "idle",
"imported_unique": 0,
"available": 0,
"claimed": 0,
"cooldown": False,
}
messages = []
terminal = EventTerminal(mode="user", output=messages.append)
terminal.emit(("status", {**base, "lifetime_imports_per_minute": None}))
terminal.emit(("status", {**base, "lifetime_imports_per_minute": 0.0}))
assert "运行平均 —" in messages[0]
assert "运行平均 0.00/分" in messages[1]
assert "source_queue" not in messages[0]
def test_user_terminal_omits_missing_task_number_before_authorization_starts():
messages = []
terminal = EventTerminal(mode="user", output=messages.append)
terminal.emit(
(
"result",
{
"status": "failed",
"reason": "device_flow_failed",
"task_number": None,
},
)
)
assert messages == ["[✗] 认证未完成 | 暂时失败,将自动重试"]
assert "None" not in messages[0]
def test_debug_terminal_keeps_aggregate_diagnostics_and_sanitizes_unknown_events():
messages = []
terminal = EventTerminal(mode="debug", output=messages.append)
terminal.emit(
(
"status",
{
"state": "running",
"source_queue": 2,
"prepared_queue": 1,
"completion_queue": 0,
"active_stage": "authorization",
"retry_waiting": 1,
"next_retry_seconds": 5.0,
"authorization_starts": 3,
"cooldown": False,
"cooldown_remaining_seconds": 0.0,
"probe_in_flight": False,
"min_authorization_interval_seconds": 10.0,
"pacing_remaining_seconds": 2.0,
"imported_unique": 2,
"attempted_unique": 3,
"rate_limited": 1,
"five_minute_imports_per_minute": 2.0,
"lifetime_imports_per_minute": 1.0,
"available": 2,
"claiming": 0,
"claimed": 0,
},
)
)
terminal.emit(
(
"future_event",
{"reason": "internal_error", "token": "do-not-print-token"},
)
)
assert "queues=2/1/0" in messages[0]
assert messages[1] == "• debug event=future_event reason=internal_error"
assert "do-not-print-token" not in "\n".join(messages)
def test_terminal_output_failure_does_not_escape():
def broken_output(_message):
raise OSError("closed terminal")
terminal = EventTerminal(mode="user", output=broken_output)
terminal.emit(("service_stopped", {}))
def test_interactive_prompt_restores_partially_typed_take_command_after_events():
output = io.StringIO()
commands = []
prompt = InteractiveCommandPrompt(
output=output,
interactive=True,
prompt="认证> ",
)
prompt.start(commands.append)
prompt.feed("take 12")
prompt.write_event("[↻] 发现新账号 3")
assert output.getvalue().endswith("[↻] 发现新账号 3\n认证> take 12")
prompt.feed("\r")
assert commands == ["take 12"]
assert output.getvalue().endswith("\n认证> ")
def test_auth_service_configuration_errors_are_actionable_without_traceback(tmp_path):
environment = os.environ.copy()
environment.pop("XAI_AUTH_SERVICE_SSH_HOST", None)
environment.pop("XAI_AUTH_SERVICE_LOG_MODE", None)
environment["XAI_AUTH_SERVICE_SOURCE"] = "ssh"
missing = subprocess.run(
[sys.executable, "-m", "xai_enroller.service"],
env=environment,
capture_output=True,
text=True,
)
assert missing.returncode == 2
assert "XAI_AUTH_SERVICE_SSH_HOST" in missing.stderr
assert "docs/guides/auth-service.md" in missing.stderr
assert "Traceback" not in missing.stderr
environment["XAI_AUTH_SERVICE_LOG_MODE"] = "verbose"
invalid = subprocess.run(
[sys.executable, "-m", "xai_enroller.service"],
env=environment,
capture_output=True,
text=True,
)
assert invalid.returncode == 2
assert "XAI_AUTH_SERVICE_LOG_MODE" in invalid.stderr
assert "Traceback" not in invalid.stderr
environment["XAI_AUTH_SERVICE_LOG_MODE"] = "user"
environment.pop("XAI_AUTH_SERVICE_SOURCE", None)
environment["XAI_AUTH_SERVICE_SSH_HOST"] = "user@example.test"
environment["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(tmp_path / "auth")
environment["XAI_ENROLLER_TIMEOUT_SEC"] = "not-a-number"
invalid_enroller_setting = subprocess.run(
[sys.executable, "-m", "xai_enroller.service"],
env=environment,
capture_output=True,
text=True,
)
assert invalid_enroller_setting.returncode == 2
assert "XAI_ENROLLER_TIMEOUT_SEC" in invalid_enroller_setting.stderr
assert "docs/guides/auth-service.md" in invalid_enroller_setting.stderr
assert "Traceback" not in invalid_enroller_setting.stderr
def test_auth_service_startup_failure_is_sanitized_without_traceback(tmp_path):
blocked_destination = tmp_path / "private-output-path"
blocked_destination.write_text("not a directory", encoding="utf-8")
environment = os.environ.copy()
environment["XAI_AUTH_SERVICE_SSH_HOST"] = "user@example.test"
environment["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(blocked_destination)
failed = subprocess.run(
[sys.executable, "-m", "xai_enroller.service"],
env=environment,
capture_output=True,
text=True,
)
assert failed.returncode == 1
assert "认证服务异常终止" in failed.stderr
assert "bash auth-service.sh --debug" in failed.stderr
assert "Traceback" not in failed.stderr
assert str(blocked_destination) not in failed.stderr
def test_ledger_recognizes_previously_imported_sources(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"test-salt")
imported = ledger.start("done@example.test")
ledger.finish(imported, JobStatus.IMPORTED, "imported")
failed = ledger.start("retry@example.test")
ledger.finish(failed, JobStatus.SINK_FAILED, "sink_failed")
assert ledger.has_imported("done@example.test") is True
assert ledger.has_imported("retry@example.test") is False
def test_auth_service_settings_defaults_local_and_preserves_ssh_auto_detection(tmp_path):
local = AuthServiceSettings.from_environ(
{"XAI_AUTH_SERVICE_REGISTER_ROOT": str(tmp_path)}
)
remote = AuthServiceSettings.from_environ(
{
"XAI_AUTH_SERVICE_SSH_HOST": "ubuntu@example.test",
"XAI_AUTH_SERVICE_SYNC_SEC": "45",
}
)
assert local.source_kind == "local"
assert local.ssh_host is None
assert local.register_root == str(tmp_path)
assert remote.source_kind == "ssh"
assert remote.ssh_host == "ubuntu@example.test"
assert remote.sync_seconds == 45
assert remote.remote_root == "/opt/grok-free-register"
def test_auth_service_settings_explicit_source_overrides_auto_detection():
local = AuthServiceSettings.from_environ(
{
"XAI_AUTH_SERVICE_SOURCE": "local",
"XAI_AUTH_SERVICE_SSH_HOST": "ignored@example.test",
}
)
assert local.source_kind == "local"
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_SSH_HOST"):
AuthServiceSettings.from_environ({"XAI_AUTH_SERVICE_SOURCE": "ssh"})
with pytest.raises(ValueError, match="XAI_AUTH_SERVICE_SOURCE"):
AuthServiceSettings.from_environ({"XAI_AUTH_SERVICE_SOURCE": "unknown"})
def test_pipeline_runner_takes_a_credential_batch_and_reports_inventory():
class InventoryLedger:
def inventory_counts(self):
return {"available": 7, "claiming": 0, "claimed": 3}
class Pipeline:
ledger = InventoryLedger()
def status(self):
return {"state": "running"}
class Inventory:
def take(self, count):
assert count == 3
return SimpleNamespace(
batch_id="batch-1",
directory=Path("/tmp/claimed/batch-1"),
moved=3,
note="",
)
async def scenario():
events = []
runner = AuthPipelineRunner(Pipeline(), events.append, inventory=Inventory())
assert await runner.handle_command("take 3") is True
assert await runner.handle_command("s") is True
return events
assert asyncio.run(scenario()) == [
(
"inventory_taken",
{
"batch_id": "batch-1",
"directory": "/tmp/claimed/batch-1",
"moved": 3,
"available": 7,
"claiming": 0,
"claimed": 3,
},
),
(
"status",
{
"state": "running",
"available": 7,
"claiming": 0,
"claimed": 3,
},
),
]
def test_pipeline_runner_reports_inventory_failure_without_stopping():
class Ledger:
def inventory_counts(self):
return {"available": 1, "claiming": 1, "claimed": 0}
class Pipeline:
ledger = Ledger()
class Inventory:
def take(self, _count):
raise InventoryError("credential file is missing")
async def scenario():
events = []
runner = AuthPipelineRunner(Pipeline(), events.append, inventory=Inventory())
assert await runner.handle_command("take 1") is True
return events
assert asyncio.run(scenario()) == [
(
"inventory_error",
{
"reason": "credential file is missing",
"available": 1,
"claiming": 1,
"claimed": 0,
},
)
]

View File

@@ -0,0 +1,58 @@
from xai_enroller.inventory import CredentialInventory
from xai_enroller.ledger import Ledger
from xai_enroller.models import JobStatus
def import_credential(ledger, source, receipt):
job_id = ledger.start(source)
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
return job_id
def test_take_moves_available_credentials_without_changing_imported_jobs(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
available = tmp_path / "authenticated"
claimed = tmp_path / "claimed"
available.mkdir()
first_job = import_credential(ledger, "first", "xai-first")
second_job = import_credential(ledger, "second", "xai-second")
(available / "xai-first.json").write_text("{}\n", encoding="utf-8")
(available / "xai-second.json").write_text("{}\n", encoding="utf-8")
batch = CredentialInventory(ledger, available, claimed).take(2)
assert batch.moved == 2
assert batch.note == ""
assert not list(available.glob("*.json"))
assert sorted(path.name for path in batch.directory.glob("*.json")) == [
"xai-first.json",
"xai-second.json",
]
assert ledger.inventory_counts() == {
"available": 0,
"claiming": 0,
"claimed": 2,
}
assert ledger.get(first_job)["status"] == JobStatus.IMPORTED.value
assert ledger.get(second_job)["status"] == JobStatus.IMPORTED.value
def test_recover_finishes_a_batch_interrupted_before_file_move(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
available = tmp_path / "authenticated"
claimed = tmp_path / "claimed"
available.mkdir()
import_credential(ledger, "source", "xai-recover")
(available / "xai-recover.json").write_text("{}\n", encoding="utf-8")
assert ledger.claim_available(1, "batch-recover") == ["xai-recover"]
recovered = CredentialInventory(ledger, available, claimed).recover()
assert recovered == 1
assert not (available / "xai-recover.json").exists()
assert (claimed / "batch-recover" / "xai-recover.json").exists()
assert ledger.inventory_counts() == {
"available": 0,
"claiming": 0,
"claimed": 1,
}

View File

@@ -0,0 +1,125 @@
import os
import sqlite3
import stat
import pytest
from xai_enroller.config import Settings
from xai_enroller.sources import FileSourceAdapter, SQLiteSourceAdapter
def base_env(tmp_path, **overrides):
values = {
"XAI_ENROLLER_SOURCE_KIND": "file",
"XAI_ENROLLER_SOURCE_FILE": str(tmp_path / "sessions.tsv"),
"XAI_ENROLLER_SOURCE_SALT": "test-salt",
"XAI_ENROLLER_LEDGER_PATH": str(tmp_path / "ledger.db"),
}
values.update(overrides)
return values
def test_settings_rejects_public_http_cpa_sink(tmp_path):
with pytest.raises(ValueError, match="HTTPS"):
Settings.from_environ(
base_env(
tmp_path,
XAI_ENROLLER_SINK="cpa",
XAI_ENROLLER_CPA_BASE_URL="http://example.test",
XAI_ENROLLER_CPA_MANAGEMENT_SECRET="secret",
)
)
def test_settings_defaults_are_bounded_and_redacted(tmp_path):
settings = Settings.from_environ(base_env(tmp_path))
assert settings.target == 1
assert settings.concurrency == 1
assert settings.retry_attempts == 0
assert settings.executor == "http"
assert settings.poll_interval == 5.0
redacted = settings.redacted_dict()
assert "test-salt" not in repr(redacted)
assert "CPA_MANAGEMENT_SECRET" not in repr(redacted)
assert "source_salt" not in redacted
def test_settings_accepts_bounded_retry_attempts(tmp_path):
settings = Settings.from_environ(
base_env(tmp_path, XAI_ENROLLER_RETRY_ATTEMPTS="2")
)
assert settings.retry_attempts == 2
def test_settings_accepts_remote_source_without_a_local_source_file(tmp_path):
settings = Settings.from_environ(
base_env(
tmp_path,
XAI_ENROLLER_SOURCE_KIND="remote",
XAI_ENROLLER_SOURCE_FILE="",
)
)
assert settings.source_kind == "remote"
assert settings.source_file is None
assert settings.source_db is None
@pytest.mark.parametrize(
("key", "value", "message"),
[
("XAI_ENROLLER_SOURCE_KIND", "other", "source"),
("XAI_ENROLLER_AUTH_EXECUTOR", "other", "executor"),
("XAI_ENROLLER_CONCURRENCY", "0", "concurrency"),
("XAI_ENROLLER_POLL_SEC", "0", "poll"),
("XAI_ENROLLER_RETRY_ATTEMPTS", "-1", "retry"),
("XAI_ENROLLER_RETRY_ATTEMPTS", "4", "retry"),
("XAI_ENROLLER_TARGET", "0", "target"),
("XAI_ENROLLER_TARGET", "101", "target"),
],
)
def test_settings_reject_invalid_bounds(tmp_path, key, value, message):
with pytest.raises(ValueError, match=message):
Settings.from_environ(base_env(tmp_path, **{key: value}))
def test_file_source_requires_private_regular_file(tmp_path):
path = tmp_path / "sessions.tsv"
path.write_text("one\tsecret\n", encoding="utf-8")
path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
with pytest.raises(ValueError, match="0600"):
list(FileSourceAdapter(path).records())
def test_file_source_suppresses_duplicates_without_leaking_tokens(tmp_path):
path = tmp_path / "sessions.tsv"
path.write_text("one\tsecret-a\none\tsecret-b\n", encoding="utf-8")
path.chmod(0o600)
records = list(FileSourceAdapter(path).records())
assert len(records) == 1
assert records[0].source_id == "one"
assert "secret" not in repr(records)
def test_sqlite_source_uses_fixed_read_only_query_and_hmac_ids(tmp_path):
path = tmp_path / "accounts.db"
connection = sqlite3.connect(path)
connection.execute(
"CREATE TABLE accounts (token TEXT, status TEXT, deleted_at TEXT, updated_at INTEGER)"
)
connection.executemany(
"INSERT INTO accounts VALUES (?, ?, ?, ?)",
[
("old", "active", None, 1),
("new", "active", None, 2),
("deleted", "active", "2026-01-01", 3),
("inactive", "inactive", None, 4),
],
)
connection.commit()
connection.close()
records = list(SQLiteSourceAdapter(path, b"salt").records())
assert [record.sso_token for record in records] == ["new", "old"]
assert records[0].source_id != records[1].source_id
assert "new" not in repr(records)

View File

@@ -0,0 +1,293 @@
import asyncio
import sqlite3
import pytest
from xai_enroller.coordinator import EnrollmentCoordinator
from xai_enroller.models import (
AuthorizationResult,
AuthorizationStatus,
DeviceFlow,
JobStatus,
OAuthCredential,
SinkReceipt,
SourceRecord,
)
from xai_enroller.sources import FileSourceAdapter, SQLiteSourceAdapter
class Source:
async def records(self):
for index in range(3):
yield SourceRecord(f"source-{index}", f"token-{index}")
class Protocol:
def __init__(self):
self.polls = 0
async def start_device_flow(self):
return DeviceFlow("device", "code", "https://accounts.x.ai/device", 60, 0)
async def poll_token(self, **kwargs):
self.polls += 1
return OAuthCredential(
"access",
"refresh",
None,
"Bearer",
3600,
"2026-07-11T00:00:00Z",
"2026-07-11T00:00:00Z",
"subject",
"https://auth.x.ai/oauth2/token",
)
class Executor:
active = 0
max_active = 0
async def confirm(self, source, flow):
type(self).active += 1
type(self).max_active = max(type(self).max_active, type(self).active)
await asyncio.sleep(0)
type(self).active -= 1
if source.source_id == "source-1":
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
return AuthorizationResult(AuthorizationStatus.AUTHORIZED, "confirmed")
class Sink:
async def store(self, credential):
if credential.subject == "subject":
return SinkReceipt("receipt")
class FlakyProtocol(Protocol):
def __init__(self):
super().__init__()
self.calls = 0
async def start_device_flow(self):
self.calls += 1
if self.calls == 1:
raise OSError("temporary transport")
return await super().start_device_flow()
class InvalidProtocol(Protocol):
def __init__(self):
super().__init__()
self.calls = 0
async def start_device_flow(self):
self.calls += 1
raise ValueError("invalid device response")
class FailingExecutor(Executor):
async def confirm(self, source, flow):
raise OSError("confirmation transport")
class SlowDeviceFlowProtocol(Protocol):
async def start_device_flow(self):
await asyncio.sleep(0.04)
return await super().start_device_flow()
class SlowExecutor(Executor):
async def confirm(self, source, flow):
await asyncio.sleep(0.04)
return await super().confirm(source, flow)
def test_coordinator_limits_concurrency_and_maps_terminal_results(tmp_path):
protocol = Protocol()
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=protocol,
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=2,
timeout=5,
)
results = asyncio.run(coordinator.run(target=3))
assert [result.status for result in results] == [
JobStatus.IMPORTED,
JobStatus.NEEDS_BROWSER,
JobStatus.IMPORTED,
]
assert Executor.max_active == 2
assert protocol.polls == 2
def test_coordinator_accepts_explicit_synced_records(tmp_path):
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=Protocol(),
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
)
results = asyncio.run(
coordinator.run_records([SourceRecord("synced-account", "synced-sso")])
)
assert [result.source_id for result in results] == ["synced-account"]
assert results[0].status is JobStatus.IMPORTED
def test_coordinator_emits_device_flow_after_the_flow_is_created(tmp_path):
events = []
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=Protocol(),
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
event_callback=lambda kind, data: events.append((kind, data)),
)
asyncio.run(coordinator.run_records([SourceRecord("synced-account", "synced-sso")]))
assert events == [
(
"device_flow",
{
"source_id": "synced-account",
"user_code": "code",
"verification_url": "https://accounts.x.ai/device",
},
)
]
def test_coordinator_without_sink_never_reports_imported(tmp_path):
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=Protocol(),
executor=Executor(),
sink=None,
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
)
results = asyncio.run(coordinator.run(target=1))
assert results[0].status is JobStatus.SINK_FAILED
def test_coordinator_retries_only_before_device_flow_creation(tmp_path):
protocol = FlakyProtocol()
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=protocol,
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
retry_attempts=1,
)
results = asyncio.run(coordinator.run(target=1))
assert results[0].status is JobStatus.IMPORTED
assert protocol.calls == 2
def test_coordinator_does_not_retry_after_device_flow_creation(tmp_path):
protocol = Protocol()
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=protocol,
executor=FailingExecutor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
retry_attempts=3,
)
results = asyncio.run(coordinator.run(target=1))
assert results[0].status is JobStatus.TRANSPORT_FAILED
assert protocol.polls == 0
def test_coordinator_does_not_retry_non_transport_device_flow_error(tmp_path):
protocol = InvalidProtocol()
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=protocol,
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=5,
retry_attempts=3,
)
results = asyncio.run(coordinator.run(target=1))
assert results[0].status is JobStatus.TRANSPORT_FAILED
assert protocol.calls == 1
def test_coordinator_applies_timeout_to_the_entire_attempt(tmp_path):
coordinator = EnrollmentCoordinator(
source=Source(),
protocol=SlowDeviceFlowProtocol(),
executor=SlowExecutor(),
sink=Sink(),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"salt",
concurrency=1,
timeout=0.06,
retry_attempts=3,
)
results = asyncio.run(coordinator.run(target=1))
assert results[0].status is JobStatus.TIMEOUT
@pytest.mark.parametrize("source_kind", ["file", "sqlite"])
def test_coordinator_consumes_real_source_adapters(tmp_path, source_kind):
if source_kind == "file":
source_path = tmp_path / "sessions.tsv"
source_path.write_text("file-source\tfile-token\n", encoding="utf-8")
source_path.chmod(0o600)
source = FileSourceAdapter(source_path)
else:
source_path = tmp_path / "accounts.db"
connection = sqlite3.connect(source_path)
connection.execute(
"CREATE TABLE accounts (token TEXT, status TEXT, deleted_at TEXT, updated_at INTEGER)"
)
connection.execute("INSERT INTO accounts VALUES (?, ?, ?, ?)", ("db-token", "active", None, 1))
connection.commit()
connection.close()
source = SQLiteSourceAdapter(source_path, b"source-salt")
coordinator = EnrollmentCoordinator(
source=source,
protocol=Protocol(),
executor=Executor(),
sink=Sink(),
ledger_path=tmp_path / f"{source_kind}-ledger.db",
ledger_salt=b"ledger-salt",
concurrency=1,
timeout=5,
)
results = asyncio.run(coordinator.run(target=1))
assert len(results) == 1
assert results[0].status is JobStatus.IMPORTED

View File

@@ -0,0 +1,411 @@
import asyncio
from contextlib import suppress
import httpx
import pytest
from xai_enroller.executors import HTTPProbeExecutor, PlaywrightExecutor
from xai_enroller.models import AuthorizationStatus, DeviceFlow, SourceRecord
def test_http_probe_classifies_403_challenge_without_submission():
calls = []
def handler(request):
calls.append(request)
return httpx.Response(403, text="<html>challenge-platform</html>")
executor = HTTPProbeExecutor(
httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
)
source = SourceRecord("source", "sso-token")
flow = DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device?user_code=ABCD", 60, 1)
result = asyncio.run(executor.confirm(source, flow))
assert result.status is AuthorizationStatus.NEEDS_BROWSER
assert len(calls) == 1
assert calls[0].method == "GET"
assert "sso-token" not in repr(result)
def test_http_probe_does_not_follow_redirects_or_submit_forms():
def handler(request):
return httpx.Response(302, headers={"location": "https://evil.example/"})
executor = HTTPProbeExecutor(
httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
)
result = asyncio.run(
executor.confirm(
SourceRecord("source", "token"),
DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1),
)
)
assert result.status is AuthorizationStatus.NEEDS_INTERACTION
class FakeButton:
def __init__(self, count):
self._count = count
async def count(self):
return self._count
@property
def first(self):
return self
async def click(self):
return None
class FakePage:
def __init__(self):
self.closed = False
async def goto(self, url, wait_until):
self.url = url
def locator(self, selector):
return self
async def inner_text(self):
return "Authorize access"
def get_by_role(self, role, name):
return FakeButton(1 if name == "Authorize" else 0)
async def close(self):
self.closed = True
class FakeContext:
def __init__(self):
self.cookies = []
self.page = FakePage()
self.closed = False
async def add_cookies(self, cookies):
self.cookies.extend(cookies)
async def new_page(self):
return self.page
async def close(self):
self.closed = True
class FakeBrowser:
def __init__(self):
self.launches = 0
self.contexts = []
self.closed = False
async def new_context(self):
context = FakeContext()
self.contexts.append(context)
return context
async def close(self):
self.closed = True
class FakeChromium:
def __init__(self, browser):
self.browser = browser
self.launch_options = None
async def launch(self, **options):
self.launch_options = options
self.browser.launches += 1
return self.browser
class FakePlaywright:
def __init__(self, browser):
self.chromium = FakeChromium(browser)
self.stopped = False
async def stop(self):
self.stopped = True
class FakePlaywrightFactory:
def __init__(self):
self.browser = FakeBrowser()
self.playwright = FakePlaywright(self.browser)
def __call__(self):
factory = self
class Runner:
async def start(self):
return factory.playwright
return Runner()
class FailingPageContext(FakeContext):
async def new_page(self):
raise RuntimeError("page creation failed")
class FailingPageBrowser(FakeBrowser):
async def new_context(self):
context = FailingPageContext()
self.contexts.append(context)
return context
def test_playwright_executor_isolates_context_and_scopes_exact_cookie():
factory = FakePlaywrightFactory()
executor = PlaywrightExecutor(playwright_factory=factory)
source = SourceRecord("source", "secret-token")
flow = DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1)
result = asyncio.run(executor.confirm(source, flow))
asyncio.run(executor.close())
assert result.status is AuthorizationStatus.AUTHORIZED
assert factory.browser.launches == 1
context = factory.browser.contexts[0]
assert context.cookies == [{
"name": "sso",
"value": "secret-token",
"domain": "accounts.x.ai",
"path": "/",
"secure": True,
"httpOnly": True,
}]
assert context.page.closed
assert context.closed
assert factory.browser.closed
def test_playwright_executor_closes_context_when_page_creation_fails():
factory = FakePlaywrightFactory()
factory.browser = FailingPageBrowser()
factory.playwright = FakePlaywright(factory.browser)
executor = PlaywrightExecutor(playwright_factory=factory)
result = asyncio.run(
executor.confirm(
SourceRecord("source", "secret-token"),
DeviceFlow("device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1),
)
)
asyncio.run(executor.close())
assert result.status is AuthorizationStatus.NEEDS_INTERACTION
assert factory.browser.contexts[0].closed
def test_playwright_executor_launches_configured_fingerprint_browser():
factory = FakePlaywrightFactory()
executor = PlaywrightExecutor(
playwright_factory=factory,
executable_path="/opt/cloakbrowser/chrome",
)
asyncio.run(executor.start())
asyncio.run(executor.close())
assert factory.playwright.chromium.launch_options == {
"headless": True,
"executable_path": "/opt/cloakbrowser/chrome",
}
def test_playwright_executor_uses_fingerprint_browser_path_from_environment(monkeypatch):
monkeypatch.setenv("XAI_ENROLLER_BROWSER_EXECUTABLE", "/opt/cloakbrowser/chrome")
factory = FakePlaywrightFactory()
executor = PlaywrightExecutor(playwright_factory=factory)
asyncio.run(executor.start())
asyncio.run(executor.close())
assert factory.playwright.chromium.launch_options == {
"headless": True,
"executable_path": "/opt/cloakbrowser/chrome",
}
def test_playwright_executor_finds_macos_cloakbrowser_binary(monkeypatch):
macos_binary = (
"/Users/test/.cloakbrowser/chromium-145/Chromium.app/Contents/MacOS/Chromium"
)
monkeypatch.delenv("XAI_ENROLLER_BROWSER_EXECUTABLE", raising=False)
monkeypatch.setattr(
"xai_enroller.executors.glob.glob",
lambda pattern: [macos_binary] if "Chromium.app" in pattern else [],
)
assert PlaywrightExecutor._find_executable_path() == macos_binary
def test_playwright_attempt_cleanup_defers_repeated_cancellation():
class Page:
def __init__(self):
self.entered = asyncio.Event()
self.release = asyncio.Event()
self.closed = False
async def close(self):
self.entered.set()
await self.release.wait()
self.closed = True
class Context:
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
async def scenario():
page = Page()
context = Context()
task = asyncio.create_task(
PlaywrightExecutor._close_attempt_resources(page, context)
)
await page.entered.wait()
task.cancel()
task.cancel()
await asyncio.sleep(0)
page.release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert page.closed
assert context.closed
asyncio.run(scenario())
def test_playwright_attempt_cleanup_has_a_true_wall_clock_deadline(monkeypatch):
class Page:
def __init__(self):
self.entered = asyncio.Event()
self.release = asyncio.Event()
async def close(self):
self.entered.set()
while not self.release.is_set():
try:
await self.release.wait()
except asyncio.CancelledError:
# Playwright transports can remain stuck while cancellation
# propagates. A hard deadline must not await that forever.
continue
async def scenario():
monkeypatch.setattr(PlaywrightExecutor, "CLOSE_TIMEOUT_SECONDS", 0.02)
page = Page()
task = asyncio.create_task(
PlaywrightExecutor._close_attempt_resources(page, None)
)
await page.entered.wait()
done, _pending = await asyncio.wait({task}, timeout=0.10)
completed_within_deadline = task in done
page.release.set()
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert completed_within_deadline
asyncio.run(scenario())
def test_playwright_confirmation_has_hard_deadline_and_restarts_browser(monkeypatch):
class StubbornGotoPage(FakePage):
def __init__(self):
super().__init__()
self.entered = asyncio.Event()
self.release = asyncio.Event()
async def goto(self, url, wait_until):
self.url = url
self.entered.set()
while not self.release.is_set():
try:
await self.release.wait()
except asyncio.CancelledError:
# Model a wedged Playwright transport that does not finish
# propagating task cancellation.
continue
class StubbornGotoContext(FakeContext):
def __init__(self):
super().__init__()
self.page = StubbornGotoPage()
class StubbornGotoBrowser(FakeBrowser):
def __init__(self):
super().__init__()
self.close_entered = asyncio.Event()
self.close_release = asyncio.Event()
async def new_context(self):
context = StubbornGotoContext()
self.contexts.append(context)
return context
async def close(self):
self.close_entered.set()
while not self.close_release.is_set():
try:
await self.close_release.wait()
except asyncio.CancelledError:
continue
self.closed = True
class RotatingPlaywrightFactory:
def __init__(self):
self.browsers = [StubbornGotoBrowser(), FakeBrowser()]
self.playwrights = [FakePlaywright(browser) for browser in self.browsers]
self.starts = 0
def __call__(self):
factory = self
class Runner:
async def start(self):
playwright = factory.playwrights[factory.starts]
factory.starts += 1
return playwright
return Runner()
async def scenario():
monkeypatch.setattr(PlaywrightExecutor, "ATTEMPT_TIMEOUT_SECONDS", 0.02)
monkeypatch.setattr(PlaywrightExecutor, "CLOSE_TIMEOUT_SECONDS", 0.02)
factory = RotatingPlaywrightFactory()
executor = PlaywrightExecutor(playwright_factory=factory)
source = SourceRecord("source", "secret-token")
flow = DeviceFlow(
"device", "ABCD", "https://accounts.x.ai/oauth2/device", 60, 1
)
first = asyncio.create_task(executor.confirm(source, flow))
while not factory.browsers[0].contexts:
await asyncio.sleep(0)
stuck_page = factory.browsers[0].contexts[0].page
await stuck_page.entered.wait()
done, _pending = await asyncio.wait({first}, timeout=0.10)
completed_within_deadline = first in done
stuck_page.release.set()
factory.browsers[0].close_release.set()
first_result = await first
await asyncio.sleep(0)
second_result = await executor.confirm(source, flow)
await executor.close()
assert completed_within_deadline
assert first_result.status is AuthorizationStatus.NEEDS_INTERACTION
assert first_result.reason_code == "confirmation_timeout"
assert second_result.status is AuthorizationStatus.AUTHORIZED
assert factory.starts == 2
assert factory.browsers[0].close_entered.is_set()
assert factory.browsers[0].closed
assert factory.playwrights[0].stopped
asyncio.run(scenario())

View File

@@ -0,0 +1,188 @@
import asyncio
import json
import httpx
from xai_enroller.coordinator import EnrollmentCoordinator
from xai_enroller.executors import PlaywrightExecutor
from xai_enroller.models import SourceRecord
from xai_enroller.protocol import XAIProfile, XAIProtocol
from xai_enroller.sinks import CPAAuthFileSink
class SingleSource:
async def records(self):
yield SourceRecord("source-1", "sso-secret")
class FakeButton:
async def count(self):
return 1
@property
def first(self):
return self
async def click(self):
return None
class FakePage:
async def goto(self, url, wait_until):
self.url = url
def locator(self, selector):
return self
async def inner_text(self):
return "Authorize access"
def get_by_role(self, role, name):
return FakeButton() if name == "Authorize" else type(
"NoButton",
(),
{"count": staticmethod(lambda: asyncio.sleep(0, result=0))},
)()
async def close(self):
return None
class FakeContext:
def __init__(self, browser):
self.browser = browser
self.cookies = []
async def add_cookies(self, cookies):
self.cookies.extend(cookies)
async def new_page(self):
return FakePage()
async def close(self):
self.browser.live_contexts -= 1
class FakeBrowser:
def __init__(self):
self.launches = 0
self.live_contexts = 0
self.max_live_contexts = 0
async def new_context(self):
self.live_contexts += 1
self.max_live_contexts = max(self.max_live_contexts, self.live_contexts)
return FakeContext(self)
async def close(self):
return None
class FakeChromium:
def __init__(self, browser):
self.browser = browser
async def launch(self, **options):
self.browser.launches += 1
return self.browser
class FakePlaywright:
def __init__(self, browser):
self.chromium = FakeChromium(browser)
async def stop(self):
return None
class FakePlaywrightFactory:
def __init__(self):
self.browser = FakeBrowser()
self.playwright = FakePlaywright(self.browser)
def __call__(self):
factory = self
class Runner:
async def start(self):
return factory.playwright
return Runner()
def test_adapter_boundary_hands_xai_credential_to_cpa_without_ledger_secrets(tmp_path):
def xai_handler(request):
if request.url.path == "/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
if request.url.path == "/oauth2/device/code":
return httpx.Response(
200,
json={
"device_code": "device-secret",
"user_code": "ABCD",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"verification_uri_complete": (
"https://accounts.x.ai/oauth2/device?user_code=ABCD"
),
"expires_in": 60,
"interval": 0,
},
)
if request.url.path == "/oauth2/token":
return httpx.Response(
200,
json={
"access_token": "access-secret",
"refresh_token": "refresh-secret",
"id_token": "id-secret",
"token_type": "Bearer",
"expires_in": 3600,
"sub": "subject-1",
},
)
raise AssertionError(f"unexpected xAI request: {request.url}")
cpa_requests = []
def cpa_handler(request):
cpa_requests.append(request)
return httpx.Response(201)
xai_client = httpx.AsyncClient(transport=httpx.MockTransport(xai_handler))
cpa_client = httpx.AsyncClient(transport=httpx.MockTransport(cpa_handler))
factory = FakePlaywrightFactory()
coordinator = EnrollmentCoordinator(
source=SingleSource(),
protocol=XAIProtocol(xai_client, XAIProfile.default()),
executor=PlaywrightExecutor(playwright_factory=factory),
sink=CPAAuthFileSink(
"https://cpa.example",
"management-secret",
cpa_client,
name_secret=b"name-secret",
),
ledger_path=tmp_path / "ledger.db",
ledger_salt=b"ledger-salt",
)
try:
results = asyncio.run(coordinator.run(target=1))
finally:
asyncio.run(xai_client.aclose())
asyncio.run(cpa_client.aclose())
assert results[0].status.value == "imported"
assert factory.browser.launches == 1
assert factory.browser.max_live_contexts == 1
assert cpa_requests[0].url.path == "/v0/management/auth-files"
assert cpa_requests[0].url.params["name"].startswith("xai-")
assert json.loads(cpa_requests[0].content)["refresh_token"] == "refresh-secret"
ledger_bytes = (tmp_path / "ledger.db").read_bytes()
for secret in ("sso-secret", "device-secret", "access-secret", "refresh-secret", "id-secret"):
assert secret.encode() not in ledger_bytes

View File

@@ -0,0 +1,163 @@
import sqlite3
import pytest
from xai_enroller.ledger import Ledger
from xai_enroller.models import JobStatus
def test_ledger_persists_only_redacted_terminal_fields(tmp_path):
path = tmp_path / "ledger.db"
ledger = Ledger(path, b"salt")
job_id = ledger.start("source", attempt=1)
ledger.finish(job_id, JobStatus.SINK_FAILED, "sink_failed", "receipt")
raw = path.read_bytes()
for secret in [
"sso-token",
"device-code",
"https://accounts.x.ai",
"access-token",
"refresh-token",
"id-token",
"person@example.com",
]:
assert secret.encode() not in raw
row = ledger.get(job_id)
assert row["status"] == "sink_failed"
assert row["reason_code"] == "sink_failed"
assert row["sink_receipt_fingerprint"] == "receipt"
assert "source" not in repr(row["source_fingerprint"])
def test_ledger_recovers_pending_jobs(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
job_id = ledger.start("source", attempt=1)
ledger.recover_pending()
assert ledger.get(job_id)["status"] == JobStatus.CANCELLED.value
def test_ledger_backfills_imported_receipts_into_available_inventory(tmp_path):
path = tmp_path / "ledger.db"
with sqlite3.connect(path) as connection:
connection.execute(
"""
CREATE TABLE jobs (
job_id INTEGER PRIMARY KEY,
source_fingerprint TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
reason_code TEXT,
sink_receipt_fingerprint TEXT
)
"""
)
connection.executemany(
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at, "
"finished_at, reason_code, sink_receipt_fingerprint) VALUES (?, 1, ?, ?, ?, ?, ?)",
[
("source-old", "imported", "2026-01-01", "2026-01-02", "imported", "receipt-old"),
("source-new", "imported", "2026-01-03", "2026-01-04", "imported", "receipt-new"),
("source-failed", "sink_failed", "2026-01-05", "2026-01-06", "sink_failed", "receipt-failed"),
],
)
ledger = Ledger(path, b"salt")
assert ledger.inventory_counts() == {
"available": 2,
"claiming": 0,
"claimed": 0,
}
assert ledger.claim_available(2, "batch-backfill") == [
"receipt-new",
"receipt-old",
]
def test_imported_finish_and_inventory_insert_are_atomic(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
job_id = ledger.start("source", attempt=1)
with sqlite3.connect(ledger.path) as connection:
connection.execute(
"""
CREATE TRIGGER reject_inventory_insert
BEFORE INSERT ON credential_inventory
BEGIN
SELECT RAISE(ABORT, 'inventory insert rejected');
END
"""
)
with pytest.raises(sqlite3.IntegrityError, match="inventory insert rejected"):
ledger.finish(job_id, JobStatus.IMPORTED, "imported", "receipt")
assert ledger.get(job_id)["status"] == "pending"
assert ledger.inventory_counts()["available"] == 0
def test_claim_available_uses_latest_finish_and_tracks_batch(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
jobs = []
for source, receipt in [
("source-a", "receipt-a"),
("source-b", "receipt-b"),
("source-c", "receipt-c"),
]:
job_id = ledger.start(source)
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
jobs.append(job_id)
with sqlite3.connect(ledger.path) as connection:
connection.executemany(
"UPDATE jobs SET finished_at=? WHERE job_id=?",
[
("2026-01-01T00:00:00+00:00", jobs[0]),
("2026-01-03T00:00:00+00:00", jobs[1]),
("2026-01-02T00:00:00+00:00", jobs[2]),
],
)
assert ledger.claim_available(2, "batch-1") == ["receipt-b", "receipt-c"]
assert ledger.claim_available(2, "batch-2") == ["receipt-a"]
assert ledger.pending_claims("batch-1") == [
{
"sink_receipt_fingerprint": "receipt-b",
"batch_id": "batch-1",
"note": "",
},
{
"sink_receipt_fingerprint": "receipt-c",
"batch_id": "batch-1",
"note": "",
},
]
assert ledger.inventory_counts() == {
"available": 0,
"claiming": 3,
"claimed": 0,
}
def test_claim_completion_and_recovery_are_batch_scoped(tmp_path):
ledger = Ledger(tmp_path / "ledger.db", b"salt")
for source, receipt in [
("source-a", "receipt-a"),
("source-b", "receipt-b"),
("source-c", "receipt-c"),
]:
job_id = ledger.start(source)
ledger.finish(job_id, JobStatus.IMPORTED, "imported", receipt)
assert ledger.claim_available(2, "batch-complete")
assert ledger.claim_available(1, "batch-recover")
assert ledger.mark_claimed("batch-complete", "delivered") == 2
assert ledger.recover_claiming("batch-recover", note="worker_restart") == 1
assert ledger.pending_claims() == []
assert ledger.inventory_counts() == {
"available": 1,
"claiming": 0,
"claimed": 2,
}
assert ledger.claim_available(1, "batch-retry") == ["receipt-a"]

View File

@@ -0,0 +1,245 @@
import base64
import json
import httpx
import pytest
from xai_enroller.models import AuthorizationStatus
from xai_enroller.protocol import XAIProtocol, XAIProfile
def _unsigned_jwt(payload):
encoded = base64.urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode()
).decode().rstrip("=")
return f"header.{encoded}.signature"
def test_default_profile_matches_observed_grok_cli_device_contract():
profile = XAIProfile.default()
assert profile.client_id == "b1a00492-073a-47ea-816f-4c329264a828"
assert profile.scope == "openid profile email offline_access grok-cli:access api:access"
assert profile.version == "grok-cli-device-v1"
def test_device_flow_uses_discovered_endpoints_and_profile_scope():
requests = []
def handler(request):
requests.append(request)
if request.url.path == "/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
return httpx.Response(
200,
json={
"device_code": "device",
"user_code": "ABCD",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"expires_in": 60,
"interval": 1,
},
)
profile = XAIProfile(client_id="client", scope="openid offline_access")
protocol = XAIProtocol(httpx.AsyncClient(transport=httpx.MockTransport(handler)), profile)
async def run():
flow = await protocol.start_device_flow()
return flow
flow = __import__("asyncio").run(run())
assert flow.verification_url == "https://accounts.x.ai/oauth2/device?user_code=ABCD"
assert dict(__import__("urllib.parse", fromlist=["parse_qsl"]).parse_qsl(requests[1].content.decode())) == {
"client_id": "client",
"scope": "openid offline_access",
}
def test_device_flow_uses_configured_poll_interval_when_issuer_omits_it():
def handler(request):
if request.url.path == "/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
return httpx.Response(
200,
json={
"device_code": "device",
"user_code": "ABCD",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"expires_in": 60,
},
)
protocol = XAIProtocol(
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
XAIProfile(client_id="client", scope="openid"),
default_poll_interval=7,
)
flow = __import__("asyncio").run(protocol.start_device_flow())
assert flow.interval == 7
def test_device_flow_preserves_complete_verification_url():
def handler(request):
if request.url.path == "/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
return httpx.Response(
200,
json={
"device_code": "device",
"user_code": "ABCD",
"verification_uri": "https://accounts.x.ai/oauth2/device",
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer",
"expires_in": 60,
},
)
protocol = XAIProtocol(
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
XAIProfile(client_id="client", scope="openid"),
)
flow = __import__("asyncio").run(protocol.start_device_flow())
assert flow.verification_url == (
"https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer"
)
def test_xai_url_allowlist_rejects_missing_hostname():
assert XAIProtocol._allowed_url("https:///oauth2/device") is False
@pytest.mark.parametrize(
"discovery",
[
{
"device_authorization_endpoint": "http://auth.x.ai/device",
"token_endpoint": "https://auth.x.ai/token",
},
{
"device_authorization_endpoint": "https://evil.example/device",
"token_endpoint": "https://auth.x.ai/token",
},
],
)
def test_discovery_rejects_non_xai_or_non_https(discovery):
def handler(request):
return httpx.Response(200, json=discovery)
protocol = XAIProtocol(
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
XAIProfile(client_id="client", scope="openid"),
)
with pytest.raises(ValueError, match="allowlist"):
__import__("asyncio").run(protocol.discover())
def test_token_polling_maps_pending_slow_down_and_denial():
responses = iter(
[
httpx.Response(400, json={"error": "authorization_pending"}),
httpx.Response(400, json={"error": "slow_down"}),
httpx.Response(400, json={"error": "access_denied"}),
]
)
def handler(request):
return next(responses)
protocol = XAIProtocol(
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
XAIProfile(client_id="client", scope="openid"),
sleep=lambda _: __import__("asyncio").sleep(0),
)
with pytest.raises(RuntimeError, match="oauth_denied"):
__import__("asyncio").run(
protocol.poll_token(
endpoint="https://auth.x.ai/oauth2/token",
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
timeout=1,
)
)
def test_token_response_requires_refresh_token_and_never_exposes_body():
def handler(request):
return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer"})
protocol = XAIProtocol(
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
XAIProfile(client_id="client", scope="openid"),
)
with pytest.raises(RuntimeError) as error:
__import__("asyncio").run(
protocol.poll_token(
endpoint="https://auth.x.ai/oauth2/token",
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
timeout=1,
)
)
assert "access" not in str(error.value)
def test_credential_subject_prefers_id_token_subject():
credential = XAIProtocol._credential(
{
"access_token": _unsigned_jwt({"principal_id": "access-subject"}),
"refresh_token": "refresh",
"id_token": _unsigned_jwt(
{"sub": "stable-subject", "email": "ignored@example.test"}
),
"sub": "response-subject",
},
"https://auth.x.ai/oauth2/token",
)
assert credential.subject == "stable-subject"
def test_credential_subject_falls_back_to_access_token_principal_id():
credential = XAIProtocol._credential(
{
"access_token": _unsigned_jwt({"principal_id": "stable-principal"}),
"refresh_token": "refresh",
"id_token": "malformed",
"sub": "response-subject",
},
"https://auth.x.ai/oauth2/token",
)
assert credential.subject == "stable-principal"
def test_credential_subject_ignores_email_and_falls_back_to_response_subject():
credential = XAIProtocol._credential(
{
"access_token": "not-a-jwt",
"refresh_token": "refresh",
"id_token": _unsigned_jwt({"email": "ignored@example.test"}),
"sub": "response-subject",
},
"https://auth.x.ai/oauth2/token",
)
assert credential.subject == "response-subject"

View File

@@ -0,0 +1,79 @@
import asyncio
import json
import httpx
from xai_enroller.models import OAuthCredential
from xai_enroller.sinks import CPAAuthFileSink, SinkError
def credential():
return OAuthCredential(
access_token="access",
refresh_token="refresh",
id_token="id-token",
token_type="Bearer",
expires_in=3600,
expires_at="2026-07-11T00:00:00Z",
last_refresh="2026-07-11T00:00:00Z",
subject="subject",
token_endpoint="https://auth.x.ai/oauth2/token",
)
def test_cpa_sink_builds_pinned_document_and_safe_name():
requests = []
def handler(request):
requests.append(request)
return httpx.Response(201)
sink = CPAAuthFileSink(
"https://cpa.example",
"management-secret",
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
name_secret=b"name-secret",
)
receipt = asyncio.run(sink.store(credential()))
assert requests[0].url.path == "/v0/management/auth-files"
assert requests[0].headers["authorization"] == "Bearer management-secret"
assert requests[0].url.params["name"].startswith("xai-")
assert requests[0].url.params["name"].endswith(".json")
assert json.loads(requests[0].content) == {
"type": "xai",
"access_token": "access",
"refresh_token": "refresh",
"id_token": "id-token",
"token_type": "Bearer",
"expires_in": 3600,
"expired": "2026-07-11T00:00:00Z",
"last_refresh": "2026-07-11T00:00:00Z",
"sub": "subject",
"base_url": "https://api.x.ai/v1",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"auth_kind": "oauth",
}
assert "access" not in repr(receipt)
def test_cpa_sink_maps_non_2xx_without_retry_or_secret_leak():
count = 0
def handler(request):
nonlocal count
count += 1
return httpx.Response(500, text="access")
sink = CPAAuthFileSink(
"https://cpa.example",
"management-secret",
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
name_secret=b"name-secret",
)
try:
asyncio.run(sink.store(credential()))
except SinkError as error:
assert "access" not in str(error)
else:
raise AssertionError("expected SinkError")
assert count == 1

View File

@@ -0,0 +1,240 @@
import asyncio
import json
import os
from xai_enroller.models import SourceRecord
from xai_enroller.remote_stream import (
DiskSnapshotSource,
LocalSnapshotSynchronizer,
SSHSnapshotSynchronizer,
)
def _document(source_id):
return json.dumps(
{
"email": source_id,
"cookies": [
{
"name": "sso",
"value": "opaque",
"domain": "accounts.x.ai",
"path": "/",
}
],
},
separators=(",", ":"),
).encode()
class FakeStream:
def __init__(self, lines):
self._lines = iter(lines)
async def readline(self):
return next(self._lines, b"")
async def read(self, _size=-1):
return b""
class FakeProcess:
def __init__(self, lines, returncode=0):
self.stdout = FakeStream(lines)
self.stderr = FakeStream([])
self.returncode = None
self._final_returncode = returncode
async def wait(self):
self.returncode = self._final_returncode
return self.returncode
def terminate(self):
self.returncode = -15
def kill(self):
self.returncode = -9
def test_snapshot_sync_atomically_replaces_only_valid_complete_export(tmp_path):
async def scenario():
destination = tmp_path / "source-snapshot.jsonl"
destination.write_bytes(_document("old") + b"\n")
invalid = FakeProcess([b"not-json\n"])
async def invalid_factory(*_args, **_kwargs):
return invalid
synchronizer = SSHSnapshotSynchronizer(
"host",
destination,
process_factory=invalid_factory,
fingerprint=lambda source_id: f"key:{source_id}",
)
assert not await synchronizer.sync_once()
assert destination.read_bytes() == _document("old") + b"\n"
rejected = FakeProcess([_document("rejected") + b"\n"], returncode=1)
async def rejected_factory(*_args, **_kwargs):
return rejected
synchronizer.process_factory = rejected_factory
assert not await synchronizer.sync_once()
assert destination.read_bytes() == _document("old") + b"\n"
empty = FakeProcess([])
async def empty_factory(*_args, **_kwargs):
return empty
synchronizer.process_factory = empty_factory
assert not await synchronizer.sync_once()
assert destination.read_bytes() == _document("old") + b"\n"
valid = FakeProcess([_document("new-a") + b"\n", _document("new-b") + b"\n"])
async def valid_factory(*_args, **_kwargs):
return valid
synchronizer.process_factory = valid_factory
assert await synchronizer.sync_once()
assert synchronizer.snapshot_fingerprints == frozenset(
{"key:new-a", "key:new-b"}
)
assert destination.read_bytes() == _document("new-a") + b"\n" + _document("new-b") + b"\n"
assert destination.stat().st_mode & 0o777 == 0o600
assert not list(tmp_path.glob(".source-snapshot.jsonl.*.tmp"))
synchronizer.process_factory = invalid_factory
assert not await synchronizer.sync_once()
assert synchronizer.snapshot_fingerprints == frozenset(
{"key:new-a", "key:new-b"}
)
asyncio.run(scenario())
def test_local_snapshot_accepts_empty_input_and_anchors_exporter_code(tmp_path):
async def scenario():
registration_root = tmp_path / "registration"
destination = tmp_path / "auth" / "source-snapshot.jsonl"
captured = []
process = FakeProcess([])
async def factory(*args, **_kwargs):
captured.append(args)
return process
synchronizer = LocalSnapshotSynchronizer(
registration_root,
destination,
process_factory=factory,
)
assert await synchronizer.sync_once()
assert destination.read_bytes() == b""
assert destination.stat().st_mode & 0o777 == 0o600
assert synchronizer.snapshot_fingerprints == frozenset()
assert captured[0][0]
assert captured[0][1] != str(registration_root / "scripts" / "export_registered_sessions.py")
assert captured[0][-2:] == (
str(registration_root / "keys" / "auth-sessions.jsonl"),
str(registration_root / "keys" / "accounts.txt"),
)
asyncio.run(scenario())
def test_local_snapshot_rejects_candidate_when_either_input_changes(tmp_path):
async def scenario():
registration_root = tmp_path / "registration"
keys = registration_root / "keys"
keys.mkdir(parents=True)
sessions = keys / "auth-sessions.jsonl"
accounts = keys / "accounts.txt"
sessions.write_bytes(_document("old") + b"\n")
accounts.write_text("", encoding="utf-8")
destination = tmp_path / "auth" / "source-snapshot.jsonl"
destination.parent.mkdir()
destination.write_bytes(_document("stable") + b"\n")
process = FakeProcess([_document("candidate") + b"\n"])
async def factory(*_args, **_kwargs):
accounts.write_text("new@example.test:p:sso\n", encoding="utf-8")
return process
synchronizer = LocalSnapshotSynchronizer(
registration_root,
destination,
process_factory=factory,
)
assert not await synchronizer.sync_once()
assert destination.read_bytes() == _document("stable") + b"\n"
asyncio.run(scenario())
def test_snapshot_consumer_finishes_open_generation_before_replacement(tmp_path):
async def scenario():
destination = tmp_path / "source-snapshot.jsonl"
destination.write_bytes(_document("old-a") + b"\n" + _document("old-b") + b"\n")
source = DiskSnapshotSource(destination, poll_seconds=0.01)
records = source.records()
first = await anext(records)
assert isinstance(first, SourceRecord)
assert first.source_id == "old-a"
replacement = tmp_path / "replacement"
replacement.write_bytes(_document("new-a") + b"\n")
os.replace(replacement, destination)
assert (await anext(records)).source_id == "old-b"
assert (await asyncio.wait_for(anext(records), 1)).source_id == "new-a"
await source.close()
await records.aclose()
asyncio.run(scenario())
def test_snapshot_source_reports_only_aggregate_new_records(tmp_path):
class Synchronizer:
def __init__(self):
self.snapshot_fingerprints = None
self.calls = 0
self.source = None
async def sync_once(self):
self.calls += 1
if self.calls == 1:
self.snapshot_fingerprints = frozenset({"a", "b"})
else:
self.snapshot_fingerprints = frozenset({"a", "b", "c"})
self.source._closed = True
return True
async def close(self):
return None
async def scenario():
synchronizer = Synchronizer()
events = []
source = DiskSnapshotSource(
tmp_path / "source-snapshot.jsonl",
synchronizer=synchronizer,
sync_seconds=0.001,
event_callback=lambda kind, data: events.append((kind, data)),
)
synchronizer.source = source
await source._sync_loop()
assert events == [
("source_connected", {}),
("source_updated", {"new": 1, "total": 3}),
]
asyncio.run(scenario())

View File

@@ -0,0 +1,100 @@
# Grok Session → CPA / sub2api
纯前端单页工具:把 grok-free-register 产出的凭证,转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。
对照思路GPT session → CPA / sub2api 纯前端转换器(同类工具,无外部依赖)。
## 本地使用
直接打开:
```text
tools/GrokSession2CPAAndSub2API/index.html
```
所有解析和转换都在浏览器本地完成,不上传 token。
## 支持输入
| 来源 | 说明 |
|------|------|
| `authenticated/xai-*.json` | 注册成功 OAuthbase_url 常是 api.x.ai |
| `cpa_ready/xai-*.json` | watchdog 终态(带 headers/email |
| `sub2api-data` / accounts 数组 | 已转换过的包,可再导成其它格式 |
| `auth-sessions.jsonl` | `email` + `cookies.sso` |
| `accounts.txt` | `email:password:sso` |
| `grok.txt` | 每行一个 SSO JWT |
可粘贴 JSON / 数组 / jsonl或拖入多个文件。
## 输出格式
### CPA
对齐注册机 cpa_ready
- `type: "xai"`
- `access_token` / `refresh_token` / `id_token`
- `base_url` **强制** `https://cli-chat-proxy.grok.com/v1`(忽略 authenticated 里的 `api.x.ai`
- `headers`:保留源 headers否则写 grok-cli 默认头
- `email` / `sub` / `expired` / `expires_in`
单条输出对象,多条输出数组。
### sub2api 导入
`type: "sub2api-data"` 包,可走 sub2api 数据导入:
```json
{
"type": "sub2api-data",
"version": 1,
"exported_at": "...",
"proxies": [],
"accounts": [ ... ]
}
```
账号字段:
- `platform: "grok"`, `type: "oauth"`
- `credentials.access_token` / `refresh_token` / `id_token` / `expires_at` / `email` / `sub` / `base_url`
- **有 `refresh_token`**:不写账号顶层 `expires_at` / `auto_pause_on_expired`(避免 access 6h 到期被 pause
- **无 `refresh_token`**:写 access JWT exp + `auto_pause_on_expired: true`
### batch 建号
`POST /api/v1/admin/accounts/batch` 体:
```json
{ "accounts": [ /* 同上 sub2api 账号对象 */ ] }
```
### sso-to-oauth
`POST /api/v1/admin/grok/sso-to-oauth` 体:
```json
{
"sso_tokens": ["eyJ...", "..."],
"_meta": [ /* 仅本地对照API 可忽略 */ ]
}
```
纯 SSO 输入grok.txt / accounts.txt / auth-sessions**只能**走这个格式CPA / sub2api / batch 需要 OAuth。
## 关键规则(别踩坑)
1. free 号 API 走 `cli-chat-proxy.grok.com`,不是 `api.x.ai`
2. free OAuth access ≈ 6h`expires_in=21600`)。有 refresh 时让 sub2api 自己刷;别把账号级 `expires_at` 钉成 access exp。
3. 当前线上常见refresh 被整批 revokeaccess 在 exp 前仍可用。这种号导入后到期无法续命,只能重铸。
4. 注册机 `grok.txt` / `accounts.txt` / `auth-sessions.jsonl` 只有 SSO没有 OAuth要进 sub2api 要么走 sso-to-oauth要么先走 xai_enroller 出 `authenticated/` 再转。
## 与注册机衔接
```
register → grok.txt / accounts.txt / auth-sessions.jsonl (SSO)
→ xai_enroller → authenticated/xai-*.json (OAuth)
→ acpa_watchdog → cpa_ready/xai-*.json + acc.md
→ 本工具 → sub2api-data / batch / CPA / sso-to-oauth
```

View File

@@ -0,0 +1,902 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Grok Session → CPA / sub2api</title>
<meta name="theme-color" content="#111827" />
<style>
:root {
color-scheme: light dark;
--bg: #0b1220;
--surface: #121a2b;
--surface-soft: #1a2438;
--line: #2a3650;
--text: #e8eefc;
--muted: #93a0bc;
--accent: #38bdf8;
--accent-strong: #0ea5e9;
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Inter", "Avenir Next", "Helvetica Neue", "PingFang SC", "Noto Sans SC", sans-serif;
--radius: 10px;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 28%),
linear-gradient(180deg, #0b1220, #0a101b 40%, #070b14);
}
button, textarea, input { font: inherit; }
button { border: 0; cursor: pointer; }
.app { width: min(1280px, calc(100vw - 28px)); margin: 0 auto; padding: 22px 0 36px; }
.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; margin-bottom: 16px; }
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(1.5rem, 3vw, 2.3rem); line-height: 1.1; }
.subtitle { margin: 10px 0 0; color: var(--muted); line-height: 1.5; max-width: 70ch; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.chip {
display: inline-flex; align-items: center; min-height: 32px; padding: 6px 10px;
border: 1px solid var(--line); border-radius: 999px; background: rgba(18, 26, 43, 0.9); color: var(--muted); font-size: 0.84rem;
}
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.segmented {
display: inline-grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px;
width: min(760px, 100%); padding: 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
}
.segmented button {
min-height: 40px; border-radius: 8px; background: transparent; color: var(--muted); padding: 0 10px;
}
.segmented button[aria-pressed="true"] { background: var(--accent-strong); color: #041018; font-weight: 800; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.button {
display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 14px;
border-radius: var(--radius); transition: 0.15s ease;
}
.button-primary { background: var(--accent-strong); color: #041018; font-weight: 800; }
.button-primary:hover { filter: brightness(1.08); }
.button-secondary { border: 1px solid var(--line); background: var(--surface); color: var(--text); }
.button-secondary:hover { background: var(--surface-soft); }
.button:disabled { opacity: 0.5; cursor: not-allowed; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.panel {
min-width: 0; border: 1px solid var(--line); border-radius: var(--radius);
background: rgba(18, 26, 43, 0.92); box-shadow: var(--shadow);
}
.panel-head { display: flex; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
.panel-head h2 { margin: 0; font-size: 1rem; }
.panel-head p { margin: 4px 0 0; color: var(--muted); font-size: 0.88rem; line-height: 1.45; }
.panel-body { padding: 14px 16px 16px; }
.guide {
margin: 0 0 12px; padding: 12px 14px; border: 1px solid rgba(56, 189, 248, 0.28);
border-left: 4px solid var(--accent); border-radius: var(--radius); background: rgba(14, 165, 233, 0.08);
color: var(--muted); line-height: 1.5; font-size: 0.9rem;
}
.guide strong { color: var(--text); }
.guide code { font-family: var(--mono); color: var(--accent); }
textarea {
width: 100%; min-height: 460px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px; background: #0a1220; color: var(--text); font-family: var(--mono); font-size: 0.82rem; line-height: 1.5;
}
textarea:focus { outline: 2px solid rgba(56, 189, 248, 0.35); border-color: var(--accent); }
.status-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.status {
min-height: 30px; display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); font-size: 0.84rem; background: rgba(0,0,0,0.15);
}
.status.ok { color: var(--success); border-color: rgba(52, 211, 153, 0.35); }
.status.err { color: var(--danger); border-color: rgba(248, 113, 113, 0.35); }
.status.warn { color: var(--warning); border-color: rgba(251, 191, 36, 0.35); }
.accounts, .issues {
margin-top: 12px; display: grid; gap: 8px; max-height: 180px; overflow: auto;
}
.card {
border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: rgba(0,0,0,0.14);
font-size: 0.86rem; line-height: 1.4;
}
.card .title { font-weight: 700; }
.card .meta { color: var(--muted); margin-top: 4px; font-family: var(--mono); font-size: 0.78rem; word-break: break-all; }
.drop-active textarea { border-color: var(--accent); box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
.footer-note { margin-top: 14px; color: var(--muted); font-size: 0.84rem; line-height: 1.5; }
@media (max-width: 960px) {
.workspace { grid-template-columns: 1fr; }
.segmented { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
.topbar { flex-direction: column; align-items: flex-start; }
.chips { justify-content: flex-start; }
}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div>
<p class="eyebrow">Grok Free Register toolkit</p>
<h1>Grok Session → CPA / sub2api</h1>
<p class="subtitle">
纯前端转换:把注册机 <code>xai-*.json</code> / SSO / accounts / sessions
转成 CPA、sub2api 导入包、batch 建号、或 sso-to-oauth 请求体。本地解析,不上传。
</p>
</div>
<div class="chips">
<span class="chip">platform: grok</span>
<span class="chip">base: cli-chat-proxy</span>
<span class="chip">有 refresh 不写账号 6h 死期</span>
</div>
</div>
<div class="toolbar">
<div class="segmented" role="tablist" aria-label="输出格式">
<button type="button" data-format="cpa" aria-pressed="true">CPA</button>
<button type="button" data-format="sub2api" aria-pressed="false">sub2api 导入</button>
<button type="button" data-format="batch" aria-pressed="false">batch 建号</button>
<button type="button" data-format="sso" aria-pressed="false">sso-to-oauth</button>
</div>
<div class="actions">
<button class="button button-secondary" id="btn-sample" type="button">填示例</button>
<button class="button button-secondary" id="btn-clear" type="button">清空</button>
<button class="button button-secondary" id="btn-copy" type="button" disabled>复制输出</button>
<button class="button button-primary" id="btn-download" type="button" disabled>下载 JSON</button>
</div>
</div>
<div class="workspace">
<section class="panel" id="input-panel">
<div class="panel-head">
<div>
<h2>输入</h2>
<p>粘贴 / 拖入 JSON、JSON 数组、jsonl、accounts 行、纯 SSO 行</p>
</div>
</div>
<div class="panel-body">
<div class="guide">
<strong>支持输入</strong><br />
1. <code>authenticated/xai-*.json</code><code>cpa_ready/xai-*.json</code><br />
2. sub2api account / accounts 数组 / sub2api-data 包<br />
3. <code>auth-sessions.jsonl</code>email + cookies.sso<br />
4. <code>accounts.txt</code><code>email:password:sso</code><br />
5. <code>grok.txt</code>:每行一个 SSO JWT
</div>
<textarea id="session-input" spellcheck="false" placeholder="在此粘贴 xai-*.json / SSO / accounts 行…"></textarea>
<div class="status-row">
<span class="status" id="input-status">等待输入</span>
<span class="status" id="convert-status">未转换</span>
</div>
<div class="accounts" id="accounts"></div>
<div class="issues" id="issues"></div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>输出</h2>
<p id="output-help">CPAtype=xai对齐注册机 cpa_ready / new-api 风格</p>
</div>
</div>
<div class="panel-body">
<textarea id="output" readonly spellcheck="false" placeholder="转换结果会显示在这里"></textarea>
<div class="status-row">
<span class="status" id="output-status"></span>
</div>
<p class="footer-note">
规则:<br />
• free 号 <code>base_url</code> 固定为 <code>https://cli-chat-proxy.grok.com/v1</code>(忽略 authenticated 里的 api.x.ai<br />
• 有 <code>refresh_token</code>sub2api 账号<strong>不写</strong>顶层 <code>expires_at/auto_pause</code>,避免 6h 被 pause<br />
• 无 refresh 时:写 access 的 exp + auto_pause到期停用<br />
• 纯 SSO 输入只能出 <code>sso-to-oauth</code> 请求体CPA/sub2api 需要 OAuth token
</p>
</div>
</section>
</div>
</div>
<script>
(() => {
const CLI_BASE = "https://cli-chat-proxy.grok.com/v1";
const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
const HELP = {
cpa: "CPAtype=xai对齐注册机 cpa_ready / cli-proxy 风格",
sub2api: "sub2api 数据导入type=sub2api-data / accounts[] platform=grok",
batch: "batch 建号POST /api/v1/admin/accounts/batch 的 {accounts:[]}",
sso: "sso-to-oauthPOST /api/v1/admin/grok/sso-to-oauth 的 {sso_tokens:[]}",
};
const els = {
input: document.querySelector("#session-input"),
output: document.querySelector("#output"),
inputStatus: document.querySelector("#input-status"),
convertStatus: document.querySelector("#convert-status"),
outputStatus: document.querySelector("#output-status"),
accounts: document.querySelector("#accounts"),
issues: document.querySelector("#issues"),
outputHelp: document.querySelector("#output-help"),
inputPanel: document.querySelector("#input-panel"),
btnCopy: document.querySelector("#btn-copy"),
btnDownload: document.querySelector("#btn-download"),
btnClear: document.querySelector("#btn-clear"),
btnSample: document.querySelector("#btn-sample"),
formatButtons: [...document.querySelectorAll("[data-format]")],
};
const state = {
format: "cpa",
converted: [],
ssoTokens: [],
skipped: [],
timer: null,
outputText: "",
};
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function firstNonEmpty(...values) {
for (const value of values) {
if (value === undefined || value === null) continue;
if (typeof value === "string" && value.trim() === "") continue;
return typeof value === "string" ? value.trim() : value;
}
return undefined;
}
function setStatus(el, text, tone = "") {
el.textContent = text;
el.className = "status" + (tone ? ` ${tone}` : "");
}
function decodeBase64Url(value) {
const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
try {
return decodeURIComponent(
Array.from(atob(padded), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")
);
} catch {
return null;
}
}
function parseJwtPayload(token) {
if (!token || typeof token !== "string" || token.split(".").length < 2) return null;
try {
const raw = decodeBase64Url(token.split(".")[1]);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function looksLikeJwt(value) {
return typeof value === "string" && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value.trim());
}
function normalizeTimestamp(value) {
if (value === undefined || value === null || value === "") return undefined;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().replace(/\.\d{3}Z$/, "Z");
if (typeof value === "number" && Number.isFinite(value)) {
const ms = value > 1e12 ? value : value * 1000;
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
}
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) {
const ms = n > 1e12 ? n : n * 1000;
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
return undefined;
}
function unixSecondsFromValue(value) {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number" && Number.isFinite(value)) return Math.floor(value > 1e12 ? value / 1000 : value);
if (typeof value === "string") {
const n = Number(value);
if (Number.isFinite(n) && /^\d+(\.\d+)?$/.test(value.trim())) return Math.floor(n > 1e12 ? n / 1000 : n);
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
}
return undefined;
}
function stripUndefined(obj) {
if (Array.isArray(obj)) return obj.map(stripUndefined).filter((v) => v !== undefined);
if (!isPlainObject(obj)) return obj;
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v === undefined || v === null || v === "") continue;
const next = stripUndefined(v);
if (next === undefined) continue;
if (isPlainObject(next) && !Object.keys(next).length) continue;
out[k] = next;
}
return out;
}
function extractSsoFromCookies(cookies) {
if (!Array.isArray(cookies)) return undefined;
let sso = "";
let fallback = "";
for (const cookie of cookies) {
if (!isPlainObject(cookie)) continue;
const name = String(cookie.name || "");
const value = String(cookie.value || "").trim();
if (!value) continue;
if (name === "sso" && !sso) sso = value;
if (name === "sso-rw" && !fallback) fallback = value;
}
return sso || fallback || undefined;
}
function collectOAuthLikeObjects(value, sourceName = "pasted-json") {
const found = [];
const visited = new WeakSet();
function visit(item, path) {
if (!isPlainObject(item) && !Array.isArray(item)) return;
if (Array.isArray(item)) {
item.forEach((child, i) => visit(child, `${path}[${i}]`));
return;
}
if (visited.has(item)) return;
visited.add(item);
const access = firstNonEmpty(
item.access_token, item.accessToken,
item.tokens?.access_token, item.tokens?.accessToken,
item.credentials?.access_token, item.credentials?.accessToken,
);
const refresh = firstNonEmpty(
item.refresh_token, item.refreshToken,
item.tokens?.refresh_token, item.tokens?.refreshToken,
item.credentials?.refresh_token, item.credentials?.refreshToken,
);
const idToken = firstNonEmpty(
item.id_token, item.idToken,
item.tokens?.id_token, item.tokens?.idToken,
item.credentials?.id_token, item.credentials?.idToken,
);
const email = firstNonEmpty(item.email, item.user?.email, item.credentials?.email, item.name);
const sub = firstNonEmpty(item.sub, item.credentials?.sub, item.subject);
const sso = firstNonEmpty(item.sso, item.sso_token, item.ssoToken, extractSsoFromCookies(item.cookies));
const isXai = item.type === "xai" || item.auth_kind === "oauth" || item.platform === "grok";
const isSub2 = item.platform === "grok" && item.credentials;
if (access || (refresh && (email || sub || isXai || isSub2)) || (sso && (email || item.cookies))) {
found.push({ value: item, sourceName, path, kind: access || refresh ? "oauth" : "sso" });
return;
}
// sub2api-data envelope
if (Array.isArray(item.accounts)) {
visit(item.accounts, `${path}.accounts`);
return;
}
for (const [key, child] of Object.entries(item)) {
if (["access_token", "accessToken", "refresh_token", "id_token", "cookies"].includes(key)) continue;
visit(child, `${path}.${key}`);
}
}
visit(value, "$");
return found;
}
function parseTextInput(text) {
const raw = String(text || "").trim();
if (!raw) return { oauthSources: [], ssoTokens: [], skipped: [] };
// Try full JSON first
try {
const parsed = JSON.parse(raw);
const oauthSources = collectOAuthLikeObjects(parsed);
const ssoTokens = [];
// also collect pure sso fields if any
for (const src of oauthSources) {
if (src.kind === "sso") {
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email, src.value.user?.email), path: src.path });
}
}
if (oauthSources.length || ssoTokens.length) {
return { oauthSources: oauthSources.filter((s) => s.kind === "oauth"), ssoTokens, skipped: [] };
}
} catch {
// line mode below
}
const oauthSources = [];
const ssoTokens = [];
const skipped = [];
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
lines.forEach((line, index) => {
// jsonl
if (line.startsWith("{") || line.startsWith("[")) {
try {
const parsed = JSON.parse(line);
const found = collectOAuthLikeObjects(parsed, `line-${index + 1}`);
for (const src of found) {
if (src.kind === "oauth") oauthSources.push(src);
else {
const sso = firstNonEmpty(src.value.sso, src.value.sso_token, extractSsoFromCookies(src.value.cookies));
if (sso) ssoTokens.push({ token: sso, email: firstNonEmpty(src.value.email), path: src.path });
}
}
if (!found.length) skipped.push({ path: `line-${index + 1}`, reason: "JSON 行未识别为 Grok 凭证" });
return;
} catch (error) {
skipped.push({ path: `line-${index + 1}`, reason: `JSON 行解析失败:${error.message}` });
return;
}
}
// accounts.txt: email:password:sso
if (line.includes(":") && !looksLikeJwt(line)) {
const parts = line.split(":");
if (parts.length >= 3) {
const email = parts[0];
const sso = parts.slice(2).join(":");
if (looksLikeJwt(sso)) {
ssoTokens.push({ token: sso.trim(), email: email.trim(), path: `accounts:${index + 1}` });
return;
}
}
}
// pure SSO jwt line
if (looksLikeJwt(line)) {
ssoTokens.push({ token: line.trim(), path: `sso:${index + 1}` });
return;
}
skipped.push({ path: `line-${index + 1}`, reason: "无法识别该行" });
});
return { oauthSources, ssoTokens, skipped };
}
function convertOAuthRecord(record, options = {}) {
if (!isPlainObject(record)) throw new Error("记录不是对象");
// unwrap sub2api account
const credsIn = isPlainObject(record.credentials) ? record.credentials : {};
const accessToken = firstNonEmpty(
record.access_token, record.accessToken,
credsIn.access_token, credsIn.accessToken,
record.tokens?.access_token,
);
if (!accessToken) throw new Error("缺少 access_token");
const refreshToken = firstNonEmpty(
record.refresh_token, record.refreshToken,
credsIn.refresh_token, credsIn.refreshToken,
record.tokens?.refresh_token,
);
const idToken = firstNonEmpty(
record.id_token, record.idToken,
credsIn.id_token, credsIn.idToken,
record.tokens?.id_token,
);
const tokenType = firstNonEmpty(record.token_type, credsIn.token_type, "Bearer");
const accessClaims = parseJwtPayload(accessToken) || {};
const idClaims = parseJwtPayload(idToken) || {};
const email = firstNonEmpty(
record.email, credsIn.email, record.user?.email, record.name,
idClaims.email, accessClaims.email, accessClaims.preferred_username,
);
const sub = firstNonEmpty(
record.sub, credsIn.sub, record.subject,
idClaims.sub, accessClaims.sub, accessClaims.principal_id,
);
const expiresIn = firstNonEmpty(
record.expires_in, credsIn.expires_in,
accessClaims.exp && accessClaims.iat ? Number(accessClaims.exp) - Number(accessClaims.iat) : undefined,
21600,
);
const expiredIso = firstNonEmpty(
normalizeTimestamp(record.expired),
normalizeTimestamp(record.expires_at),
normalizeTimestamp(credsIn.expires_at),
normalizeTimestamp(record.expiresAt),
accessClaims.exp ? normalizeTimestamp(accessClaims.exp) : undefined,
);
const expiresUnix = firstNonEmpty(
unixSecondsFromValue(expiredIso),
unixSecondsFromValue(accessClaims.exp),
);
const lastRefresh = firstNonEmpty(
normalizeTimestamp(record.last_refresh),
normalizeTimestamp(credsIn.last_refresh),
normalizeTimestamp(options.now || new Date()),
);
const baseUrl = CLI_BASE; // free CLI 强制
const hasRefresh = Boolean(refreshToken);
const name = firstNonEmpty(email, sub && `grok-${String(sub).slice(0, 8)}`, options.sourceName, "Grok Account");
const headers = isPlainObject(record.headers) ? record.headers : {
"x-grok-client-version": "0.2.93",
"x-xai-token-auth": "xai-grok-cli",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-authenticateresponse": "authenticate-response",
"x-grok-client-identifier": "grok-shell",
"x-compaction-at": "400000",
"User-Agent": "grok-shell/0.2.93 (linux; x86_64)",
};
const cpa = stripUndefined({
type: "xai",
access_token: accessToken,
refresh_token: refreshToken,
id_token: idToken,
token_type: tokenType,
expires_in: Number(expiresIn) || undefined,
expired: expiredIso,
last_refresh: lastRefresh,
sub,
base_url: baseUrl,
token_endpoint: firstNonEmpty(record.token_endpoint, TOKEN_ENDPOINT),
auth_kind: "oauth",
headers,
email,
});
// sub2api credentials always keep token-level expires_at
const credentials = stripUndefined({
access_token: accessToken,
refresh_token: refreshToken,
id_token: idToken,
token_type: tokenType,
expires_at: expiredIso,
email,
sub,
base_url: baseUrl,
});
// 有 refresh不写账号级 expires_at避免 6h auto-pause
// 无 refresh写 access exp + auto_pause
const sub2apiAccount = stripUndefined({
name,
platform: "grok",
type: "oauth",
credentials,
concurrency: Number.isFinite(Number(record.concurrency)) ? Number(record.concurrency) : 1,
priority: Number.isFinite(Number(record.priority)) ? Number(record.priority) : 0,
expires_at: hasRefresh ? undefined : expiresUnix,
auto_pause_on_expired: hasRefresh ? undefined : (expiresUnix ? true : undefined),
notes: firstNonEmpty(record.notes, record.note, record.remark),
});
return {
sourceName: options.sourceName,
sourcePath: options.sourcePath,
email,
name,
sub,
hasRefresh,
expiresAt: expiredIso,
expiresUnix,
cpa,
sub2apiAccount,
};
}
function convertFromText(text) {
const { oauthSources, ssoTokens, skipped } = parseTextInput(text);
const converted = [];
const allSkipped = [...skipped];
const now = new Date();
oauthSources.forEach((item, index) => {
try {
converted.push(convertOAuthRecord(item.value, {
now,
sourceName: item.sourceName,
sourcePath: item.path || `$[${index}]`,
}));
} catch (error) {
allSkipped.push({
path: item.path || `$[${index}]`,
reason: error instanceof Error ? error.message : "无法转换",
});
}
});
// de-dupe sso
const seenSso = new Set();
const uniqueSso = [];
for (const item of ssoTokens) {
const token = String(item.token || "").trim();
if (!token || seenSso.has(token)) continue;
seenSso.add(token);
uniqueSso.push(item);
}
if (!converted.length && !uniqueSso.length) {
allSkipped.push({ path: "$", reason: "未找到可识别的 Grok OAuth / SSO 记录" });
}
return { converted, ssoTokens: uniqueSso, skipped: allSkipped };
}
function buildSub2apiDocument(converted, now = new Date()) {
return {
type: "sub2api-data",
version: 1,
exported_at: normalizeTimestamp(now),
proxies: [],
accounts: converted.map((item) => item.sub2apiAccount),
};
}
function buildBatchDocument(converted) {
return { accounts: converted.map((item) => item.sub2apiAccount) };
}
function buildSsoDocument(ssoTokens) {
return {
sso_tokens: ssoTokens.map((item) => item.token),
// 便于对照API 实际只吃 sso_tokens
_meta: ssoTokens.map((item, i) => stripUndefined({
index: i + 1,
email: item.email,
path: item.path,
token_head: String(item.token).slice(0, 24) + "...",
})),
};
}
function buildCpaDocument(converted) {
return converted.length === 1
? converted[0].cpa
: converted.map((item) => item.cpa);
}
function buildOutputDocument() {
const now = new Date();
if (state.format === "cpa") {
if (!state.converted.length) throw new Error("CPA 需要 OAuth 凭证access_token。纯 SSO 请切换到 sso-to-oauth");
return buildCpaDocument(state.converted);
}
if (state.format === "sub2api") {
if (!state.converted.length) throw new Error("sub2api 导入需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
return buildSub2apiDocument(state.converted, now);
}
if (state.format === "batch") {
if (!state.converted.length) throw new Error("batch 建号需要 OAuth 凭证。纯 SSO 请切换到 sso-to-oauth");
return buildBatchDocument(state.converted);
}
if (state.format === "sso") {
if (!state.ssoTokens.length) {
// also allow extracting nothing useful message
throw new Error("未找到 SSO token。可粘贴 grok.txt / accounts.txt / auth-sessions.jsonl");
}
return buildSsoDocument(state.ssoTokens);
}
return buildSub2apiDocument(state.converted, now);
}
function renderAccounts() {
if (!state.converted.length && !state.ssoTokens.length) {
els.accounts.innerHTML = "";
return;
}
const oauthCards = state.converted.map((item) => `
<div class="card">
<div class="title">${escapeHtml(item.name || item.email || "Grok Account")}</div>
<div class="meta">
email=${escapeHtml(item.email || "-")}
· sub=${escapeHtml(item.sub || "-")}
· refresh=${item.hasRefresh ? "yes" : "no"}
· exp=${escapeHtml(item.expiresAt || "-")}
</div>
</div>
`).join("");
const ssoCards = state.ssoTokens.slice(0, 20).map((item, i) => `
<div class="card">
<div class="title">SSO #${i + 1}${item.email ? " · " + escapeHtml(item.email) : ""}</div>
<div class="meta">${escapeHtml(String(item.token).slice(0, 48))}...</div>
</div>
`).join("");
els.accounts.innerHTML = oauthCards + ssoCards;
}
function renderIssues() {
if (!state.skipped.length) {
els.issues.innerHTML = "";
return;
}
els.issues.innerHTML = state.skipped.map((item) => `
<div class="card">
<div class="title">跳过 ${escapeHtml(item.path || "?")}</div>
<div class="meta">${escapeHtml(item.reason || "")}</div>
</div>
`).join("");
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function updateOutput() {
els.outputHelp.textContent = HELP[state.format] || "";
try {
if (!els.input.value.trim()) {
state.outputText = "";
els.output.value = "";
els.btnCopy.disabled = true;
els.btnDownload.disabled = true;
setStatus(els.outputStatus, "空");
setStatus(els.convertStatus, "未转换");
return;
}
const doc = buildOutputDocument();
state.outputText = JSON.stringify(doc, null, 2);
els.output.value = state.outputText;
els.btnCopy.disabled = false;
els.btnDownload.disabled = false;
const count = state.format === "sso" ? state.ssoTokens.length : state.converted.length;
setStatus(els.outputStatus, `已生成 · ${count}`, "ok");
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, "ok");
} catch (error) {
state.outputText = "";
els.output.value = "";
els.btnCopy.disabled = true;
els.btnDownload.disabled = true;
setStatus(els.outputStatus, error instanceof Error ? error.message : "生成失败", "err");
setStatus(els.convertStatus, `OAuth ${state.converted.length} / SSO ${state.ssoTokens.length}`, state.converted.length || state.ssoTokens.length ? "warn" : "err");
}
}
function scheduleConvert() {
clearTimeout(state.timer);
state.timer = setTimeout(() => {
try {
const result = convertFromText(els.input.value);
state.converted = result.converted;
state.ssoTokens = result.ssoTokens;
state.skipped = result.skipped;
const total = state.converted.length + state.ssoTokens.length;
setStatus(
els.inputStatus,
total ? `识别 OAuth ${state.converted.length} · SSO ${state.ssoTokens.length}` : "未识别到凭证",
total ? "ok" : "warn",
);
renderAccounts();
renderIssues();
updateOutput();
} catch (error) {
state.converted = [];
state.ssoTokens = [];
state.skipped = [{ path: "$", reason: error instanceof Error ? error.message : "转换失败" }];
setStatus(els.inputStatus, error instanceof Error ? error.message : "转换失败", "err");
renderAccounts();
renderIssues();
updateOutput();
}
}, 120);
}
function downloadOutput() {
if (!state.outputText) return;
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const name = `grok-${state.format}-${stamp}.json`;
const blob = new Blob([state.outputText], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
async function copyOutput() {
if (!state.outputText) return;
try {
await navigator.clipboard.writeText(state.outputText);
setStatus(els.outputStatus, "已复制", "ok");
} catch {
els.output.select();
document.execCommand("copy");
setStatus(els.outputStatus, "已复制", "ok");
}
}
function setFormat(format) {
state.format = format;
els.formatButtons.forEach((btn) => {
btn.setAttribute("aria-pressed", btn.dataset.format === format ? "true" : "false");
});
updateOutput();
}
function fillSample() {
const now = Math.floor(Date.now() / 1000);
const exp = now + 21600;
// fake jwt-like payload (not signed; only for local demo decode)
const header = btoa(JSON.stringify({ alg: "none", typ: "JWT" })).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
const payload = btoa(JSON.stringify({
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
email: "demo@example.com",
iat: now,
exp,
})).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
const access = `${header}.${payload}.demo`;
const sample = {
type: "xai",
access_token: access,
refresh_token: "demo-refresh-token-replace-me",
id_token: access,
token_type: "Bearer",
expires_in: 21600,
expired: new Date(exp * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
last_refresh: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
sub: "55c526cb-9f70-4486-a232-3710e6f3df38",
base_url: "https://api.x.ai/v1",
token_endpoint: TOKEN_ENDPOINT,
auth_kind: "oauth",
email: "demo@example.com",
};
els.input.value = JSON.stringify(sample, null, 2);
scheduleConvert();
}
// events
els.input.addEventListener("input", scheduleConvert);
els.btnClear.addEventListener("click", () => {
els.input.value = "";
scheduleConvert();
});
els.btnSample.addEventListener("click", fillSample);
els.btnCopy.addEventListener("click", copyOutput);
els.btnDownload.addEventListener("click", downloadOutput);
els.formatButtons.forEach((btn) => {
btn.addEventListener("click", () => setFormat(btn.dataset.format));
});
// drag-drop
["dragenter", "dragover"].forEach((type) => {
els.inputPanel.addEventListener(type, (event) => {
event.preventDefault();
els.inputPanel.classList.add("drop-active");
});
});
["dragleave", "drop"].forEach((type) => {
els.inputPanel.addEventListener(type, (event) => {
event.preventDefault();
if (type === "drop") {
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return;
Promise.all(files.map((file) => file.text())).then((parts) => {
const existing = els.input.value.trim();
const incoming = parts.join("\n");
els.input.value = existing ? `${existing}\n${incoming}` : incoming;
scheduleConvert();
});
}
els.inputPanel.classList.remove("drop-active");
});
});
setFormat("cpa");
})();
</script>
</body>
</html>

1
tools/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Developer-only analysis and stress tools."""

View File

@@ -0,0 +1,419 @@
"""
Parse real runtime monitor logs from both CSP and legacy state-machine runs.
The analyzer is intentionally read-only. It does not tune parameters or modify
runtime state; it only turns production logs into comparable stage rates.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
import re
from typing import Iterable
_CSP_RE = re.compile(
r"^\[\*\] T:(?P<t>\d+) Q:(?P<q>\d+) phys:(?P<phys>\d+) "
r"(?:p_send:(?P<p_send>\d+) )?t_slot:(?P<t_slot>\d+) q_slot:(?P<q_slot>\d+) q_pend:(?P<q_pend>\d+)"
r"(?P<rest>.*)rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
)
_STATE_RE = re.compile(
r"^\[\*\] slots:(?P<slots>\d+)/(?P<max_slots>\d+) act:(?P<active>\d+) "
r"cpu:(?P<cpu>[0-9.]+)% avg:(?P<cpu_avg>[0-9.]+)%/(?P<cpu_target>[0-9.]+) "
r"mem:(?P<mem>\d+)M T:(?P<t>\d+) Q:(?P<q>\d+) "
r"sent:(?P<q_sent>\d+) got:(?P<q_ret>\d+)\((?P<q_hit>[0-9.]+)%\) "
r"rate:(?P<rate>[0-9.]+)/min #(?P<ok>\d+)"
)
_SOLVER_TIMELINE_PREFIX = "[solver_timeline] "
@dataclass(frozen=True)
class MonitorRow:
kind: str
t: int
q: int
ok: int
rate: float
phys: int | None = None
p_send: int | None = None
t_slot: int | None = None
q_slot: int | None = None
q_pend: int | None = None
p_batch: float | None = None
t_prog: int | None = None
q_inflight: int | None = None
t_prod: int | None = None
q_sent: int | None = None
q_ret: int | None = None
q_adm: int | None = None
pair: int | None = None
fail: int | None = None
s_phys_wait: float | None = None
s_phys_hold: float | None = None
p_phys_wait: float | None = None
p_phys_hold: float | None = None
c_phys_wait: float | None = None
c_phys_hold: float | None = None
p_email_create: float | None = None
p_page_prepare: float | None = None
p_send_stage: float | None = None
c_page_acquire: float | None = None
c_verify: float | None = None
c_register: float | None = None
c_hot_hits: int | None = None
c_hot_misses: int | None = None
solver_goto: float | None = None
solver_inject: float | None = None
solver_initial: float | None = None
solver_click: float | None = None
solver_wait: float | None = None
solver_reuse: float | None = None
solver_visible: float | None = None
slots: int | None = None
max_slots: int | None = None
active: int | None = None
@property
def elapsed_min(self) -> float | None:
if self.ok <= 0 or self.rate <= 0:
return None
return self.ok / self.rate
@dataclass(frozen=True)
class SolverTimeline:
events: list[dict]
def _int_field(rest: str, name: str) -> int | None:
match = re.search(rf"\b{name}:(\d+)", rest)
return int(match.group(1)) if match else None
def _float_field(rest: str, name: str) -> float | None:
match = re.search(rf"\b{name}:([0-9.]+)", rest)
return float(match.group(1)) if match else None
def _float_pair_field(rest: str, name: str) -> tuple[float | None, float | None]:
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)", rest)
if not match:
return None, None
return float(match.group(1)), float(match.group(2))
def _float_triple_field(rest: str, name: str) -> tuple[float | None, float | None, float | None]:
match = re.search(rf"\b{name}:([0-9.]+)/([0-9.]+)/([0-9.]+)", rest)
if not match:
return None, None, None
return float(match.group(1)), float(match.group(2)), float(match.group(3))
def _int_pair_field(rest: str, name: str) -> tuple[int | None, int | None]:
match = re.search(rf"\b{name}:(\d+)/(\d+)", rest)
if not match:
return None, None
return int(match.group(1)), int(match.group(2))
def parse_monitor_lines(text_or_lines: str | Iterable[str]) -> list[MonitorRow]:
if isinstance(text_or_lines, str):
lines = text_or_lines.splitlines()
else:
lines = list(text_or_lines)
rows: list[MonitorRow] = []
for line in lines:
csp = _CSP_RE.match(line)
if csp:
rest = csp.group("rest")
s_phys_wait, s_phys_hold = _float_pair_field(rest, "s_phys")
p_phys_wait, p_phys_hold = _float_pair_field(rest, "p_phys")
c_phys_wait, c_phys_hold = _float_pair_field(rest, "c_phys")
p_email_create, p_page_prepare, p_send_stage = _float_triple_field(rest, "p_stage")
c_page_acquire, c_verify, c_register = _float_triple_field(rest, "c_stage")
c_hot_hits, c_hot_misses = _int_pair_field(rest, "c_hot")
rows.append(
MonitorRow(
kind="csp",
t=int(csp.group("t")),
q=int(csp.group("q")),
ok=int(csp.group("ok")),
rate=float(csp.group("rate")),
phys=int(csp.group("phys")),
p_send=int(csp.group("p_send")) if csp.group("p_send") is not None else None,
t_slot=int(csp.group("t_slot")),
q_slot=int(csp.group("q_slot")),
q_pend=int(csp.group("q_pend")),
p_batch=_float_field(rest, "p_batch"),
t_prog=_int_field(rest, "t_prog"),
q_inflight=_int_field(rest, "q_inflight"),
t_prod=_int_field(rest, "t_prod"),
q_sent=_int_field(rest, "q_sent"),
q_ret=_int_field(rest, "q_ret"),
q_adm=_int_field(rest, "q_adm"),
pair=_int_field(rest, "pair"),
fail=_int_field(rest, "fail"),
s_phys_wait=s_phys_wait,
s_phys_hold=s_phys_hold,
p_phys_wait=p_phys_wait,
p_phys_hold=p_phys_hold,
c_phys_wait=c_phys_wait,
c_phys_hold=c_phys_hold,
p_email_create=p_email_create,
p_page_prepare=p_page_prepare,
p_send_stage=p_send_stage,
c_page_acquire=c_page_acquire,
c_verify=c_verify,
c_register=c_register,
c_hot_hits=c_hot_hits,
c_hot_misses=c_hot_misses,
solver_goto=_float_field(rest, "solver_goto"),
solver_inject=_float_field(rest, "solver_inject"),
solver_initial=_float_field(rest, "solver_initial"),
solver_click=_float_field(rest, "solver_click"),
solver_wait=_float_field(rest, "solver_wait"),
solver_reuse=_float_field(rest, "solver_reuse"),
solver_visible=_float_field(rest, "solver_visible"),
)
)
continue
state = _STATE_RE.match(line)
if state:
rows.append(
MonitorRow(
kind="state_machine",
t=int(state.group("t")),
q=int(state.group("q")),
ok=int(state.group("ok")),
rate=float(state.group("rate")),
q_sent=int(state.group("q_sent")),
q_ret=int(state.group("q_ret")),
slots=int(state.group("slots")),
max_slots=int(state.group("max_slots")),
active=int(state.group("active")),
)
)
return rows
def _rate_delta(rows: list[MonitorRow], attr: str) -> float | None:
candidates = [r for r in rows if r.elapsed_min is not None and getattr(r, attr) is not None]
if len(candidates) < 2:
return None
first = candidates[0]
last = candidates[-1]
dt = (last.elapsed_min or 0) - (first.elapsed_min or 0)
if dt <= 0:
return None
return (getattr(last, attr) - getattr(first, attr)) / dt
def _leader(values: dict[str, float | None]) -> str | None:
if any(value is None for value in values.values()):
return None
return max(values, key=lambda key: values[key] or 0)
def _avg(values: list[float]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 2)
def _ratio(numerator: int, denominator: int) -> float | None:
if denominator <= 0:
return None
return round(numerator / denominator, 3)
def summarize_monitor_rows(rows: list[MonitorRow], recent_count: int = 6) -> dict[str, float | int | str | None]:
if not rows:
return {"rows": 0}
last = rows[-1]
recent = rows[-recent_count:]
summary: dict[str, float | int | str | None] = {
"rows": len(rows),
"kind": last.kind,
"last_ok": last.ok,
"last_cumulative_rate": last.rate,
"last_q_minus_t": last.q - last.t,
"last_phys": last.phys,
"last_p_send_sem": last.p_send,
"last_t_slot": last.t_slot,
"last_q_slot": last.q_slot,
"last_q_pend": last.q_pend,
"last_p_batch": last.p_batch,
"last_t_prog": last.t_prog,
"last_q_inflight": last.q_inflight,
"last_q_return_minus_t_prod": (
last.q_ret - last.t_prod
if last.q_ret is not None and last.t_prod is not None
else None
),
"last_slots": last.slots,
"last_t_prod": last.t_prod,
"last_q_ret": last.q_ret,
"last_q_adm": last.q_adm,
"last_pair": last.pair,
"last_fail": last.fail,
"last_s_phys_wait": last.s_phys_wait,
"last_s_phys_hold": last.s_phys_hold,
"last_p_phys_wait": last.p_phys_wait,
"last_p_phys_hold": last.p_phys_hold,
"last_c_phys_wait": last.c_phys_wait,
"last_c_phys_hold": last.c_phys_hold,
"last_p_email_create": last.p_email_create,
"last_p_page_prepare": last.p_page_prepare,
"last_p_send": last.p_send_stage,
"last_c_page_acquire": last.c_page_acquire,
"last_c_verify": last.c_verify,
"last_c_register": last.c_register,
"last_c_hot_hits": last.c_hot_hits,
"last_c_hot_misses": last.c_hot_misses,
"last_physical_hold_leader": _leader({
"s": last.s_phys_hold,
"p": last.p_phys_hold,
"c": last.c_phys_hold,
}),
"last_physical_wait_leader": _leader({
"s": last.s_phys_wait,
"p": last.p_phys_wait,
"c": last.c_phys_wait,
}),
"last_solver_goto": last.solver_goto,
"last_solver_inject": last.solver_inject,
"last_solver_initial": last.solver_initial,
"last_solver_click": last.solver_click,
"last_solver_wait": last.solver_wait,
"last_solver_reuse": last.solver_reuse,
"last_solver_visible": last.solver_visible,
"recent_ok_per_min": _rate_delta(recent, "ok"),
"recent_t_prod_per_min": _rate_delta(recent, "t_prod"),
"recent_q_ret_per_min": _rate_delta(recent, "q_ret"),
"recent_pair_per_min": _rate_delta(recent, "pair"),
}
return summary
def parse_solver_timelines(text_or_lines: str | Iterable[str]) -> list[SolverTimeline]:
if isinstance(text_or_lines, str):
lines = text_or_lines.splitlines()
else:
lines = list(text_or_lines)
timelines: list[SolverTimeline] = []
for line in lines:
if not line.startswith(_SOLVER_TIMELINE_PREFIX):
continue
payload = line[len(_SOLVER_TIMELINE_PREFIX):]
try:
events = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(events, list):
timelines.append(SolverTimeline(events=[e for e in events if isinstance(e, dict)]))
return timelines
def _last_page_trace(events: list[dict]) -> dict | None:
for event in reversed(events):
page_trace = event.get("page_trace")
if isinstance(page_trace, dict):
return page_trace
return None
def summarize_solver_timelines(timelines: list[SolverTimeline]) -> dict[str, float | int | None]:
if not timelines:
return {"solver_timeline_count": 0}
ok_count = 0
click_calls: list[float] = []
click_move_ms: list[float] = []
click_down_up_ms: list[float] = []
render_to_token_ms: list[float] = []
token_write_to_poll_done_ms: list[float] = []
poll_attempts: list[float] = []
poll_read_avg_ms: list[float] = []
click_before_count = 0
center_iframe_hits = 0
turnstile_iframe_seen = 0
widget_seen = 0
for timeline in timelines:
events = timeline.events
page_trace = _last_page_trace(events)
if page_trace:
render_called = page_trace.get("render_called_at")
token_written = page_trace.get("token_written_at")
if isinstance(render_called, (int, float)) and isinstance(token_written, (int, float)):
render_to_token_ms.append(float(token_written) - float(render_called))
for event in events:
if event.get("event") == "click_before":
click_before_count += 1
dom = event.get("dom") if isinstance(event.get("dom"), dict) else {}
widget = dom.get("widget") if isinstance(dom.get("widget"), dict) else {}
center = dom.get("element_at_center") if isinstance(dom.get("element_at_center"), dict) else {}
if widget.get("present") and widget.get("visible"):
widget_seen += 1
if center.get("is_iframe"):
center_iframe_hits += 1
if (dom.get("turnstile_iframe_count") or 0) > 0:
turnstile_iframe_seen += 1
if event.get("event") == "click_after":
call_ms = event.get("click_call_ms")
if isinstance(call_ms, (int, float)):
click_calls.append(float(call_ms))
trace = event.get("click_trace") if isinstance(event.get("click_trace"), dict) else {}
move1 = trace.get("mouse_move1_ms")
move2 = trace.get("mouse_move2_ms")
down = trace.get("mouse_down_ms")
up = trace.get("mouse_up_ms")
move_total = sum(float(v) for v in (move1, move2) if isinstance(v, (int, float)))
down_up_total = sum(float(v) for v in (down, up) if isinstance(v, (int, float)))
if move_total:
click_move_ms.append(move_total)
if down_up_total:
click_down_up_ms.append(down_up_total)
if event.get("event") == "poll_done":
if event.get("ok"):
ok_count += 1
attempts = event.get("poll_attempts")
if isinstance(attempts, (int, float)):
poll_attempts.append(float(attempts))
read_avg = event.get("poll_read_ms_avg")
if isinstance(read_avg, (int, float)):
poll_read_avg_ms.append(float(read_avg))
page_trace = event.get("page_trace") if isinstance(event.get("page_trace"), dict) else {}
token_written = page_trace.get("token_written_at")
event_t = event.get("t")
created_at = page_trace.get("created_at")
if all(isinstance(v, (int, float)) for v in (token_written, event_t, created_at)):
token_write_to_poll_done_ms.append((float(event_t) * 1000.0) - (float(token_written) - float(created_at)))
return {
"solver_timeline_count": len(timelines),
"ok_count": ok_count,
"avg_click_call_ms": _avg(click_calls),
"avg_click_mouse_move_ms": _avg(click_move_ms),
"avg_click_down_up_ms": _avg(click_down_up_ms),
"avg_render_to_token_ms": _avg(render_to_token_ms),
"avg_token_write_to_poll_done_ms": _avg(token_write_to_poll_done_ms),
"avg_poll_attempts": _avg(poll_attempts),
"avg_poll_read_ms": _avg(poll_read_avg_ms),
"widget_seen_ratio": _ratio(widget_seen, click_before_count),
"center_iframe_hit_ratio": _ratio(center_iframe_hits, click_before_count),
"turnstile_iframe_seen_ratio": _ratio(turnstile_iframe_seen, click_before_count),
}
def analyze_text(text: str) -> dict[str, float | int | str | None]:
return summarize_monitor_rows(parse_monitor_lines(text))

3
xai_enroller/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Standalone xAI OAuth Device Flow enroller."""
__version__ = "0.1.0"

111
xai_enroller/__main__.py Normal file
View File

@@ -0,0 +1,111 @@
import argparse
import asyncio
import httpx
from .config import Settings
from .coordinator import EnrollmentCoordinator
from .executors import HTTPProbeExecutor, PlaywrightExecutor
from .protocol import XAIProfile, XAIProtocol
from .sinks import CPAAuthFileSink
from .sources import FileSourceAdapter, SQLiteSourceAdapter
def build_parser():
parser = argparse.ArgumentParser(description="Bounded xAI OAuth Device Flow enroller")
parser.add_argument("--source", choices=("file", "sqlite"))
parser.add_argument("--target", type=int)
parser.add_argument("--concurrency", type=int)
parser.add_argument("--retry-attempts", type=int)
parser.add_argument("--executor", choices=("http", "playwright"))
parser.add_argument("--sink", choices=("cpa",))
return parser
async def main_async(args=None):
parsed = build_parser().parse_args(args)
env = dict()
if parsed.source:
env["XAI_ENROLLER_SOURCE_KIND"] = parsed.source
if parsed.target is not None:
env["XAI_ENROLLER_TARGET"] = str(parsed.target)
if parsed.concurrency is not None:
env["XAI_ENROLLER_CONCURRENCY"] = str(parsed.concurrency)
if parsed.retry_attempts is not None:
env["XAI_ENROLLER_RETRY_ATTEMPTS"] = str(parsed.retry_attempts)
if parsed.executor:
env["XAI_ENROLLER_AUTH_EXECUTOR"] = parsed.executor
if parsed.sink:
env["XAI_ENROLLER_SINK"] = parsed.sink
import os
merged = dict(os.environ)
merged.update(env)
settings = Settings.from_environ(merged)
if settings.source_kind == "remote":
raise ValueError("remote source requires the local auth service entrypoint")
source = (
FileSourceAdapter(settings.source_file)
if settings.source_kind == "file"
else SQLiteSourceAdapter(settings.source_db, settings.source_salt)
)
# CF prewarm (external FlareSolverr) + register-side proxy for httpx/Playwright
try:
from grok_register.clearance import (
format_prewarm_log,
httpx_proxy_mounts,
prewarm_clearance,
)
print(format_prewarm_log(prewarm_clearance()))
proxy_url = httpx_proxy_mounts()
except Exception as exc: # noqa: BLE001
print(f"[clearance] skip: {exc}")
proxy_url = None
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
protocol = XAIProtocol(
client,
XAIProfile.default(),
default_poll_interval=settings.poll_interval,
)
executor = (
HTTPProbeExecutor(client)
if settings.executor == "http"
else PlaywrightExecutor(settings.concurrency)
)
sink = None
sink_client = None
if settings.sink == "cpa":
sink_client = (
httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
)
sink = CPAAuthFileSink(settings.cpa_base_url, settings.cpa_management_secret, sink_client)
try:
coordinator = EnrollmentCoordinator(
source=source,
protocol=protocol,
executor=executor,
sink=sink,
ledger_path=settings.ledger_path,
ledger_salt=settings.source_salt,
concurrency=settings.concurrency,
timeout=settings.timeout_sec,
retry_attempts=settings.retry_attempts,
)
results = await coordinator.run(settings.target)
for index, result in enumerate(results, 1):
print(f"job {index}: {result.status.value} ({result.reason_code})")
finally:
await client.aclose()
if sink_client:
await sink_client.aclose()
def main():
asyncio.run(main_async())
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

139
xai_enroller/config.py Normal file
View File

@@ -0,0 +1,139 @@
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
def _int(env, key, default):
try:
return int(env.get(key, str(default)))
except ValueError as exc:
raise ValueError(f"{key} must be an integer") from exc
def _float(env, key, default):
try:
return float(env.get(key, str(default)))
except ValueError as exc:
raise ValueError(f"{key} must be numeric") from exc
@dataclass(frozen=True)
class Settings:
source_kind: str
source_file: Path | None
source_db: Path | None
source_salt: bytes
ledger_path: Path
target: int = 1
concurrency: int = 1
retry_attempts: int = 0
timeout_sec: float = 1800.0
poll_interval: float = 5.0
executor: str = "http"
sink: str | None = None
cpa_base_url: str | None = None
cpa_management_secret: str | None = None
local_auth_dir: Path | None = None
@classmethod
def from_environ(cls, env=None):
env = dict(os.environ if env is None else env)
source_kind = env.get("XAI_ENROLLER_SOURCE_KIND", "file")
if source_kind not in {"file", "sqlite", "remote"}:
raise ValueError("source kind must be file, sqlite, or remote")
source_file = Path(env["XAI_ENROLLER_SOURCE_FILE"]) if env.get("XAI_ENROLLER_SOURCE_FILE") else None
source_db = Path(env["XAI_ENROLLER_SOURCE_DB"]) if env.get("XAI_ENROLLER_SOURCE_DB") else None
if source_kind == "file" and source_file is None:
raise ValueError("source file is required")
if source_kind == "sqlite" and source_db is None:
raise ValueError("source db is required")
salt = env.get("XAI_ENROLLER_SOURCE_SALT")
if not salt:
raise ValueError("source salt is required")
ledger_path = Path(env.get("XAI_ENROLLER_LEDGER_PATH", "xai-enroller-ledger.db"))
target = _int(env, "XAI_ENROLLER_TARGET", 1)
concurrency = _int(env, "XAI_ENROLLER_CONCURRENCY", 1)
retry_attempts = _int(env, "XAI_ENROLLER_RETRY_ATTEMPTS", 0)
timeout_sec = _float(env, "XAI_ENROLLER_TIMEOUT_SEC", 1800)
poll_interval = _float(env, "XAI_ENROLLER_POLL_SEC", 5)
executor = env.get("XAI_ENROLLER_AUTH_EXECUTOR", "http")
sink = env.get("XAI_ENROLLER_SINK") or None
if not 1 <= target <= 100:
raise ValueError("target must be between 1 and 100")
if not 1 <= concurrency <= 4:
raise ValueError("concurrency must be between 1 and 4")
if not 0 <= retry_attempts <= 3:
raise ValueError("retry attempts must be between 0 and 3")
if timeout_sec <= 0:
raise ValueError("timeout must be positive")
if poll_interval <= 0:
raise ValueError("poll interval must be positive")
if executor not in {"http", "playwright"}:
raise ValueError("executor must be http or playwright")
if sink not in {None, "cpa", "local"}:
raise ValueError("sink must be cpa or local")
cpa_url = env.get("XAI_ENROLLER_CPA_BASE_URL") or None
cpa_secret = env.get("XAI_ENROLLER_CPA_MANAGEMENT_SECRET") or None
local_auth_dir = (
Path(env["XAI_ENROLLER_LOCAL_AUTH_DIR"]).expanduser()
if env.get("XAI_ENROLLER_LOCAL_AUTH_DIR")
else None
)
if sink == "cpa":
if not cpa_url or not cpa_secret:
raise ValueError("CPA base URL and management secret are required")
parsed = urlparse(cpa_url)
is_private = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not is_private:
raise ValueError("CPA base URL must use HTTPS")
if sink == "local" and local_auth_dir is None:
raise ValueError("local auth directory is required")
return cls(
source_kind=source_kind,
source_file=source_file,
source_db=source_db,
source_salt=salt.encode(),
ledger_path=ledger_path,
target=target,
concurrency=concurrency,
retry_attempts=retry_attempts,
timeout_sec=timeout_sec,
poll_interval=poll_interval,
executor=executor,
sink=sink,
cpa_base_url=cpa_url,
cpa_management_secret=cpa_secret,
local_auth_dir=local_auth_dir,
)
def redacted_dict(self):
return {
"source_kind": self.source_kind,
"source_file": str(self.source_file) if self.source_file else None,
"source_db": str(self.source_db) if self.source_db else None,
"ledger_path": str(self.ledger_path),
"target": self.target,
"concurrency": self.concurrency,
"retry_attempts": self.retry_attempts,
"timeout_sec": self.timeout_sec,
"poll_interval": self.poll_interval,
"executor": self.executor,
"sink": self.sink,
"cpa_base_url": self.cpa_base_url,
"local_auth_dir": str(self.local_auth_dir) if self.local_auth_dir else None,
}
def require_private_regular_file(path: Path, *, require_0600=False):
try:
mode = path.stat()
except OSError as exc:
raise ValueError(f"source path is unavailable: {path}") from exc
if not stat.S_ISREG(mode.st_mode):
raise ValueError("source path must be a regular file")
if mode.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ValueError("source path must not be group/world writable")
if require_0600 and mode.st_mode & (stat.S_IRGRP | stat.S_IROTH):
raise ValueError("file source requires mode 0600 or stricter")

177
xai_enroller/coordinator.py Normal file
View File

@@ -0,0 +1,177 @@
import asyncio
from contextlib import suppress
import httpx
from .ledger import Ledger
from .models import (
AuthorizationStatus,
JobResult,
JobStatus,
)
class EnrollmentCoordinator:
def __init__(
self,
*,
source,
protocol,
executor,
sink,
ledger_path,
ledger_salt,
concurrency=1,
timeout=1800,
retry_attempts=0,
event_callback=None,
):
self.source = source
self.protocol = protocol
self.executor = executor
self.sink = sink
self.ledger = Ledger(ledger_path, ledger_salt)
self.concurrency = concurrency
self.timeout = timeout
self.retry_attempts = max(0, retry_attempts)
self.event_callback = event_callback
self._semaphore = asyncio.Semaphore(concurrency)
@staticmethod
def _is_pre_device_transport_failure(error):
return isinstance(error, (httpx.TransportError, OSError))
def _emit(self, kind, data):
if self.event_callback is None:
return
try:
self.event_callback(kind, data)
except Exception:
pass
async def run(self, target=1):
return await self.run_records(self.source.records(), target=target)
async def run_records(self, records, target=100):
if not 1 <= target <= 100:
raise ValueError("target must be between 1 and 100")
self.ledger.recover_pending()
results = []
tasks = []
index = 0
if hasattr(records, "__aiter__"):
async for source in records:
if index >= target:
break
index += 1
tasks.append(asyncio.create_task(self._run_one(source)))
else:
for source in records:
if index >= target:
break
index += 1
tasks.append(asyncio.create_task(self._run_one(source)))
if hasattr(self.executor, "start"):
await self.executor.start()
try:
if tasks:
results = await asyncio.gather(*tasks)
return results
finally:
for task in tasks:
if not task.done():
task.cancel()
if hasattr(self.executor, "close"):
with suppress(Exception):
await self.executor.close()
async def _run_one(self, source):
async with self._semaphore:
for attempt in range(1, self.retry_attempts + 2):
job_id = self.ledger.start(source.source_id, attempt=attempt)
try:
async with asyncio.timeout(self.timeout):
try:
flow = await self.protocol.start_device_flow()
except Exception as error:
result = JobResult(
source.source_id,
JobStatus.TRANSPORT_FAILED,
"device_flow_failed",
attempt,
)
self.ledger.finish(job_id, result.status, result.reason_code)
if (
self._is_pre_device_transport_failure(error)
and attempt <= self.retry_attempts
):
continue
return result
self._emit(
"device_flow",
{
"source_id": source.source_id,
"user_code": flow.user_code,
"verification_url": flow.verification_url,
},
)
return await self._attempt_after_flow(source, job_id, flow, attempt)
except asyncio.TimeoutError:
result = JobResult(source.source_id, JobStatus.TIMEOUT, "timeout", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
except asyncio.CancelledError:
result = JobResult(source.source_id, JobStatus.CANCELLED, "cancelled", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
raise
except Exception:
result = JobResult(
source.source_id, JobStatus.TRANSPORT_FAILED, "transport_failed", attempt
)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
async def _attempt_after_flow(self, source, job_id, flow, attempt):
authorization = await self.executor.confirm(source, flow)
if authorization.status is not AuthorizationStatus.AUTHORIZED:
status = JobStatus(authorization.status.value)
result = JobResult(source.source_id, status, authorization.reason_code, attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
if self.sink is None:
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_unconfigured", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
try:
credential = await self.protocol.poll_token(
endpoint=flow.token_endpoint,
flow=flow,
timeout=self.timeout,
)
except RuntimeError as error:
reason = str(error)
mapping = {
"oauth_denied": JobStatus.OAUTH_DENIED,
"oauth_expired": JobStatus.OAUTH_EXPIRED,
"oauth_rejected": JobStatus.OAUTH_REJECTED,
}
result = JobResult(
source.source_id, mapping.get(reason, JobStatus.TRANSPORT_FAILED), reason, attempt
)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
try:
receipt = await self.sink.store(credential)
except Exception:
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_failed", attempt)
self.ledger.finish(job_id, result.status, result.reason_code)
return result
result = JobResult(
source.source_id,
JobStatus.IMPORTED,
"imported",
attempt,
sink_receipt_fingerprint=receipt.fingerprint,
)
self.ledger.finish(job_id, result.status, result.reason_code, receipt.fingerprint)
return result

595
xai_enroller/executors.py Normal file
View File

@@ -0,0 +1,595 @@
import asyncio
import glob
import importlib
import os
from contextlib import suppress
from urllib.parse import parse_qs, urlparse
import httpx
from grok_register.clearance import (
apply_clearance_to_context,
cached_user_agent,
playwright_proxy_settings,
)
from .models import AuthorizationResult, AuthorizationStatus, DeviceFlow, SourceRecord
class HTTPProbeExecutor:
def __init__(self, client: httpx.AsyncClient, max_body=64 * 1024):
self.client = client
self.max_body = max_body
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
parsed = urlparse(flow.verification_url)
if parsed.scheme != "https" or parsed.hostname != "accounts.x.ai":
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "invalid_url")
response = await self.client.get(
flow.verification_url,
headers={"Cookie": f"sso={source.sso_token}"},
follow_redirects=False,
)
body = await response.aread()
if len(body) > self.max_body:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "response_too_large")
text = body.decode("utf-8", errors="replace").lower()
if response.status_code == 403 and any(
marker in text for marker in ("challenge", "cf-chl", "captcha")
):
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
if response.status_code in {301, 302, 303, 307, 308}:
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "redirect")
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "http_probe_inconclusive"
)
class PlaywrightExecutor:
ALLOWED_CONTROLS = frozenset({"Authorize", "Allow", "Continue", "Confirm"})
ATTEMPT_TIMEOUT_SECONDS = 55.0
CLOSE_TIMEOUT_SECONDS = 5.0
# Keep confirm loops tight: most successful device verifies finish under ~15s.
LOOP_DEADLINE_SECONDS = 28.0
COOKIE_WAIT_MS = 250
CONSENT_WAIT_MS = 350
CODE_WAIT_MS = 300
CHALLENGE_WAIT_MS = 700
POLL_WAIT_MS = 250
# Drop heavy assets that never participate in device verification.
BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
BLOCKED_URL_MARKERS = (
"google-analytics",
"googletagmanager",
"doubleclick",
"facebook.net",
"hotjar",
"segment.io",
)
def __init__(self, concurrency=1, playwright_factory=None, executable_path=None):
self.concurrency = concurrency
self.playwright_factory = playwright_factory
self.executable_path = executable_path or self._find_executable_path()
self._playwright = None
self._browser = None
self._semaphore = asyncio.Semaphore(concurrency)
self._lifecycle_lock = asyncio.Lock()
@staticmethod
def _find_executable_path():
configured = os.environ.get("XAI_ENROLLER_BROWSER_EXECUTABLE")
if configured:
return configured
candidates = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
candidates.extend(
glob.glob(
os.path.expanduser(
"~/.cloakbrowser/chromium-*/Chromium.app/Contents/MacOS/Chromium"
)
)
)
return sorted(candidates)[-1] if candidates else None
async def start(self):
async with self._lifecycle_lock:
if self._browser is not None:
is_connected = getattr(self._browser, "is_connected", None)
if not callable(is_connected) or is_connected():
return
await self._close_unlocked()
factory = self.playwright_factory
if factory is None:
module = importlib.import_module("playwright.async_api")
factory = module.async_playwright
playwright = await factory().start()
options = {"headless": True}
if self.executable_path:
options["executable_path"] = self.executable_path
# REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY → this process only
proxy = playwright_proxy_settings()
if proxy:
options["proxy"] = proxy
try:
browser = await playwright.chromium.launch(**options)
except BaseException:
await self._close_transport_resource(playwright.stop())
raise
self._playwright = playwright
self._browser = browser
async def close(self):
async with self._lifecycle_lock:
await self._close_unlocked()
async def _close_unlocked(self):
browser = self._browser
playwright = self._playwright
self._browser = None
self._playwright = None
try:
if browser is not None:
await self._close_transport_resource(browser.close())
finally:
if playwright is not None:
await self._close_transport_resource(playwright.stop())
@staticmethod
def _consume_task_result(task):
with suppress(BaseException):
task.result()
@classmethod
async def _close_transport_resource(cls, awaitable):
task = asyncio.create_task(awaitable)
try:
done, _pending = await asyncio.wait(
{task}, timeout=cls.CLOSE_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
task.cancel()
task.add_done_callback(cls._consume_task_result)
raise
if task in done:
cls._consume_task_result(task)
return True
task.cancel()
task.add_done_callback(cls._consume_task_result)
return False
async def _recycle_browser(self, browser):
async with self._lifecycle_lock:
if self._browser is browser:
await self._close_unlocked()
async def __aenter__(self):
await self.start()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
@classmethod
async def _close_attempt_resources(cls, page, context):
async def close_session():
closers = []
if page is not None:
closers.append(page.close())
if context is not None:
closers.append(context.close())
if closers:
await asyncio.gather(*closers, return_exceptions=True)
cleanup = asyncio.create_task(close_session())
cancelled = False
deadline = asyncio.get_running_loop().time() + cls.CLOSE_TIMEOUT_SECONDS
while not cleanup.done():
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
break
try:
await asyncio.wait({cleanup}, timeout=remaining)
except asyncio.CancelledError:
cancelled = True
if cleanup.done():
cls._consume_task_result(cleanup)
else:
# asyncio.wait_for() is not a hard deadline: it cancels the child and
# then waits for cancellation to finish, which a wedged Playwright
# transport may never do. Abandon the cleanup task after one real
# wall-clock deadline and consume its eventual result asynchronously.
cleanup.cancel()
cleanup.add_done_callback(cls._consume_task_result)
if cancelled:
raise asyncio.CancelledError
return cleanup.done()
@staticmethod
async def _click_visible_exact(page, names):
for name in names:
try:
button = page.get_by_role("button", name=name, exact=True)
except TypeError:
button = page.get_by_role("button", name=name)
for index in range(await button.count()):
candidate = button.nth(index) if hasattr(button, "nth") else button.first
is_visible = getattr(candidate, "is_visible", None)
if is_visible is None or await is_visible():
await candidate.click()
return True
return False
@staticmethod
async def _click_visible_turnstile(page):
"""Click a visible Turnstile widget using the registration solver motion."""
for selector in (
".cf-turnstile",
"iframe[src*='challenges.cloudflare.com']",
"iframe[src*='turnstile']",
):
locator = page.locator(selector).first
if not await locator.count() or not await locator.is_visible():
continue
box = await locator.bounding_box()
if not box:
continue
x = box["x"] + box["width"] / 2
y = box["y"] + box["height"] / 2
await page.mouse.move(max(0, x - 25), max(0, y - 8))
await page.mouse.move(x, y, steps=8)
await page.mouse.down()
await asyncio.sleep(0.05)
await page.mouse.up()
return True
return False
@staticmethod
def _expanded_cookies(source):
allowed = {
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
cookies = []
for source_cookie in source.cookies:
cookie = {
key: value for key, value in source_cookie.items() if key in allowed
}
if cookie.get("domain"):
cookie.pop("url", None)
elif cookie.get("url"):
cookie.pop("domain", None)
cookie.pop("path", None)
cookies.append(cookie)
if not cookies:
cookies = [
{
"name": "sso",
"value": source.sso_token,
"domain": "accounts.x.ai",
"path": "/",
"secure": True,
"httpOnly": True,
}
]
seen = {
(cookie.get("name"), cookie.get("domain"), cookie.get("path", "/"))
for cookie in cookies
}
for cookie in list(cookies) if source.cookies else ():
name = cookie.get("name", "")
if not name.startswith("sso"):
continue
key = (name, ".x.ai", cookie.get("path", "/"))
if key in seen:
continue
clone = dict(cookie)
clone.pop("url", None)
clone["domain"] = ".x.ai"
clone.setdefault("path", "/")
cookies.append(clone)
seen.add(key)
return cookies
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
await self.start()
async with self._semaphore:
browser = self._browser
attempt = asyncio.create_task(
self._confirm_once(browser, source, flow)
)
try:
done, _pending = await asyncio.wait(
{attempt}, timeout=self.ATTEMPT_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
raise
if attempt in done:
return attempt.result()
# A Playwright transport can swallow cancellation while one of its
# RPCs is wedged. Do not let asyncio.wait_for() extend the nominal
# timeout indefinitely: detach the attempt and replace the complete
# browser transport before admitting the next account.
attempt.cancel()
attempt.add_done_callback(self._consume_task_result)
await self._recycle_browser(browser)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
async def _install_request_filters(self, page):
"""Skip non-essential resources so device verification spends less wall time."""
async def route_handler(route):
request = route.request
resource_type = getattr(request, "resource_type", "") or ""
url = getattr(request, "url", "") or ""
if resource_type in self.BLOCKED_RESOURCE_TYPES or any(
marker in url for marker in self.BLOCKED_URL_MARKERS
):
await route.abort()
return
await route.continue_()
with suppress(Exception):
await page.route("**/*", route_handler)
async def _confirm_once(self, browser, source: SourceRecord, flow: DeviceFlow):
context = None
page = None
code_submitted = False
consent_submitted = False
challenge_clicks = 0
try:
context_kwargs = {}
ua = cached_user_agent()
if ua:
context_kwargs["user_agent"] = ua
context = await browser.new_context(**context_kwargs)
# CF cookies from external FlareSolverr prewarm (host-level)
await apply_clearance_to_context(context)
page = await context.new_page()
await self._install_request_filters(page)
await context.add_cookies(self._expanded_cookies(source))
await page.goto(flow.verification_url, wait_until="domcontentloaded")
deadline = (
asyncio.get_running_loop().time() + self.LOOP_DEADLINE_SECONDS
)
unknown_since = None
while asyncio.get_running_loop().time() < deadline:
parsed = urlparse(page.url)
if parsed.scheme != "https" or not parsed.hostname or not (
parsed.hostname == "x.ai"
or parsed.hostname.endswith(".x.ai")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unsafe_page"
)
query_error = parse_qs(parsed.query).get("error", [None])[0]
text = await page.locator("body").inner_text()
text_lower = text.lower()
page_title = getattr(page, "title", None)
title_lower = (
(await page_title()).lower() if page_title is not None else ""
)
if query_error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if query_error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "device_verify_failed"
)
if any(
marker in text_lower
for marker in ("rate limit", "too many requests", "请求过于频繁")
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
)
if (
"attention required" in title_lower
and "cloudflare" in title_lower
) or any(
marker in text_lower
for marker in (
"sorry, you have been blocked",
"unable to access x.ai",
"cloudflare ray id",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if "/oauth2/device/done" in parsed.path or any(
marker in text_lower
for marker in ("device authorized", "设备已授权")
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED, "confirmed"
)
cookie_clicked = await self._click_visible_exact(
page,
(
"全部拒绝",
"拒绝全部",
"Reject all",
"Reject All",
),
)
if cookie_clicked:
unknown_since = None
await page.wait_for_timeout(self.COOKIE_WAIT_MS)
continue
if "/oauth2/device/consent" in parsed.path or any(
marker in text_lower
for marker in ("authorize grok build", "授权 grok build")
):
if not consent_submitted:
allowed = await self._click_visible_exact(
page, ("允许", "Allow", "Authorize", "Approve")
)
if allowed:
consent_submitted = True
unknown_since = None
await page.wait_for_timeout(self.CONSENT_WAIT_MS)
continue
else:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
code_input = page.locator('input[name="user_code"]')
try:
code_input_count = await code_input.count()
except AttributeError:
if await self._click_visible_exact(
page, self.ALLOWED_CONTROLS
):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
code_input_count = 0
if code_input_count and await code_input.first.is_visible():
if code_submitted:
await page.wait_for_timeout(self.POLL_WAIT_MS)
continue
unknown_since = None
current = await code_input.first.input_value()
# fill() is far cheaper than press_sequentially and the
# device-code form does not require keystroke events.
if flow.user_code.replace("-", "") not in current.replace(
"-", ""
):
await code_input.first.fill(flow.user_code)
submit = page.locator(
'button[type="submit"], input[type="submit"]'
)
if not await submit.count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"submit_missing",
)
predicate = lambda response: (
urlparse(response.url).hostname == "auth.x.ai"
and urlparse(response.url).path == "/oauth2/device/verify"
)
async with page.expect_response(
predicate, timeout=12000
) as response_info:
code_submitted = True
await submit.first.click()
response = await response_info.value
location = urlparse(response.headers.get("location", ""))
error = parse_qs(location.query).get("error", [None])[0]
if error == "rate_limited":
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"rate_limited",
)
if error:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"device_verify_failed",
)
await page.wait_for_timeout(self.CODE_WAIT_MS)
continue
if any(
marker in text_lower
for marker in (
"continue with email",
"sign in with email",
"使用邮箱登录",
)
) or await page.locator('input[type="password"]').count():
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"login_required",
)
if any(
marker in text_lower
for marker in (
"captcha",
"verify you are human",
"security challenge",
"turnstile",
"人机验证",
"安全验证",
)
):
if (
challenge_clicks < 3
and await self._click_visible_turnstile(page)
):
challenge_clicks += 1
unknown_since = None
await page.wait_for_timeout(self.CHALLENGE_WAIT_MS)
continue
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"challenge_required",
)
if any(
marker in text_lower
for marker in (
"multi-factor",
"two-factor",
"authentication code",
"verification code",
"双重验证",
"验证码",
)
):
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"mfa_required",
)
if await self._click_visible_exact(page, self.ALLOWED_CONTROLS):
return AuthorizationResult(
AuthorizationStatus.AUTHORIZED,
"confirmed",
)
now = asyncio.get_running_loop().time()
if unknown_since is None:
unknown_since = now
elif now - unknown_since >= 2.5:
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "unknown_page"
)
await page.wait_for_timeout(self.POLL_WAIT_MS)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
)
except asyncio.CancelledError:
raise
except Exception as error:
is_timeout = (
error.__class__.__name__ == "TimeoutError"
and error.__class__.__module__.startswith("playwright")
)
return AuthorizationResult(
AuthorizationStatus.NEEDS_INTERACTION,
"confirmation_timeout" if is_timeout else "browser_error",
)
finally:
await self._close_attempt_resources(page, context)

105
xai_enroller/inventory.py Normal file
View File

@@ -0,0 +1,105 @@
import os
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
class InventoryError(RuntimeError):
pass
@dataclass(frozen=True)
class ClaimBatch:
batch_id: str
directory: Path
moved: int
note: str = ""
class CredentialInventory:
def __init__(self, ledger, available_directory, claimed_directory):
self.ledger = ledger
self.available_directory = Path(available_directory)
self.claimed_directory = Path(claimed_directory)
def take(self, limit, note=""):
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise ValueError("claim count must be an integer") from exc
if limit <= 0:
raise ValueError("claim count must be positive")
note = str(note or "").strip()
batch_id = self._new_batch_id()
receipts = self.ledger.claim_available(limit, batch_id)
return self._complete_batch(batch_id, receipts, note)
def recover(self):
rows = self.ledger.pending_claims()
batches = {}
for row in rows:
batches.setdefault(row["batch_id"], []).append(
row["sink_receipt_fingerprint"]
)
recovered = 0
for batch_id, receipts in batches.items():
recovered += self._complete_batch(batch_id, receipts, "").moved
return recovered
@staticmethod
def _new_batch_id():
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return f"{timestamp}-{secrets.token_hex(3)}"
@staticmethod
def _validate_receipt(receipt):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", receipt):
raise InventoryError("invalid credential receipt")
@staticmethod
def _fsync_directory(directory):
descriptor = os.open(directory, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _complete_batch(self, batch_id, receipts, note):
destination_directory = self.claimed_directory / batch_id
if not receipts:
return ClaimBatch(batch_id, destination_directory, 0, note)
self.available_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
self.claimed_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
destination_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
for directory in (
self.available_directory,
self.claimed_directory,
destination_directory,
):
os.chmod(directory, 0o700)
moved = 0
for receipt in receipts:
self._validate_receipt(receipt)
source = self.available_directory / f"{receipt}.json"
destination = destination_directory / source.name
source_exists = source.is_file()
destination_exists = destination.is_file()
if source_exists and destination_exists:
raise InventoryError("credential exists in both inventory locations")
if not source_exists and not destination_exists:
raise InventoryError("credential file is missing")
if source_exists:
os.replace(source, destination)
os.chmod(destination, 0o600)
moved += 1
self._fsync_directory(self.available_directory)
self._fsync_directory(destination_directory)
self._fsync_directory(self.claimed_directory)
marked = self.ledger.mark_claimed(batch_id, note=note)
if marked != len(receipts):
raise InventoryError("inventory batch did not commit completely")
return ClaimBatch(batch_id, destination_directory, moved, note)

275
xai_enroller/ledger.py Normal file
View File

@@ -0,0 +1,275 @@
import hashlib
import hmac
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .models import JobStatus
class Ledger:
def __init__(self, path: Path, salt: bytes):
self.path = Path(path)
self.salt = bytes(salt)
self._init()
def _connect(self):
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _init(self):
with self._connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
job_id INTEGER PRIMARY KEY,
source_fingerprint TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
reason_code TEXT,
sink_receipt_fingerprint TEXT
)
"""
)
columns = {
row["name"] for row in connection.execute("PRAGMA table_info(jobs)")
}
if "authorization_started" not in columns:
connection.execute(
"ALTER TABLE jobs ADD COLUMN authorization_started INTEGER NOT NULL DEFAULT 0"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS jobs_source_status_idx "
"ON jobs(source_fingerprint, status)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS credential_inventory (
sink_receipt_fingerprint TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK(state IN ('available', 'claiming', 'claimed')),
claimed_at TEXT NOT NULL DEFAULT '',
batch_id TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS credential_inventory_state_idx "
"ON credential_inventory(state)"
)
connection.execute(
"""
INSERT OR IGNORE INTO credential_inventory(
sink_receipt_fingerprint, state
)
SELECT sink_receipt_fingerprint, 'available'
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
""",
(JobStatus.IMPORTED.value,),
)
os.chmod(self.path, 0o600)
def fingerprint(self, source_id):
return hmac.new(self.salt, source_id.encode(), hashlib.sha256).hexdigest()
def _fingerprint(self, source_id):
return self.fingerprint(source_id)
def start(self, source_id, attempt=1):
return self.start_fingerprint(self.fingerprint(source_id), attempt=attempt)
def start_fingerprint(self, source_fingerprint, attempt=None):
if attempt is None:
attempt = self.next_attempt(source_fingerprint)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at) "
"VALUES (?, ?, ?, ?)",
(source_fingerprint, attempt, "pending", now),
)
return cursor.lastrowid
def next_attempt(self, source_fingerprint):
with self._connect() as connection:
row = connection.execute(
"SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt "
"FROM jobs WHERE source_fingerprint=?",
(source_fingerprint,),
).fetchone()
return int(row["next_attempt"])
def mark_authorization_started(self, job_id):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET authorization_started=1 "
"WHERE job_id=? AND status='pending'",
(job_id,),
)
def finish(self, job_id, status, reason_code, sink_receipt_fingerprint=None):
status_value = status.value if isinstance(status, JobStatus) else str(status)
now = datetime.now(timezone.utc).isoformat()
with self._connect() as connection:
cursor = connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=?, "
"sink_receipt_fingerprint=? "
"WHERE job_id=? AND status='pending'",
(status_value, now, reason_code, sink_receipt_fingerprint, job_id),
)
if (
cursor.rowcount == 1
and status_value == JobStatus.IMPORTED.value
and sink_receipt_fingerprint
):
connection.execute(
"INSERT OR IGNORE INTO credential_inventory("
"sink_receipt_fingerprint, state) VALUES (?, 'available')",
(sink_receipt_fingerprint,),
)
def claim_available(self, limit, batch_id):
limit = int(limit)
if limit <= 0:
return []
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"""
WITH latest_import AS (
SELECT sink_receipt_fingerprint, MAX(finished_at) AS finished_at
FROM jobs
WHERE status=?
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
GROUP BY sink_receipt_fingerprint
)
SELECT inventory.sink_receipt_fingerprint
FROM credential_inventory AS inventory
JOIN latest_import USING (sink_receipt_fingerprint)
WHERE inventory.state='available'
ORDER BY latest_import.finished_at DESC,
inventory.sink_receipt_fingerprint ASC
LIMIT ?
""",
(JobStatus.IMPORTED.value, limit),
).fetchall()
fingerprints = [row["sink_receipt_fingerprint"] for row in rows]
connection.executemany(
"UPDATE credential_inventory SET state='claiming', batch_id=?, "
"claimed_at='', note='' "
"WHERE sink_receipt_fingerprint=? AND state='available'",
((batch_id, fingerprint) for fingerprint in fingerprints),
)
return fingerprints
def mark_claimed(self, batch_id, note=""):
batch_id = str(batch_id).strip()
if not batch_id:
raise ValueError("batch_id must not be empty")
with self._connect() as connection:
cursor = connection.execute(
"UPDATE credential_inventory SET state='claimed', claimed_at=?, note=? "
"WHERE state='claiming' AND batch_id=?",
(datetime.now(timezone.utc).isoformat(), str(note), batch_id),
)
return cursor.rowcount
def inventory_counts(self):
counts = {"available": 0, "claiming": 0, "claimed": 0}
with self._connect() as connection:
rows = connection.execute(
"SELECT state, COUNT(*) AS count FROM credential_inventory GROUP BY state"
)
for row in rows:
counts[row["state"]] = int(row["count"])
return counts
def pending_claims(self, batch_id=None):
query = (
"SELECT sink_receipt_fingerprint, batch_id, note "
"FROM credential_inventory WHERE state='claiming'"
)
params = ()
if batch_id is not None:
query += " AND batch_id=?"
params = (str(batch_id),)
query += " ORDER BY batch_id, sink_receipt_fingerprint"
with self._connect() as connection:
rows = connection.execute(query, params)
return [dict(row) for row in rows]
def recover_claiming(self, batch_id=None, *, note=""):
query = (
"UPDATE credential_inventory SET state='available', claimed_at='', "
"batch_id='', note=? WHERE state='claiming'"
)
params = [str(note)]
if batch_id is not None:
query += " AND batch_id=?"
params.append(str(batch_id))
with self._connect() as connection:
cursor = connection.execute(query, params)
return cursor.rowcount
def recover_pending(self, *, reason="recovered_pending"):
with self._connect() as connection:
connection.execute(
"UPDATE jobs SET status=?, finished_at=?, reason_code=? WHERE status='pending'",
(JobStatus.CANCELLED.value, datetime.now(timezone.utc).isoformat(), reason),
)
def has_imported(self, source_id):
with self._connect() as connection:
row = connection.execute(
"SELECT 1 FROM jobs WHERE source_fingerprint=? AND status=? LIMIT 1",
(self.fingerprint(source_id), JobStatus.IMPORTED.value),
).fetchone()
return row is not None
def imported_fingerprints(self):
with self._connect() as connection:
rows = connection.execute(
"SELECT DISTINCT source_fingerprint FROM jobs WHERE status=?",
(JobStatus.IMPORTED.value,),
)
return {row["source_fingerprint"] for row in rows}
def aggregate_counts(self):
with self._connect() as connection:
row = connection.execute(
"""
SELECT
COUNT(DISTINCT CASE WHEN authorization_started=1 OR status=?
THEN source_fingerprint END) AS attempted_unique,
COUNT(DISTINCT CASE WHEN status=?
THEN source_fingerprint END) AS imported_unique,
COUNT(CASE WHEN finished_at IS NOT NULL THEN 1 END) AS finalized_attempts,
COUNT(CASE WHEN status=? THEN 1 END) AS imported_attempts,
COUNT(CASE WHEN reason_code='rate_limited' THEN 1 END) AS rate_limited
FROM jobs
""",
(
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
JobStatus.IMPORTED.value,
),
).fetchone()
return {key: int(row[key] or 0) for key in row.keys()}
def get(self, job_id):
with self._connect() as connection:
row = connection.execute(
"SELECT source_fingerprint, attempt_number, status, started_at, finished_at, "
"reason_code, sink_receipt_fingerprint FROM jobs WHERE job_id=?",
(job_id,),
).fetchone()
return dict(row) if row else None

128
xai_enroller/models.py Normal file
View File

@@ -0,0 +1,128 @@
from dataclasses import dataclass
from enum import Enum
from typing import Optional
@dataclass(frozen=True)
class SourceRecord:
source_id: str
sso_token: str
cookies: tuple[dict, ...] = ()
def __repr__(self) -> str:
return "SourceRecord(<redacted>)"
@dataclass(frozen=True)
class DeviceFlow:
device_code: str
user_code: str
verification_url: str
expires_in: int
interval: float
token_endpoint: str = "https://auth.x.ai/oauth2/token"
def __repr__(self) -> str:
return "DeviceFlow(<redacted>)"
@dataclass(frozen=True)
class OAuthCredential:
access_token: str
refresh_token: str
id_token: Optional[str]
token_type: Optional[str]
expires_in: int
expires_at: str
last_refresh: str
subject: Optional[str]
token_endpoint: str
def __repr__(self) -> str:
return "OAuthCredential(<redacted>)"
@dataclass(frozen=True)
class SinkReceipt:
fingerprint: str
def __repr__(self) -> str:
return "SinkReceipt(<redacted>)"
class AuthorizationStatus(str, Enum):
AUTHORIZED = "authorized"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
@dataclass(frozen=True)
class AuthorizationResult:
status: AuthorizationStatus
reason_code: str
class JobStatus(str, Enum):
IMPORTED = "imported"
NEEDS_BROWSER = "needs_browser"
NEEDS_INTERACTION = "needs_interaction"
SOURCE_INVALID = "source_invalid"
OAUTH_DENIED = "oauth_denied"
OAUTH_EXPIRED = "oauth_expired"
OAUTH_REJECTED = "oauth_rejected"
SINK_FAILED = "sink_failed"
TRANSPORT_FAILED = "transport_failed"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class JobResult:
source_id: str
status: JobStatus
reason_code: str
attempt_number: int = 1
sink_receipt_fingerprint: Optional[str] = None
def __repr__(self) -> str:
return (
f"JobResult(source_id=<redacted>, status={self.status.value!r}, "
f"reason_code={self.reason_code!r}, attempt_number={self.attempt_number})"
)
class PipelineState(str, Enum):
QUEUED = "queued"
PREPARED = "prepared"
ACTIVE = "active"
RETRY_WAITING = "retry_waiting"
IMPORTED = "imported"
TERMINAL = "terminal"
@dataclass(frozen=True)
class PreparedJob:
source: SourceRecord
source_fingerprint: str
flow: DeviceFlow
flow_created_monotonic: float
job_id: int
attempt_number: int
task_number: Optional[int] = None
def __repr__(self) -> str:
return (
"PreparedJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.attempt_number})"
)
@dataclass(frozen=True)
class CompletionJob:
prepared: PreparedJob
def __repr__(self) -> str:
return (
"CompletionJob(source=<redacted>, flow=<redacted>, "
f"attempt_number={self.prepared.attempt_number})"
)

181
xai_enroller/protocol.py Normal file
View File

@@ -0,0 +1,181 @@
import asyncio
import base64
import binascii
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Awaitable, Callable
from urllib.parse import urlencode
import httpx
from .models import DeviceFlow, OAuthCredential
@dataclass(frozen=True)
class XAIProfile:
client_id: str
scope: str
version: str = "xai-device-v1"
@classmethod
def default(cls):
return cls(
client_id="b1a00492-073a-47ea-816f-4c329264a828",
scope="openid profile email offline_access grok-cli:access api:access",
version="grok-cli-device-v1",
)
class XAIProtocol:
DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration"
MAX_BODY = 64 * 1024
def __init__(
self,
client: httpx.AsyncClient,
profile: XAIProfile,
sleep=None,
default_poll_interval=5.0,
):
self.client = client
self.profile = profile
self.sleep = sleep or asyncio.sleep
self.default_poll_interval = float(default_poll_interval)
self._discovered = None
@staticmethod
def _allowed_url(value):
from urllib.parse import urlparse
parsed = urlparse(value)
hostname = parsed.hostname
return bool(hostname) and parsed.scheme == "https" and (
hostname == "x.ai" or hostname.endswith(".x.ai")
)
async def _json(self, response):
body = await response.aread()
if len(body) > self.MAX_BODY:
raise RuntimeError("response body exceeds limit")
try:
return json.loads(body)
except (TypeError, ValueError) as exc:
raise RuntimeError("invalid JSON response") from exc
async def discover(self):
response = await self.client.get(self.DISCOVERY_URL, follow_redirects=False)
if response.status_code // 100 != 2:
raise RuntimeError("discovery rejected")
document = await self._json(response)
device_endpoint = document.get("device_authorization_endpoint")
token_endpoint = document.get("token_endpoint")
if not device_endpoint or not token_endpoint or not all(
self._allowed_url(url) for url in (device_endpoint, token_endpoint)
):
raise ValueError("discovery endpoint failed x.ai HTTPS allowlist")
self._discovered = (device_endpoint, token_endpoint)
return self._discovered
async def start_device_flow(self):
device_endpoint, token_endpoint = await self.discover()
response = await self.client.post(
device_endpoint,
data={"client_id": self.profile.client_id, "scope": self.profile.scope},
follow_redirects=False,
)
if response.status_code // 100 != 2:
raise RuntimeError("device authorization rejected")
document = await self._json(response)
try:
device_code = document["device_code"]
user_code = document["user_code"]
base_url = document.get("verification_uri") or document["verification_url"]
expires_in = int(document["expires_in"])
except (KeyError, TypeError, ValueError) as exc:
raise RuntimeError("invalid device authorization response") from exc
if not self._allowed_url(base_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
interval = float(document.get("interval", self.default_poll_interval))
verification_url = document.get("verification_uri_complete")
if verification_url is None:
verification_url = (
f"{base_url}{'&' if '?' in base_url else '?'}"
f"{urlencode({'user_code': user_code})}"
)
if not self._allowed_url(verification_url):
raise ValueError("verification URL failed x.ai HTTPS allowlist")
return DeviceFlow(device_code, user_code, verification_url, expires_in, interval, token_endpoint)
async def poll_token(self, *, endpoint, flow, timeout):
deadline = time.monotonic() + timeout
interval = max(0.0, float(flow.interval))
while time.monotonic() < deadline:
response = await self.client.post(
endpoint,
data={
"client_id": self.profile.client_id,
"device_code": flow.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
follow_redirects=False,
)
document = await self._json(response)
if response.status_code // 100 == 2:
return self._credential(document, endpoint)
error = document.get("error")
if error in {"authorization_pending", "slow_down"}:
interval += 1 if error == "slow_down" else 0
await self.sleep(interval)
continue
if error == "access_denied":
raise RuntimeError("oauth_denied")
if error == "expired_token":
raise RuntimeError("oauth_expired")
raise RuntimeError("oauth_rejected")
raise RuntimeError("oauth_expired")
@staticmethod
def _jwt_subject(token):
if not isinstance(token, str):
return None
parts = token.split(".")
if len(parts) != 3 or not parts[1]:
return None
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
except (binascii.Error, UnicodeDecodeError, ValueError):
return None
if not isinstance(claims, dict):
return None
for claim in ("sub", "principal_id"):
value = claims.get(claim)
if isinstance(value, str) and value:
return value
return None
@staticmethod
def _credential(document, endpoint):
if not document.get("access_token") or not document.get("refresh_token"):
raise RuntimeError("oauth_rejected")
now = datetime.now(timezone.utc)
expires_in = int(document.get("expires_in", 3600))
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, timezone.utc).isoformat().replace("+00:00", "Z")
subject = (
XAIProtocol._jwt_subject(document.get("id_token"))
or XAIProtocol._jwt_subject(document["access_token"])
or document.get("sub")
)
return OAuthCredential(
access_token=document["access_token"],
refresh_token=document["refresh_token"],
id_token=document.get("id_token"),
token_type=document.get("token_type"),
expires_in=expires_in,
expires_at=expires_at,
last_refresh=now.isoformat().replace("+00:00", "Z"),
subject=subject,
token_endpoint=endpoint,
)

View File

@@ -0,0 +1,630 @@
"""Atomic local snapshots of password-free registration sessions."""
import asyncio
import json
import os
import shlex
import sys
import tempfile
from contextlib import suppress
from pathlib import Path
from .models import SourceRecord
MAX_SESSION_RECORD_BYTES = 256 * 1024
class RemoteStreamError(RuntimeError):
"""A classified stream failure whose text never includes remote output."""
class SSHSnapshotSynchronizer:
"""Atomically refresh one validated, password-free local JSONL snapshot."""
MAX_STDERR_BYTES = 16 * 1024
def __init__(
self,
host,
destination,
*,
remote_root="/opt/grok-free-register",
identity_file=None,
process_factory=asyncio.create_subprocess_exec,
fingerprint=None,
):
self.host = host
self.destination = Path(destination)
self.remote_root = remote_root
self.identity_file = identity_file
self.process_factory = process_factory
self.fingerprint = fingerprint or (lambda source_id: source_id)
self.snapshot_fingerprints = None
self._process = None
def _input_generation(self):
return None
def _input_generation_unchanged(self, generation):
return True
def _empty_snapshot_allowed(self):
return False
def _command(self):
return (
f"cd {shlex.quote(self.remote_root)} && "
"python3 scripts/export_registered_sessions.py "
"keys/auth-sessions.jsonl keys/accounts.txt"
)
def _args(self):
args = [
"ssh",
"-C",
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
]
if self.identity_file:
args.extend(["-i", self.identity_file])
args.extend(["--", self.host, self._command()])
return args
async def _read_stderr(self, stream):
retained = 0
while True:
chunk = await stream.read(4096)
if not chunk:
return
retained = min(self.MAX_STDERR_BYTES, retained + len(chunk))
async def _terminate(self, process):
if process is None or process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def close(self):
await self._terminate(self._process)
async def sync_once(self):
self.destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.destination.parent, 0o700)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{self.destination.name}.",
suffix=".tmp",
dir=self.destination.parent,
)
process = None
stderr_task = None
try:
os.fchmod(fd, 0o600)
input_generation = self._input_generation()
process = await self.process_factory(
*self._args(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
limit=MAX_SESSION_RECORD_BYTES + 1,
)
self._process = process
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
with os.fdopen(fd, "wb") as stream:
fd = -1
record_count = 0
snapshot_fingerprints = set()
while True:
try:
raw = await process.stdout.readline()
except (ValueError, asyncio.LimitOverrunError) as exc:
raise ValueError("invalid remote session snapshot") from exc
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
):
raise ValueError("invalid remote session snapshot")
record = parse_session_document(raw[:-1])
snapshot_fingerprints.add(self.fingerprint(record.source_id))
stream.write(raw)
record_count += 1
returncode = await process.wait()
if stderr_task is not None:
await stderr_task
stderr_task = None
if returncode != 0:
raise RemoteStreamError("remote snapshot export failed")
if record_count == 0 and not self._empty_snapshot_allowed():
raise RemoteStreamError("remote snapshot export was empty")
if not self._input_generation_unchanged(input_generation):
raise RemoteStreamError("snapshot inputs changed during export")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, self.destination)
os.chmod(self.destination, 0o600)
directory_fd = os.open(self.destination.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
self.snapshot_fingerprints = frozenset(snapshot_fingerprints)
return True
except asyncio.CancelledError:
raise
except Exception:
return False
finally:
if fd >= 0:
os.close(fd)
if stderr_task is not None:
if not stderr_task.done():
stderr_task.cancel()
await asyncio.gather(stderr_task, return_exceptions=True)
with suppress(Exception):
await self._terminate(process)
if self._process is process:
self._process = None
with suppress(FileNotFoundError):
os.unlink(temporary_name)
class LocalSnapshotSynchronizer(SSHSnapshotSynchronizer):
"""Export registration data from one local project into an atomic snapshot."""
def __init__(
self,
register_root,
destination,
*,
process_factory=asyncio.create_subprocess_exec,
fingerprint=None,
python_executable=sys.executable,
exporter_path=None,
):
super().__init__(
"",
destination,
process_factory=process_factory,
fingerprint=fingerprint,
)
self.register_root = Path(register_root)
self.python_executable = str(python_executable)
self.exporter_path = Path(exporter_path or (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "export_registered_sessions.py"
))
self.sessions_path = self.register_root / "keys" / "auth-sessions.jsonl"
self.accounts_path = self.register_root / "keys" / "accounts.txt"
@staticmethod
def _path_generation(path):
try:
stat_result = path.stat()
except FileNotFoundError:
return None
return (
stat_result.st_dev,
stat_result.st_ino,
stat_result.st_size,
stat_result.st_mtime_ns,
)
def _input_generation(self):
return (
self._path_generation(self.sessions_path),
self._path_generation(self.accounts_path),
)
def _input_generation_unchanged(self, generation):
return self._input_generation() == generation
def _empty_snapshot_allowed(self):
return True
def _args(self):
return [
self.python_executable,
str(self.exporter_path),
str(self.sessions_path),
str(self.accounts_path),
]
class DiskSnapshotSource:
"""Consume immutable local snapshot generations under queue backpressure."""
def __init__(
self,
path,
*,
synchronizer=None,
sync_seconds=30.0,
poll_seconds=0.25,
sleep=asyncio.sleep,
event_callback=None,
fingerprint=None,
):
self.path = Path(path)
self.synchronizer = synchronizer
self.sync_seconds = float(sync_seconds)
self.poll_seconds = float(poll_seconds)
self.sleep = sleep
self.event_callback = event_callback
self.fingerprint = fingerprint or (lambda source_id: source_id)
self._snapshot_fingerprints = None
self._last_reported_snapshot_fingerprints = None
self._closed = False
self._sync_task = None
self._last_sync_ok = None
def _emit(self, kind, data):
if self.event_callback is not None:
with suppress(Exception):
self.event_callback(kind, data)
@property
def snapshot_fingerprints(self):
if self.synchronizer is not None:
return self.synchronizer.snapshot_fingerprints
return self._snapshot_fingerprints
async def _sync_loop(self):
while not self._closed:
refreshed = await self.synchronizer.sync_once()
current = self.synchronizer.snapshot_fingerprints
if refreshed != self._last_sync_ok:
self._emit(
"source_connected" if refreshed else "source_disconnected",
{} if refreshed else {"reason": "snapshot_sync_failed"},
)
self._last_sync_ok = refreshed
if refreshed and current is not None:
previous = self._last_reported_snapshot_fingerprints
if previous is not None:
added = current - previous
if added:
self._emit(
"source_updated",
{"new": len(added), "total": len(current)},
)
self._last_reported_snapshot_fingerprints = current
try:
await asyncio.wait_for(self._wait_closed(), timeout=self.sync_seconds)
except TimeoutError:
pass
async def _wait_closed(self):
while not self._closed:
await self.sleep(min(0.25, self.sync_seconds))
async def close(self):
self._closed = True
if self._sync_task is not None:
self._sync_task.cancel()
with suppress(asyncio.CancelledError):
await self._sync_task
self._sync_task = None
if self.synchronizer is not None:
await self.synchronizer.close()
@staticmethod
def _generation(stat_result):
return (
stat_result.st_dev,
stat_result.st_ino,
stat_result.st_mtime_ns,
stat_result.st_size,
)
async def records(self):
if self.synchronizer is not None and self._sync_task is None:
self._sync_task = asyncio.create_task(self._sync_loop())
consumed_generation = None
while not self._closed:
try:
current = self.path.stat()
except FileNotFoundError:
await self.sleep(self.poll_seconds)
continue
generation = self._generation(current)
if generation == consumed_generation:
await self.sleep(self.poll_seconds)
continue
try:
stream = self.path.open("rb")
except FileNotFoundError:
continue
with stream:
opened_generation = self._generation(os.fstat(stream.fileno()))
generation_fingerprints = set()
valid_generation = True
while not self._closed:
raw = await asyncio.to_thread(
stream.readline, MAX_SESSION_RECORD_BYTES + 2
)
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
):
self._emit(
"source_record_rejected", {"reason": "invalid_record"}
)
valid_generation = False
break
try:
record = parse_session_document(raw[:-1])
except ValueError:
self._emit(
"source_record_rejected", {"reason": "invalid_record"}
)
valid_generation = False
continue
generation_fingerprints.add(self.fingerprint(record.source_id))
yield record
if valid_generation:
self._snapshot_fingerprints = frozenset(generation_fingerprints)
consumed_generation = opened_generation
def parse_session_document(raw: bytes | str) -> SourceRecord:
if len(raw) > MAX_SESSION_RECORD_BYTES:
raise ValueError("invalid remote session record")
try:
document = json.loads(raw)
source_id = document["email"]
raw_cookies = document["cookies"]
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
raise ValueError("invalid remote session record") from exc
if not isinstance(source_id, str) or not source_id:
raise ValueError("invalid remote session record")
try:
source_id.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("invalid remote session record") from exc
if not isinstance(raw_cookies, list) or not raw_cookies:
raise ValueError("invalid remote session record")
cookies = []
sso_token = ""
fallback_sso_token = ""
allowed = {
"name",
"value",
"url",
"domain",
"path",
"expires",
"httpOnly",
"secure",
"sameSite",
}
for raw_cookie in raw_cookies:
if not isinstance(raw_cookie, dict):
raise ValueError("invalid remote session record")
cookie = {key: raw_cookie[key] for key in allowed if key in raw_cookie}
name = cookie.get("name")
value = cookie.get("value")
if not isinstance(name, str) or not name or not isinstance(value, str) or not value:
raise ValueError("invalid remote session record")
scope = cookie.get("url") or cookie.get("domain")
if not isinstance(scope, str) or not scope:
raise ValueError("invalid remote session record")
try:
name.encode("utf-8")
value.encode("utf-8")
scope.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError("invalid remote session record") from exc
if name == "sso" and not sso_token:
sso_token = value
elif name == "sso-rw" and not fallback_sso_token:
fallback_sso_token = value
cookies.append(cookie)
sso_token = sso_token or fallback_sso_token
if not sso_token:
raise ValueError("invalid remote session record")
return SourceRecord(source_id, sso_token, tuple(cookies))
class RemoteSessionStream:
"""Yield full snapshots and appends from one reconnecting SSH child."""
MAX_STDERR_BYTES = 16 * 1024
MAX_RECORD_BYTES = MAX_SESSION_RECORD_BYTES
RECONNECT_DELAYS = (1.0, 2.0, 5.0, 10.0, 30.0)
def __init__(
self,
host: str,
*,
remote_root: str = "/opt/grok-free-register",
identity_file: str | None = None,
process_factory=asyncio.create_subprocess_exec,
sleep=asyncio.sleep,
event_callback=None,
):
self.host = host
self.remote_root = remote_root
self.identity_file = identity_file
self.process_factory = process_factory
self.sleep = sleep
self.event_callback = event_callback
self._process = None
self._closed = False
self._last_disconnect_reason = None
def _emit(self, kind, data):
if self.event_callback is None:
return
try:
self.event_callback(kind, data)
except Exception:
pass
def _command(self):
return (
f"cd {shlex.quote(self.remote_root)} && "
"python3 -u scripts/export_registered_sessions.py --follow "
"keys/auth-sessions.jsonl keys/accounts.txt"
)
def _args(self):
args = [
"ssh",
"-C",
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=15",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
]
if self.identity_file:
args.extend(["-i", self.identity_file])
args.extend(["--", self.host, self._command()])
return args
async def _read_stderr(self, stream):
retained = bytearray()
while True:
chunk = await stream.read(4096)
if not chunk:
break
remaining = self.MAX_STDERR_BYTES - len(retained)
if remaining > 0:
retained.extend(chunk[:remaining])
return bytes(retained)
@staticmethod
def _classify_disconnect(returncode, stderr):
normalized = stderr.lower()
if b"permission denied" in normalized or b"host key verification failed" in normalized:
return "ssh_auth_failed"
if b"could not resolve" in normalized or b"name or service not known" in normalized:
return "ssh_resolution_failed"
if any(
marker in normalized
for marker in (b"connection refused", b"connection timed out", b"no route to host")
):
return "ssh_connection_failed"
if returncode == 3:
return "remote_snapshot_changed"
if returncode == 4:
return "remote_data_invalid"
return "remote_stream_closed"
async def _terminate_process(self, process):
if process is None or process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def close(self):
self._closed = True
await self._terminate_process(self._process)
async def records(self):
reconnect_index = 0
while not self._closed:
process = None
stderr_task = None
yielded = False
reason = "remote_stream_closed"
try:
process = await self.process_factory(
*self._args(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
limit=self.MAX_RECORD_BYTES + 1,
)
self._process = process
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
self._emit("source_connected", {})
while not self._closed:
try:
raw = await process.stdout.readline()
except (ValueError, asyncio.LimitOverrunError):
reason = "remote_record_too_large"
break
if not raw:
break
if (
not raw.endswith(b"\n")
or len(raw) - 1 > self.MAX_RECORD_BYTES
):
reason = "remote_record_too_large"
break
try:
record = parse_session_document(raw[:-1])
except ValueError:
self._emit("source_record_rejected", {"reason": "invalid_record"})
continue
if not yielded:
self._last_disconnect_reason = None
yielded = True
reconnect_index = 0
yield record
# A follow exporter is intentionally long-lived. Once framing is
# invalid, waiting for it to exit on its own would hang this source
# forever, so terminate it before collecting the exit status.
if reason != "remote_stream_closed":
await self._terminate_process(process)
if process.returncode is None:
await process.wait()
stderr = await stderr_task
if reason == "remote_stream_closed":
reason = self._classify_disconnect(process.returncode, stderr)
except asyncio.CancelledError:
raise
except (OSError, RuntimeError):
reason = "ssh_start_failed"
finally:
if stderr_task is not None and not stderr_task.done():
stderr_task.cancel()
with suppress(asyncio.CancelledError):
await stderr_task
await self._terminate_process(process)
if self._process is process:
self._process = None
if self._closed:
break
if reason != self._last_disconnect_reason:
self._emit("source_disconnected", {"reason": reason})
self._last_disconnect_reason = reason
if reason == "remote_snapshot_changed":
delay = 0.1
else:
delay = self.RECONNECT_DELAYS[min(reconnect_index, len(self.RECONNECT_DELAYS) - 1)]
if not yielded:
reconnect_index += 1
await self.sleep(delay)
PersistentSSHSource = RemoteSessionStream

883
xai_enroller/service.py Normal file
View File

@@ -0,0 +1,883 @@
"""Interactive composition for the asynchronous local authentication service."""
import asyncio
import os
import secrets
import sys
import tempfile
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from .inventory import InventoryError
DEFAULT_LOCAL_AUTH_DIR = Path.home() / "Downloads" / "grok-free-register-auth"
AUTHENTICATED_DIRNAME = "authenticated"
CLAIMED_DIRNAME = "claimed"
class AuthServiceConfigurationError(ValueError):
pass
class InteractiveCommandPrompt:
"""Stdlib-only line editor that keeps commands visible during events."""
CLEAR_LINE = "\r\x1b[2K"
def __init__(
self,
*,
input_stream=None,
output=None,
interactive=None,
prompt="认证> ",
):
self.input_stream = input_stream or sys.stdin
self.output = output or sys.stdout
self.prompt = prompt
if interactive is None:
interactive = bool(
getattr(self.input_stream, "isatty", lambda: False)()
and getattr(self.output, "isatty", lambda: False)()
)
self.interactive = interactive
self.buffer = ""
self.active = False
self._callback = None
self._fd = None
self._original_terminal = None
def _write(self, text):
try:
self.output.write(text)
self.output.flush()
except (OSError, ValueError):
pass
def _redraw(self):
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}{self.prompt}{self.buffer}")
def start(self, callback):
self._callback = callback
self.active = True
self._redraw()
def feed(self, text):
for character in text:
if character in {"\r", "\n"}:
command = self.buffer
self.buffer = ""
self._write("\n")
if command.strip() and self._callback is not None:
self._callback(command)
if self.active and self.interactive:
self._write(self.prompt)
elif character in {"\b", "\x7f"}:
self.buffer = self.buffer[:-1]
self._redraw()
elif character == "\x04":
if self._callback is not None:
self._callback(None)
elif character.isprintable():
self.buffer += character
self._redraw()
def write_event(self, message):
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}{message}\n{self.prompt}{self.buffer}")
else:
self._write(f"{message}\n")
def attach(self, loop, callback):
self.start(callback)
self._fd = self.input_stream.fileno()
if self.interactive:
import termios
import tty
self._original_terminal = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd)
def stdin_ready():
data = os.read(self._fd, 256)
if data:
self.feed(data.decode(errors="ignore"))
elif self._callback is not None:
loop.remove_reader(self._fd)
self._callback(None)
else:
def stdin_ready():
line = self.input_stream.readline()
if line == "":
if self._callback is not None:
loop.remove_reader(self._fd)
self._callback(None)
return
if self._callback is not None:
self._callback(line)
loop.add_reader(self._fd, stdin_ready)
def close(self, loop):
if self._fd is not None:
with suppress(Exception):
loop.remove_reader(self._fd)
if self._original_terminal is not None and self._fd is not None:
import termios
with suppress(Exception):
termios.tcsetattr(
self._fd, termios.TCSADRAIN, self._original_terminal
)
if self.active and self.interactive:
self._write(f"{self.CLEAR_LINE}\n")
self.active = False
def resolve_auth_log_mode(argv=None, env=None):
argv = list(sys.argv[1:] if argv is None else argv)
env = dict(os.environ if env is None else env)
unknown = [argument for argument in argv if argument != "--debug"]
if unknown:
raise AuthServiceConfigurationError(
f"unsupported auth service argument: {unknown[0]}"
)
mode = (env.get("XAI_AUTH_SERVICE_LOG_MODE") or "user").strip().lower()
if "--debug" in argv:
mode = "debug"
if mode not in {"user", "debug"}:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_LOG_MODE must be user or debug"
)
return mode
def prepare_local_service_environment(env=None):
"""Create private local persistence defaults without storing secrets in the repo."""
merged = dict(os.environ if env is None else env)
destination = Path(
merged.get("XAI_ENROLLER_LOCAL_AUTH_DIR", DEFAULT_LOCAL_AUTH_DIR)
).expanduser()
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(destination, 0o700)
merged["XAI_ENROLLER_SINK"] = "local"
merged["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(destination)
merged.setdefault(
"XAI_ENROLLER_LEDGER_PATH", str(destination / "enrollment-ledger.db")
)
if not merged.get("XAI_ENROLLER_SOURCE_SALT"):
salt_file = destination / ".ledger-salt"
try:
salt = salt_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
salt = secrets.token_hex(32)
fd, temporary_name = tempfile.mkstemp(
prefix=".ledger-salt.", suffix=".tmp", dir=destination, text=True
)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(salt + "\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, salt_file)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
os.chmod(salt_file, 0o600)
merged["XAI_ENROLLER_SOURCE_SALT"] = salt
return merged
@dataclass(frozen=True)
class AuthServiceSettings:
source_kind: str
ssh_host: str | None
register_root: str
remote_root: str
identity_file: str | None
sync_seconds: int = 30
retry_seconds: int = 60
min_authorization_interval_seconds: float = 10.0
@classmethod
def from_environ(cls, env=None):
env = dict(os.environ if env is None else env)
ssh_host = (env.get("XAI_AUTH_SERVICE_SSH_HOST") or "").strip()
requested_source = (env.get("XAI_AUTH_SERVICE_SOURCE") or "auto").strip().lower()
if requested_source not in {"auto", "local", "ssh"}:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_SOURCE must be auto, local, or ssh"
)
source_kind = (
"ssh" if requested_source == "auto" and ssh_host else
"local" if requested_source == "auto" else
requested_source
)
if source_kind == "ssh" and not ssh_host:
raise AuthServiceConfigurationError("XAI_AUTH_SERVICE_SSH_HOST is required")
register_root = (
env.get("XAI_AUTH_SERVICE_REGISTER_ROOT")
or str(Path(__file__).resolve().parents[1])
).strip()
remote_root = (
env.get("XAI_AUTH_SERVICE_REMOTE_ROOT") or "/opt/grok-free-register"
).strip()
identity_file = (env.get("XAI_AUTH_SERVICE_SSH_IDENTITY") or "").strip() or None
try:
sync_seconds = int(env.get("XAI_AUTH_SERVICE_SYNC_SEC", "30"))
retry_seconds = int(env.get("XAI_AUTH_SERVICE_RETRY_SEC", "60"))
min_authorization_interval_seconds = float(
env.get("XAI_AUTH_SERVICE_MIN_INTERVAL_SEC", "10")
)
except ValueError as exc:
raise AuthServiceConfigurationError(
"auth service intervals must be numeric"
) from exc
if not 5 <= sync_seconds <= 3600:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_SYNC_SEC must be between 5 and 3600"
)
if not 30 <= retry_seconds <= 86400:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_RETRY_SEC must be between 30 and 86400"
)
if not 0 <= min_authorization_interval_seconds <= 3600:
raise AuthServiceConfigurationError(
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC must be between 0 and 3600"
)
return cls(
source_kind=source_kind,
ssh_host=ssh_host or None,
register_root=register_root,
remote_root=remote_root,
identity_file=identity_file,
sync_seconds=sync_seconds,
retry_seconds=retry_seconds,
min_authorization_interval_seconds=min_authorization_interval_seconds,
)
class EventTerminal:
"""Mode-aware aggregate renderer; identifiers and credentials stay hidden."""
_SAFE_REASONS = {
"already_imported",
"authorization_transport_failed",
"browser_error",
"challenge_required",
"confirmation_timeout",
"device_flow_failed",
"device_flow_invalid",
"device_flow_refresh_failed",
"imported",
"internal_error",
"oauth_denied",
"oauth_expired",
"oauth_rejected",
"operator_cancelled",
"rate_limited",
"sink_failed",
"sink_unconfigured",
"snapshot_sync_failed",
"token_failed",
"token_transport_failed",
"unknown_page",
}
_TRANSIENT_REASONS = {
"authorization_transport_failed",
"browser_error",
"challenge_required",
"confirmation_timeout",
"device_flow_failed",
"device_flow_refresh_failed",
"sink_failed",
"token_failed",
"token_transport_failed",
"unknown_page",
}
def __init__(self, mode="user", output=None):
if mode not in {"user", "debug"}:
raise ValueError("terminal mode must be user or debug")
self.mode = mode
self.output = output or self._print
@staticmethod
def _print(message):
print(message, flush=True)
@staticmethod
def _percentage(value):
return f"{100.0 * float(value):.1f}%"
@staticmethod
def _rate(value):
return "" if value is None else f"{float(value):.2f}/分"
@staticmethod
def _pending(value):
return "" if value is None else str(value)
@staticmethod
def _task_suffix(value):
return "" if value is None else f" #{value}"
def _format_user(self, kind, data):
if kind == "startup":
source = {"local": "本机", "ssh": "远端"}.get(
data.get("source_kind"), "等待同步"
)
return (
f"[✓] 本地认证服务已启动 | 来源 {source} | "
f"输出 {data['destination']} | 待处理 — | 可用 {data['available']}"
)
if kind == "service_started":
return "[▶] 认证流水线运行中"
if kind == "service_stopped":
return "[■] 认证服务已停止"
if kind == "source_connected":
return "[✓] 账号来源已连接 | 本地快照可用"
if kind == "source_updated":
return f"[↻] 发现新账号 {data['new']} | 快照共 {data['total']}"
if kind == "source_disconnected":
return "[!] 账号来源暂时断开 | 继续使用上一份有效快照"
if kind == "source_record_rejected":
return "[!] 已忽略一条无效来源记录"
if kind == "rate_limited":
pace = data.get("min_authorization_interval_seconds")
pace_text = (
f" | 恢复后间隔 {pace:.0f}" if pace is not None else ""
)
return (
f"[⏸] 触发限流 | {data['wait_seconds']}秒后单次探测{pace_text}"
)
if kind == "rate_limit_cleared":
return f"[▶] 限流解除 | 实际等待 {data['elapsed_seconds']}"
if kind == "pacing_adjusted":
return (
f"[•] 认证间隔调整为 {data['min_authorization_interval_seconds']:.0f}"
)
if kind == "authorization_started":
return (
f"[→] 开始认证 #{data['task_number']} | "
f"待处理 {self._pending(data.get('pending_total'))}"
)
if kind == "result":
if data.get("status") == "imported":
return (
f"[✓] 认证成功{self._task_suffix(data.get('task_number'))} | "
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
f"累计 {data['imported_unique']} | 可用 {data.get('available', '')}"
)
reason = data.get("reason")
if reason == "rate_limited":
return None
action = "暂时失败,将自动重试" if reason in self._TRANSIENT_REASONS else "已跳过"
return f"[✗] 认证未完成{self._task_suffix(data.get('task_number'))} | {action}"
if kind == "control":
states = {
"paused": "[⏸] 认证服务已暂停",
"running": "[▶] 认证服务已恢复",
"cancelling": "[■] 正在取消当前任务",
"idle": "[•] 当前没有可取消的任务",
"stopping": "[■] 正在安全退出",
"usage: take <positive-count>": "[!] 用法take NN 必须是正整数",
"inventory unavailable": "[!] 当前凭据库存不可用",
"help": (
"[•] 命令 s 状态 | take N 取用 | p 暂停 | "
"r 恢复 | c 取消 | q 退出"
),
"unknown": "[!] 未知命令 | 输入 help 查看命令",
}
return states.get(data.get("state"), "[•] 控制命令已处理")
if kind == "status":
state = {"running": "运行中", "paused": "已暂停", "stopping": "停止中"}.get(
data.get("state"), "未知"
)
cooldown = (
f"限流冷却 {data.get('cooldown_remaining_seconds', 0):.0f}"
if data.get("cooldown")
else "不限流"
)
return (
f"[•] 状态 {state} | 待处理 {self._pending(data.get('pending_total'))} | "
f"阶段 {data.get('active_stage', 'idle')} | "
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
f"累计 {data.get('imported_unique', 0)} | "
f"可用 {data.get('available', 0)} | 已取用 {data.get('claimed', 0)} | {cooldown}"
)
if kind == "inventory_taken":
return (
f"[✓] 已取用 {data['moved']} 个凭据 | "
f"剩余可用 {data['available']} | "
f"位置 claimed/{data['batch_id']}/"
)
if kind == "inventory_error":
return (
f"[!] 凭据取用失败 | 可用 {data['available']} | "
f"处理中 {data['claiming']} | 已取用 {data['claimed']}"
)
if kind == "pipeline_error":
return f"[!] 认证流水线异常 | 阶段 {data.get('stage', 'unknown')}"
return None
def _format_debug(self, kind, data):
if kind == "service_started":
return (
"• service: running; "
f"min_interval={data['min_authorization_interval_seconds']:.1f}s"
)
if kind == "service_stopped":
return "• service: stopped"
if kind == "source_connected":
return "• source: local snapshot updated"
if kind == "source_updated":
return f"• source: new={data['new']}; total={data['total']}"
if kind == "source_disconnected":
return "⚠ source: snapshot_sync_failed; keeping previous snapshot"
if kind == "source_record_rejected":
return "⚠ source: invalid record rejected"
if kind == "rate_limited":
pace = data.get("min_authorization_interval_seconds")
pace_text = f"; resume_interval={pace:.1f}s" if pace is not None else ""
return (
"⏸ authentication rate limited; "
f"next single probe in {data['wait_seconds']}s{pace_text}"
)
if kind == "rate_limit_cleared":
return (
"▶ authentication rate limit cleared after "
f"{data['elapsed_seconds']}s"
)
if kind == "pacing_adjusted":
return (
"• pacing: min_interval="
f"{data['min_authorization_interval_seconds']:.1f}s"
f" ({data.get('reason', 'adjusted')})"
)
if kind == "authorization_started":
return (
"→ authentication: next task started; "
f"task={data['task_number']}; attempt={data['attempt_number']}; "
f"queued={data['source_queue']}; "
f"pending={self._pending(data.get('pending_total'))}"
)
if kind == "result":
if data.get("status") == "imported":
return (
"✓ authentication: imported; "
f"task={data.get('task_number')}; total={data['imported_unique']}; "
f"5m_rate={self._rate(data.get('five_minute_imports_per_minute'))}; "
f"attempt_success={self._percentage(data['attempt_success'])}; "
f"eventual_success={self._percentage(data['eventual_success'])}"
)
reason = data.get("reason")
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
return (
f"⚠ authentication: {data.get('status', 'unknown')} ({safe_reason}); "
f"task={data.get('task_number')}; attempt={data.get('attempt_number')}"
)
if kind == "control":
state = data.get("state", "unknown")
safe_state = state if str(state).replace(" ", "").isalnum() else "unknown"
return f"• service: {safe_state}"
if kind == "status":
rate = data.get("five_minute_imports_per_minute")
rate_text = "unknown" if rate is None else f"{rate:.2f}/min"
lifetime_rate = data.get("lifetime_imports_per_minute")
lifetime_rate_text = (
"unknown"
if lifetime_rate is None
else f"{lifetime_rate:.2f}/min"
)
return (
f"• status: {data['state']}; "
f"queues={data['source_queue']}/{data['prepared_queue']}/"
f"{data['completion_queue']}; active={data['active_stage']}; "
f"retry_waiting={data['retry_waiting']}; "
f"next_retry={data['next_retry_seconds']:.1f}s; "
f"started={data['authorization_starts']}; "
f"cooldown={str(data['cooldown']).lower()}; "
f"cooldown_remaining={data['cooldown_remaining_seconds']:.1f}s; "
f"probe={str(data['probe_in_flight']).lower()}; "
f"min_interval={data['min_authorization_interval_seconds']:.1f}s; "
f"pace_remaining={data['pacing_remaining_seconds']:.1f}s; "
f"imported={data['imported_unique']}; attempted={data['attempted_unique']}; "
f"rate_limited={data['rate_limited']}; 5m_rate={rate_text}; "
f"lifetime_rate={lifetime_rate_text}; "
f"available={data['available']}; claiming={data['claiming']}; "
f"claimed={data['claimed']}"
)
if kind == "inventory_taken":
return (
f"✓ inventory: claimed={data['moved']}; "
f"available={data['available']}; batch={data['batch_id']}"
)
if kind == "inventory_error":
return (
f"⚠ inventory: available={data['available']}; "
f"claiming={data['claiming']}; claimed={data['claimed']}"
)
if kind == "pipeline_error":
reason = data.get("reason")
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
stage = data.get("stage", "unknown")
safe_stage = stage if str(stage).replace("_", "").isalnum() else "unknown"
return f"⚠ service: {safe_stage} stage stopped ({safe_reason})"
known = self._format_user(kind, data)
if known is not None:
return known
reason = data.get("reason")
suffix = f" reason={reason}" if reason in self._SAFE_REASONS else ""
safe_kind = kind if kind.replace("_", "").isalnum() else "unknown"
return f"• debug event={safe_kind}{suffix}"
def emit(self, event):
kind, data = event
try:
message = (
self._format_debug(kind, data)
if self.mode == "debug"
else self._format_user(kind, data)
)
if message is not None:
self.output(message)
except Exception:
return
class AuthPipelineRunner:
"""Map interactive controls onto the persistent pipeline lifecycle."""
def __init__(self, pipeline, emit, *, interval_seconds=None, inventory=None):
self.pipeline = pipeline
self.emit = emit
self.inventory = inventory
self.paused = False
self.current_cycle = None
async def handle_command(self, command):
command = command.strip().lower()
if command == "p":
self.paused = True
self.pipeline.pause()
self.emit(("control", {"state": "paused"}))
elif command == "r":
self.paused = False
self.pipeline.resume()
self.emit(("control", {"state": "running"}))
elif command == "s":
status = self.pipeline.status()
status.update(self.pipeline.ledger.inventory_counts())
self.emit(("status", status))
elif command.startswith("take "):
parts = command.split()
if len(parts) != 2 or not parts[1].isdigit() or int(parts[1]) <= 0:
self.emit(("control", {"state": "usage: take <positive-count>"}))
return True
if self.inventory is None:
self.emit(("control", {"state": "inventory unavailable"}))
return True
try:
batch = await asyncio.to_thread(self.inventory.take, int(parts[1]))
except InventoryError as exc:
self.emit(
(
"inventory_error",
{
"reason": str(exc),
**self.pipeline.ledger.inventory_counts(),
},
)
)
return True
counts = self.pipeline.ledger.inventory_counts()
self.emit(
(
"inventory_taken",
{
"batch_id": batch.batch_id,
"directory": str(batch.directory),
"moved": batch.moved,
**counts,
},
)
)
elif command == "c":
cancelled = await self.pipeline.cancel_active()
self.emit(
("control", {"state": "cancelling" if cancelled else "idle"})
)
elif command in {"help", "h", "?"}:
self.emit(("control", {"state": "help"}))
elif command in {"q", "quit", "exit"}:
self.pipeline.request_stop()
self.emit(("control", {"state": "stopping"}))
return False
elif command:
self.emit(("control", {"state": "unknown"}))
return True
async def run(self):
self.current_cycle = asyncio.create_task(self.pipeline.run())
try:
await self.current_cycle
finally:
self.current_cycle = None
async def _run_interactive(runner, terminal):
loop = asyncio.get_running_loop()
commands = asyncio.Queue()
prompt = InteractiveCommandPrompt()
previous_output = terminal.output
terminal.output = prompt.write_event
prompt.write_event(
"命令s 状态 | take N 取用凭据 | p 暂停 | r 恢复 | c 取消当前任务 | q 退出"
)
try:
prompt.attach(loop, commands.put_nowait)
except Exception:
prompt.close(loop)
terminal.output = previous_output
raise
worker = asyncio.create_task(runner.run())
try:
while not worker.done():
command_task = asyncio.create_task(commands.get())
done, _pending = await asyncio.wait(
{worker, command_task}, return_when=asyncio.FIRST_COMPLETED
)
if worker in done:
command_task.cancel()
with suppress(asyncio.CancelledError):
await command_task
await worker
break
command = command_task.result()
if command is None:
command = "q"
if not await runner.handle_command(command):
break
except asyncio.CancelledError:
runner.pipeline.request_stop()
raise
finally:
prompt.close(loop)
terminal.output = previous_output
runner.pipeline.request_stop()
await asyncio.gather(worker, return_exceptions=True)
async def main_async(*, log_mode="user"):
import httpx
from .auth_pipeline import AuthPipeline
from .config import Settings
from .executors import PlaywrightExecutor
from .inventory import CredentialInventory
from .ledger import Ledger
from .protocol import XAIProfile, XAIProtocol
from .remote_stream import (
DiskSnapshotSource,
LocalSnapshotSynchronizer,
SSHSnapshotSynchronizer,
)
from .sinks import LocalAuthFileSink
service_settings = AuthServiceSettings.from_environ()
merged = prepare_local_service_environment()
merged["XAI_ENROLLER_SOURCE_KIND"] = "remote"
merged["XAI_ENROLLER_AUTH_EXECUTOR"] = "playwright"
merged["XAI_ENROLLER_CONCURRENCY"] = "1"
settings = Settings.from_environ(merged)
terminal = EventTerminal(mode=log_mode)
# CF prewarm via external FlareSolverr (:8191). Separate from REGISTER_PROXY.
try:
from grok_register.clearance import (
format_prewarm_log,
httpx_proxy_mounts,
prewarm_clearance,
register_proxy_url,
)
prewarm = await asyncio.to_thread(prewarm_clearance)
terminal.emit(("control", {"state": format_prewarm_log(prewarm)}))
reg_proxy = register_proxy_url()
if reg_proxy:
terminal.emit(("control", {"state": f"register_proxy={reg_proxy}"}))
proxy_url = httpx_proxy_mounts()
except Exception as exc: # noqa: BLE001
terminal.emit(("control", {"state": f"clearance_prewarm_skip: {exc}"}))
proxy_url = None
# httpx honors REGISTER_PROXY/HTTP_PROXY only when passed explicitly
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
pipeline = None
try:
ledger = Ledger(settings.ledger_path, settings.source_salt)
snapshot_path = Path(settings.local_auth_dir) / "source-snapshot.jsonl"
if service_settings.source_kind == "ssh":
synchronizer = SSHSnapshotSynchronizer(
service_settings.ssh_host,
snapshot_path,
remote_root=service_settings.remote_root,
identity_file=service_settings.identity_file,
fingerprint=ledger.fingerprint,
)
else:
synchronizer = LocalSnapshotSynchronizer(
service_settings.register_root,
snapshot_path,
fingerprint=ledger.fingerprint,
)
source = DiskSnapshotSource(
snapshot_path,
synchronizer=synchronizer,
sync_seconds=service_settings.sync_seconds,
event_callback=lambda kind, data: terminal.emit((kind, data)),
fingerprint=ledger.fingerprint,
)
protocol = XAIProtocol(
client,
XAIProfile.default(),
default_poll_interval=settings.poll_interval,
)
executor = PlaywrightExecutor(concurrency=1)
sink = LocalAuthFileSink(
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
name_secret=settings.source_salt,
)
inventory = CredentialInventory(
ledger,
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
Path(settings.local_auth_dir) / CLAIMED_DIRNAME,
)
recovered = await asyncio.to_thread(inventory.recover)
if recovered:
terminal.emit(("control", {"state": f"recovered {recovered} claims"}))
terminal.emit(
(
"startup",
{
"destination": f"{AUTHENTICATED_DIRNAME}/",
"source_kind": service_settings.source_kind,
**ledger.inventory_counts(),
},
)
)
pipeline = AuthPipeline(
source=source,
protocol=protocol,
executor=executor,
sink=sink,
ledger=ledger,
timeout=settings.timeout_sec,
min_authorization_interval=(
service_settings.min_authorization_interval_seconds
),
event_callback=lambda kind, data: terminal.emit((kind, data)),
)
# Keep base cooldown configurable; the gate grows it after consecutive trips.
pipeline.rate_gate.base_cooldown = float(service_settings.retry_seconds)
pipeline.rate_gate.COOLDOWN_SECONDS = float(service_settings.retry_seconds)
pipeline.rate_gate._current_cooldown = float(service_settings.retry_seconds)
runner = AuthPipelineRunner(pipeline, terminal.emit, inventory=inventory)
await _run_interactive(runner, terminal)
finally:
if pipeline is not None:
pipeline.request_stop()
await client.aclose()
def _known_configuration_key(error):
message = str(error)
keys = (
"XAI_AUTH_SERVICE_LOG_MODE",
"XAI_AUTH_SERVICE_SOURCE",
"XAI_AUTH_SERVICE_SSH_HOST",
"XAI_AUTH_SERVICE_REGISTER_ROOT",
"XAI_AUTH_SERVICE_SYNC_SEC",
"XAI_AUTH_SERVICE_RETRY_SEC",
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC",
"XAI_ENROLLER_TARGET",
"XAI_ENROLLER_RETRY_ATTEMPTS",
"XAI_ENROLLER_TIMEOUT_SEC",
"XAI_ENROLLER_POLL_SEC",
"XAI_ENROLLER_AUTH_EXECUTOR",
"XAI_ENROLLER_SINK",
"XAI_ENROLLER_CPA_BASE_URL",
"XAI_ENROLLER_CPA_MANAGEMENT_SECRET",
"XAI_ENROLLER_LOCAL_AUTH_DIR",
"XAI_ENROLLER_SOURCE_SALT",
)
explicit = next((key for key in keys if key in message), None)
if explicit is not None:
return explicit
message_keys = {
"target must be between 1 and 100": "XAI_ENROLLER_TARGET",
"retry attempts must be between 0 and 3": "XAI_ENROLLER_RETRY_ATTEMPTS",
"timeout must be positive": "XAI_ENROLLER_TIMEOUT_SEC",
"poll interval must be positive": "XAI_ENROLLER_POLL_SEC",
"executor must be http or playwright": "XAI_ENROLLER_AUTH_EXECUTOR",
"sink must be cpa or local": "XAI_ENROLLER_SINK",
"CPA base URL must use HTTPS": "XAI_ENROLLER_CPA_BASE_URL",
"CPA base URL and management secret are required": (
"XAI_ENROLLER_CPA_BASE_URL/XAI_ENROLLER_CPA_MANAGEMENT_SECRET"
),
"local auth directory is required": "XAI_ENROLLER_LOCAL_AUTH_DIR",
"source salt is required": "XAI_ENROLLER_SOURCE_SALT",
}
return message_keys.get(message)
def _print_unexpected_service_failure(error, *, log_mode):
if log_mode == "debug":
detail = f"异常类别 {type(error).__name__}"
else:
detail = "使用 bash auth-service.sh --debug 查看异常类别"
print(f"[✗] 认证服务异常终止 | {detail}", file=sys.stderr)
def main(argv=None):
load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env")
mode = "user"
try:
mode = resolve_auth_log_mode(argv)
asyncio.run(main_async(log_mode=mode))
return 0
except AuthServiceConfigurationError as error:
key = _known_configuration_key(error) or "认证服务参数"
print(
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
file=sys.stderr,
)
return 2
except ValueError as error:
key = _known_configuration_key(error)
if key is not None:
print(
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
file=sys.stderr,
)
return 2
_print_unexpected_service_failure(error, log_mode=mode)
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
_print_unexpected_service_failure(error, log_mode=mode)
return 1
if __name__ == "__main__":
raise SystemExit(main())

106
xai_enroller/sinks.py Normal file
View File

@@ -0,0 +1,106 @@
import asyncio
import hashlib
import hmac
import json
import os
import tempfile
from pathlib import Path
from typing import Protocol
from urllib.parse import urlencode
import httpx
from .models import OAuthCredential, SinkReceipt
class SinkError(RuntimeError):
pass
class CredentialSink(Protocol):
async def store(self, credential: OAuthCredential) -> SinkReceipt: ...
def cpa_document(credential: OAuthCredential):
return {
"type": "xai",
"access_token": credential.access_token,
"refresh_token": credential.refresh_token,
"id_token": credential.id_token,
"token_type": credential.token_type,
"expires_in": credential.expires_in,
"expired": credential.expires_at,
"last_refresh": credential.last_refresh,
"sub": credential.subject,
"base_url": "https://api.x.ai/v1",
"token_endpoint": credential.token_endpoint,
"auth_kind": "oauth",
}
def credential_filename(credential: OAuthCredential, name_secret: bytes):
subject = credential.subject or credential.refresh_token
digest = hmac.new(name_secret, subject.encode(), hashlib.sha256).hexdigest()[:16]
return f"xai-{digest}.json"
class CPAAuthFileSink:
def __init__(self, base_url, management_secret, client: httpx.AsyncClient, name_secret=None):
self.base_url = base_url.rstrip("/")
self.management_secret = management_secret
self.client = client
self.name_secret = name_secret or management_secret.encode()
async def store(self, credential: OAuthCredential):
filename = credential_filename(credential, self.name_secret)
document = cpa_document(credential)
response = await self.client.post(
f"{self.base_url}/v0/management/auth-files?{urlencode({'name': filename})}",
headers={
"Authorization": f"Bearer {self.management_secret}",
"Content-Type": "application/json",
},
json=document,
follow_redirects=False,
)
if response.status_code // 100 != 2:
raise SinkError("CPA upload rejected")
return SinkReceipt(filename.removesuffix(".json"))
class LocalAuthFileSink:
"""Atomically persist canonical CPA-compatible documents locally."""
def __init__(self, directory, *, name_secret: bytes):
self.directory = Path(directory).expanduser()
self.name_secret = name_secret
async def store(self, credential: OAuthCredential):
return await asyncio.to_thread(self._store_sync, credential)
def _store_sync(self, credential: OAuthCredential):
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.directory, 0o700)
filename = credential_filename(credential, self.name_secret)
destination = self.directory / filename
payload = json.dumps(cpa_document(credential), ensure_ascii=False, indent=2) + "\n"
fd, temporary_name = tempfile.mkstemp(
prefix=f".{filename}.", suffix=".tmp", dir=self.directory, text=True
)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_name, destination)
os.chmod(destination, 0o600)
directory_fd = os.open(self.directory, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return SinkReceipt(filename.removesuffix(".json"))

61
xai_enroller/sources.py Normal file
View File

@@ -0,0 +1,61 @@
import base64
import hashlib
import hmac
import sqlite3
from pathlib import Path
from typing import Iterator, Protocol
from .config import require_private_regular_file
from .models import SourceRecord
class SourceAdapter(Protocol):
def records(self) -> Iterator[SourceRecord]: ...
class FileSourceAdapter:
def __init__(self, path: Path):
self.path = Path(path)
def records(self) -> Iterator[SourceRecord]:
require_private_regular_file(self.path, require_0600=True)
seen = set()
with self.path.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, 1):
raw = line.rstrip("\r\n")
if not raw:
continue
source_id, separator, token = raw.partition("\t")
if not separator or not source_id or not token:
raise ValueError(f"invalid source line {line_number}")
if source_id in seen:
continue
seen.add(source_id)
yield SourceRecord(source_id, token)
class SQLiteSourceAdapter:
def __init__(self, path: Path, salt: bytes):
self.path = Path(path)
self.salt = bytes(salt)
def records(self) -> Iterator[SourceRecord]:
require_private_regular_file(self.path)
uri = f"file:{self.path.absolute()}?mode=ro"
connection = sqlite3.connect(uri, uri=True)
try:
rows = connection.execute(
"SELECT token FROM accounts "
"WHERE status = 'active' AND deleted_at IS NULL "
"ORDER BY updated_at DESC"
)
seen = set()
for (token,) in rows:
fingerprint = hmac.new(self.salt, token.encode(), hashlib.sha256).digest()
source_id = base64.urlsafe_b64encode(fingerprint).rstrip(b"=").decode()
if source_id in seen:
continue
seen.add(source_id)
yield SourceRecord(source_id, token)
finally:
connection.close()