> ## Content Index
> Fetch the complete content index at: https://isoziyuan.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# WorkBuddy 部署器：磁盘 token 被客户端加密之后，脚本怎么自救
- URL: https://isoziyuan.com/p/100169/
- Published: 2026-09-25T03:53:55.000Z
- Updated: 2026-09-27T05:05:18.000Z
- Description: WorkBuddy 桌面端 2026-09-23 起把磁盘上的 AT/RT 换成了 AEAD envelope，磁盘上再也读不到明文；当时那份部署脚本把 dict 静默 str() 成字符串写进 Secret，CI 连挂 4 天。本文给出现在两条取凭据的路径、7 轮实测挖到的真问题，以及给上游脚本打 4 处幂等补丁的做法。
- Author: Isoziyuan
- Tags: 技术实战, workbuddy, Python, GitHub Actions, 调试, 安全

## 一句话

WorkBuddy 桌面端 **2026-09-23 凌晨**起，把磁盘上 `workbuddy-desktop.info` 里的 `accessToken` / `refreshToken` / `phoneNumber` 换成了 AEAD envelope（`{"$wbEncrypted": 1, "envelope": "eyJ..."}`）。**磁盘上再也读不到明文 token**。而我那份当时看起来很正常的部署脚本，把 dict 静默 `str()` 成字符串写进了 GitHub Secret —— 本地一切正常，CI 连着挂 4 天，日志里只有一句 `TOKEN_EXPIRED`。

现在两条路：**加号 / 换号**走官方短信登录（不碰客户端、不受加密影响）；**沿用本机当前账号**则拿 60 秒预算去进程内存里认领。

```bash
python deploy.py --add-account      # 回车后输手机号 + 短信验证码
```

> 只用标准库，**0 第三方依赖**；凭据只经 stdin 写进 GitHub Secret，不落盘、不回显。

## 问题长什么样

`deploy.py --dry-run` 打出来的是这一行：

```
✓ 账号 {'$****'}   AT 2919 字符 / RT 2923 字符 · AT 有效期未知
```

**2919 字符的 AT**？正常应该 1300 出头。`{'$****'}` 是 `mask_phone()` 在 `phone` 是 dict 时打印出来的 —— 看到这行就知道：磁盘上那个字段已经不是字符串了。

CI 侧只有一句 `TOKEN_EXPIRED`，连挂 4 次。因为本地跑 `deploy.py` 根本不联网发请求，只生成 Secret 字符串就完事了 —— **本地永远看不出问题**。

## 根因：`str(dict)` 长得太像 token

把 envelope 里的字段解出来是这样：

```json
{ "suite": 1,
  "keyId": "9127dea1b44020a7",
  "nonce": "CQkzLIQVI5IRgX9m",
  "authTag": "SjBa7wvkzJyX8j7wm8R7pg==",
  "ciphertext": "p2F0p7H2bmIvUtdey0xAh4Y..." }
```

`suite: 1` 加 16 字节 `authTag` → AES-256-GCM 类 AEAD 套件。`keyId` 只是个标识，**真实密钥不落盘**，得从客户端进程内存（或操作系统钥匙串）里拿 —— 离线脚本拿不到。

老代码是这样读的：

```python
at = str(auth.get("accessToken") or "").strip()
rt = str(auth.get("refreshToken") or "").strip()
if rt:
    return phone, at, rt, path
```

`accessToken` 现在是 dict → `str(dict)` 得到一个 **2919 字符**的字符串，`if rt` 照样判真（非空 dict）。整条逻辑走完、**一声不响**，还把这一坨塞进了 Secret。

> 如果客户端是**删掉**字段而不是加密，`str(...)` 会得到 `'{}'`、`if rt` 判假、直接进告警分支 —— 我至少知道坏了。加密真正麻烦的地方不是「读不到」，而是**读不到还长得像读到了**。

## 时间线：客户端什么时候改的

客户端每次写 `workbuddy-desktop.info` 之前会先按时间戳备份一份。`%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\` 里躺着 9 份，从 8-21 一直到 9-25：

| 时间                        | 状态              |
| ------------------------- | --------------- |
| 2026-08-21 08:15          | 明文 AT（1301 字符）  |
| 2026-09-22 10:46 \~ 12:41 | 明文（6 份）         |
| **2026-09-23 04:27**      | **加密 envelope** |
| 2026-09-23 04:32 \~ 现在    | 全部加密            |

时间点和 9-23 凌晨那次客户端后台升级**完全对得上**。

## 现在怎么加号 / 换号

只有一条路：**手机号 + 短信验证码登录**。走官方接口，不读本机登录态、不受加密影响、不需要客户端开着。

```bash
python deploy.py --add-account      # 加一个号；还有号就再跑一次，已有的号不受影响
```

| 手机号怎么写         | 说明                   |
| -------------- | -------------------- |
| 13800000000    | 国内号，直接 11 位          |
| +8613800000000 | 带国家码也行               |
| +85250001234   | 境外号带国家码 —— 官方接口同样支持  |
| 138 0000 0000  | 空格 / 连字符 / 全角数字会自动归一 |

号码输错会**当场让你重输**（3 次机会），不会一路掉到看不懂的兜底文案上。

> **有意留下的缺口**：收不到短信的账号（纯邮箱 / 微信登录）加不进来，只能用 `--token "手机号:AT:RT"` 手动给。拿覆盖面换确定性。

## 改了什么（7 轮实测挖到的真问题）

**1\. 加密态静默通过（本次起因）**  
`load_credential` 里加 `_check_envelope_or_die()`：见到 `$wbEncrypted=1` 直接 die，并把三件事说清楚 —— 这是客户端 09-23 之后的行为、磁盘上已经没有明文、以及三条绕法。报错要指向「密钥不落盘所以本机读不出来」，别只说「字段无效」。

**2\. `--token` 少打一个冒号会静默吞掉 AT**  
原来 2 段输入会兜底成 `phone::rt`，CI 必 401。现在直接 die、不再静默兜底；顺手把 README 承诺过的「多行 / `@` 分隔多账号」实现了。

**3\. 令牌库主键会丢数据**  
原来用 `phone or "account"` 当主键 —— **两个没有手机号的账号（邮箱 / 微信登录）会共用同一个键，后者把前者顶掉**。现在退到 AT 里的 `preferred_username` / `email` / `sub`。

**4\. 短信登录成功之后立刻崩**  
报 `ValueError: not enough values to unpack (expected 5, got 4)`。**凭据其实已经拿到了**，只是返回值元组少一个字段、写不进仓库，白输一次验证码。现在统一契约，中间加一层形状收口（4 个补齐、5 个放行、其它形状立刻报明确错误）。

**5\. 境外号被本地正则挡掉**  
有读者用带 `+` 国家码的号加号时报「格式不对」，然后跳到一句跟手机号毫无关系的「自动恢复链全失败」。查下来是**本地位数正则太窄** —— 官方接口本来就收。现在只挡明显不可能的输入（含字母、位数离谱），其余一律交给服务端判。

**6\. 加号通道收敛成一条**  
以前还有第二条路：先在客户端切到那个号、让脚本去内存里认领。它有时 0.1 秒就成、有时一两分钟不动，还认错过号 —— 已去掉。内存扫描保留，但只服务于「沿用本机当前登录的那个账号」。

**7\. 仓库永远收不到上游更新**  
`sync_with_origin()` 里的 `git reset --hard origin/main` 本意是保住仓库里的令牌库，却把**刚克隆下来的上游代码整个丢弃**。实测仓库脚本比上游**少 341 行** —— 上游 09-23/24/25 新增的 **7 个小程序任务**、Buddy 前置判据全都没同步进来。现在在 reset 之后自动把上游最新代码取回来（只取脚本，令牌库不动），再重新打补丁。

外加几个小的：`parse_schedule` 不去重导致 yml 报 `Duplicated value`；`--gh-proxy` 无 URL 校验（`javascript:` 能拼进环境变量）；`show_run_summary` 只认 emoji 关键词、run 失败时一条日志都打印不出来。测试从 **36 涨到 117** 个。

## 上游脚本的 4 处补丁

部署器克隆上游后会自动打补丁，不改上游仓库、全部幂等（重复跑会报「已是目标形态」且文件哈希不变）：

| 补丁          | 改什么                                                  |
| ----------- | ---------------------------------------------------- |
| 报告输出        | 把 send\_notify\_all 规范成「没有推送渠道时把报告打到 stdout」         |
| 接口容错（点）     | 4 处关键 .json() 包进 \_safe\_json()，解析失败降级为空             |
| **接口容错（面）** | **全局**把 Response.json() 换成「非 JSON 则降级」，失败时打一行带状态码的告警 |
| **单账号故障隔离** | main() 里的 run\_account() 包进 try/except，坏账号跳过并在报告里标红  |

## 一个值得记的坑：容错写在异常的下游，等于没写

上游本来写了 `.get("data", {})` 想兜底 —— **但异常在 `.json()` 那一步就抛出来了，默认值根本没机会生效**。

更麻烦的是：只在「已知崩点」上打补丁治不住。上次补完 4 处，第二天崩在另一个 `.json()` 上。实测上游一共 **90 处 `.json()`，其中 51 处不在 `try` 里** —— 所以这次改成全局兜底。

> **为什么敢全局改**：先用 AST 把全部 **39 处「位于 `try` 体内」**的调用扫了一遍，确认它们的 `except` 分支只做「记日志 / 跳过」、**没有一处**靠这个异常去降级到另一个请求 —— 所以把「抛异常」换成「返回空」不改变任何控制流。

**踩到的第二个坑**：第一版守卫把所有非 JSON 都吞成空数据，结果**那个已经废掉的号在报告里显示「✅ 全部完成！」** —— 用户完全看不出账号已经不能用。现在 401/403 一律上抛（那是**凭据级**故障，不是「这一步拿不到数据」），交给逐账号隔离在报告里如实标红：

```
👤 账号4  19100000000
   ❌ 该账号处理失败（已跳过，不影响其它账号）:
      RuntimeError: 凭据被服务端拒绝（HTTP 401 /v2/activity/growth/tasks）
      —— 该账号需要重新登录，或从令牌库移除
```

## 验证矩阵

| 阶段               | 用例数     |
| ---------------- | ------- |
| 初版               | 36      |
| 加密态检测            | 44      |
| 进程内存认领当前账号       | 73      |
| 令牌库主键修复          | 85      |
| 加号收敛成单通道         | 92      |
| 返回值契约 + 上游补丁     | 111     |
| **全局守卫 + 单账号隔离** | **117** |

`python -m unittest test_deploy` → **117/117**，全程只用标准库。

上游补丁单独对**真实上游文件**验过一次：连打 5 遍，第 1 遍生效、之后每遍都报「已是目标形态」且 sha256 不变，产物 `ast.parse` 通过。

加号路径是端到端真跑的：短信登录拿凭据 → 写入仓库 → 触发 Actions → **两次实跑均 `success`**，报告里五个账号四个正常、一个如实标红。

## 分享前的三类审计

给别人之前固定做三件事：

**1\. 凭据扫描** —— `eyJ` JWT / `ghp_` PAT / 手机号 / 真实邮箱 / 本机路径。踩过：只查「连写形式」，漏了 `+852 5000 1234` 这种**带分隔符的变体** —— 现在按任意分隔符扫。

**2\. 依赖可达性** —— 只 import 标准库，**0 第三方包**，对方拿到就能跑，不用 `pip install` 任何东西。

**3\. 上游许可** —— 上游是 MIT；部署器只做「部署封装 + 幂等补丁」，所有补丁都带 `hardened-by-deployer` 注释，便于识别与日后 merge。

还有两条实战经验：`wb_refresh_tokens.json` 会被 Actions 用 `git add -f` 提交进仓库历史 —— **把整个目录拷给别人，等于把明文 RT 一起给出去**（脚本有清场机制，会先 `reset` 到干净上游历史）；GitHub 提交里的 author / committer 邮箱**永远公开**，跟个人资料里是否隐藏邮箱无关，所以 push 用的是 `noreply@users.noreply.github.com`。

## 下载

永久链接，不限次数：[https://pan.ailxw.com/pickup/37209](https://pan.ailxw.com/pickup/37209?ref=isoziyuan.com)

整包 **100425** 字节，SHA256 `5f8cb5affe48e0096cb1a2d54cd70f78b6ede82462a19a0264a295e28d6b3a36`；里面 `deploy.py` **157597** 字节，SHA256 `983d39be031836942a8f4bcc61d17b705b8cc1122d61aa6fac4892f74d176f08` —— 下完可以自己核一下。

里面含 `deploy.py`、`README.md`、`test_deploy.py`、`deploy.sh`、`一键部署.bat` / `.ps1`、`login-tool/workbuddy_login.py`。

> 已经在用旧版的，**重跑一次部署器**即可带上全部修复与上游最新代码（令牌库不会被动）。