dsh-connect-workbuddy 4.0.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/docs/WINDOWS.md +38 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1130 -15
- package/lib/{host-heartbeat-vvVjdY_q.js → host-heartbeat-jNlsUMZO.js} +610 -26
- package/lib/index.d.ts +297 -2
- package/lib/index.js +794 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.3.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
> **升级前请读这一条(行为变更)**:账号列表里的「删除」在 4.3.0 起是**「暂时隐藏」**——删掉后立即从列表消失,也不会被自动轮询带回来,但它是**可找回**的:点「重新检测账号」会把**所有**隐藏的账号一次性全部找回(不是只找回你刚删的那个),重新登录或导入同一账号也会清除该账号的隐藏记录。如果你的习惯是「删掉就永远不再看到它」,请在升级前确认这一语义可以接受。桌面 App 自身的登录**始终不被改动**——删除只是「本插件不再列出它」。上一版(4.2.0)不存在隐藏记录,因此**没有历史状态需要迁移**。
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **扫码登录改为弹窗交互,发起后自动打开浏览器。**(PR #44)
|
|
10
|
+
- 点开后弹窗内分步走:获取授权链接 → 自动打开浏览器 → 弹窗内显示链接与等待状态 → 完成/失败回填结果。弹窗内提供链接原文、复制、重新打开、取消,以及实时等待秒数与剩余有效分钟。样式复用本项目的导出/导入弹框类。
|
|
11
|
+
- 自动打开用「用户手势内同步开占位窗口、拿到授权 URL 后再导航」规避弹窗拦截;占位窗口被环境拦下时,弹窗内的链接与按钮兜底,功能不损失。
|
|
12
|
+
- **占位窗口刻意不带 `noopener`/`noreferrer`**:按规范,带 `noopener` 的 `window.open` 会打开标签页但返回 `null`,而这里的占位窗口**就是要拿句柄去导航**。安全属性改由 `placeholder.opener = null` 建立,等价且不牺牲句柄。这一条有源码守卫与实际 `window.open` 语义的行为测试(`tests/oauth-auto-open.spec.tsx`)——原先的写法只会留下一个空白标签页。
|
|
13
|
+
- 登录方式文案按区域区分:国际版是网页登录,国内版才是扫码。只拆「称呼登录方式」的三条文案,其余(等待、取消、完成)与方式无关,继续共用。
|
|
14
|
+
|
|
15
|
+
### Fixes
|
|
16
|
+
|
|
17
|
+
- **导入/删除后列表即时刷新,并统一加缓冲遮罩。**(PR #44)
|
|
18
|
+
- **导入后新账号不出现**:导入成功后没有刷新调用;且轮询只在 `signed-in` 时启动,首次导入(原本无账号)根本不轮询。现在导入完成即回调重新读取;轮询门槛改为「该区域有账号」。
|
|
19
|
+
- **删除后列表滞后**:行删除 fire-and-forget 触发刷新,遮罩随即收起,而旧快照仍渲染那一行。现在 `await` 刷新落地后才收起。
|
|
20
|
+
- 新增共用 `BusyOverlay`:删除/导入/扫码添加三处共用同一套遮罩视觉与语义,文案按场景给出,并提示「完成后列表会立即刷新」。
|
|
21
|
+
|
|
22
|
+
- **桌面 App 登录的账号现在可以真正「删除」。**(PR #44)
|
|
23
|
+
- **现象**:账号列表里桌面 App 登录的账号点删除后会在下一次扫描回来,删除按钮像坏了。实测:`removeAccount` 返回 `true`,`accounts()` 仍然列出该账号。
|
|
24
|
+
- **根因**:删的只是 vault 副本,而 `readAll()` 会把桌面扫描结果增量写回 vault(`syncVault`——这正是桌面账号能被选中的原因),下一轮读取又把它写回来。
|
|
25
|
+
- **修法**:新增持久化忽略名单(vault 目录下 `.dismissed.json`,与 vault 同锁同原子写),删除时无论账号来自桌面、OAuth 还是导入都记录 id;扫描(`readAll` 两处)与写回(`syncVault`)都跳过被忽略的账号。**删除的语义是「暂时隐藏」,不是永久删除**:点「重新检测账号」时桌面端现存账号重新出现(该路由先清除隐藏再读取);重新登录或导入同一账号也会清除该账号的隐藏记录。桌面 App 的 auth 文件**始终只读、绝不改动**。
|
|
26
|
+
|
|
27
|
+
- **「重新检测账号」在隐藏名单清不掉时会如实说明,而不是假装成功。**(PR #44 评审追加)
|
|
28
|
+
- **现象(Windows)**:`clearDismissals` 原本是 `rm(path, { force: true })` 外面套一个吞掉异常的 `catch`,而 `force` 只忽略「文件不存在」,不忽略拒绝。POSIX 上 `unlink` 对打开的文件照样成功,所以 macOS 看不出问题;Windows 上杀毒/OneDrive/编辑器持有句柄会让它返回 `EPERM`/`EBUSY`/`EACCES`(见 `docs/WINDOWS.md`)。实测结果是**最糟的一种破法**:账号没回来、界面什么都不说、按钮看起来像成功了——而它的文案明确写着「点重新检测账号即可找回」。
|
|
29
|
+
- **修法**:`clearDismissals` 在 unlink 被拒时回退为「经同一把锁原子写入空名单」(对任何读者而言两种结果等价),并把结果**读回来验证**后返回布尔值;路由把 `dismissalsCleared` 转给卡片;卡片新增账户级错误行(`role="alert"`)如实显示。该状态原先只被写入、从未渲染——`accountError` 的渲染一并补上。
|
|
30
|
+
- 测试模拟 Windows 的拒绝:unlink 被拒时回退生效、两条路都失败时诚实返回 `false`、路由转发该标志、卡片真的把它画出来。
|
|
31
|
+
- **另有一层真机取证**:`tests/windows-file-lock.spec.ts` 用 PowerShell 的 `[System.IO.File]::Open(…, FileShare.Read)` 在 **真实 Windows**(CI 的 `windows-latest`)上占住文件,断言返回 `false` 且账号保持隐藏、释放后同一调用转为成功——占用由操作系统造成,不是注入假异常。其余平台该文件 skip(`FileShare` 无 POSIX 等价物)。
|
|
32
|
+
- **变异验证在 `windows-latest` 上真跑**:还原成原来的裸 `rm` + 吞异常后,该文件 2 例全红,报错即 `expected true to be false`——静默成功这个缺陷在真实 Windows 上复现并被咬住。据此 `docs/WINDOWS.md` §3 该条按其自身分级规则定为 **B 档(CI 实测)**,并写明未升 A 档的原因。
|
|
33
|
+
|
|
34
|
+
- **vault 目录里的非凭据文件不再被当成「读不了的凭据」。**(PR #44 评审追加)
|
|
35
|
+
- **现象**:在**完全没有账号**的机器上(只有这种情况才渲染路径列表),删过账号之后,「已探测路径」会多出一条 `…/vault/.dismissed.json — not a readable vault credential`。登录本身是健康的,插件把自己的记账文件读成了坏掉的 token 文件。
|
|
36
|
+
- **根因**:`readVaultEntries` 与 `diagnose` 两处 vault 枚举都用 `endsWith('.json')` 收编目录内容,而 `.dismissed.json` 是格式正确的 JSON、却不是凭据文档。
|
|
37
|
+
- **修法**:两处统一改用**账号 id 形状**判断(`^[0-9a-f]{24}\.json$`,即 store 自己写出的文件名),顺带覆盖原子写留下的 `<id>.json.<hex>.tmp` 兄弟文件。不用「文件名约定」是因为记账文件与临时文件都不该靠约定去排除。测试含正向对照(真实 vault 条目仍能读到),确保过滤器没有把 vault 变成读不到任何东西的目录。
|
|
38
|
+
|
|
39
|
+
- **清理三条已失效的文案键**(`row.oauthOpen`、`row.oauthUnavailable`、`row.oauthUnavailableGlobal`):改为弹窗后前者不再有引用,后两条自加入起就没有引用。中英文键集合保持一一对应。其余 12 条失效键在本次改动前就已存在,未一并处理。
|
|
40
|
+
|
|
41
|
+
## 4.2.0 (2026-10-10)
|
|
42
|
+
|
|
43
|
+
### Fixes
|
|
44
|
+
|
|
45
|
+
- **导入不再拒掉插件自己导出的账号:CN 聊天网关 `copilot.tencent.com` 现在被识别为国内站。**
|
|
46
|
+
- **现象**:卡片导出 8 个账号,再把同一个文件导入回来,结果是「导入 5 / 跳过 3」——**导出的文件自己导不回去**。用户反馈这 3 个账号在另一台电脑上完全正常。
|
|
47
|
+
- **根因**:`copilot.tencent.com` 不是第三个上游,它**就是本插件自己的国内聊天网关**(`CN_CHAT_BASE = 'https://copilot.tencent.com'`,见 `src/upstream.ts`),而真实凭据的 `domain` 字段就写着它。4.1.x 的导入闸门(`knownRegionOf`,为防伪冒域名而做的「正向识别」)只认 `workbuddy.cn` / `codebuddy.cn` / `workbuddy.ai` / `codebuddy.ai`,`copilot.tencent.com` 匹配不上任何一个 → 返回 `undefined` → 计入 `skipped`。于是插件导出得出、导入不认,自相矛盾。
|
|
48
|
+
- **为什么 4.2.0 而不是 4.1.3**:4.1.0 刚把「批量导出 / 导入」作为新功能交付,4.1.2 收紧区域闸门时把它变成了自身不可用——跨机器迁移这条主路径在 4.1.x 整条线上都是断的,属于功能级缺陷而非补丁级瑕疵。
|
|
49
|
+
- **修法**:`knownRegionOf` 的 CN 表加入 `copilot.tencent.com` 及其子域。仍按可注册域匹配,仿冒主机(`copilot.tencent.com.evil.com`、`evilcopilot.tencent.com`)依旧被拒,闸门没有放宽成默认归 CN。
|
|
50
|
+
- 回归测试两条:一条钉住网关域名与仿冒主机的判定(`tests/transfer.spec.ts`),一条复刻「5 个登录域名 + 3 个网关域名混在一个导出文件」的完整往返,确认导入 CN 得 8/0、导入 global 得 0/8。
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
- **导入不再拒掉插件自己导出的账号:CN 聊天网关 `copilot.tencent.com` 现在被识别为国内站。**
|
|
54
|
+
- **现象**:卡片导出 8 个账号,再把同一个文件导入回来,结果是「导入 5 / 跳过 3」——**导出的文件自己导不回去**。用户反馈这 3 个账号在另一台电脑上完全正常。
|
|
55
|
+
- **根因**:`copilot.tencent.com` 不是第三个上游,它**就是本插件自己的国内聊天网关**(`CN_CHAT_BASE = 'https://copilot.tencent.com'`,见 `src/upstream.ts`),而真实凭据的 `domain` 字段就写着它。4.1.2 的导入闸门(`knownRegionOf`,为防伪冒域名而做的「正向识别」)只认 `workbuddy.cn` / `codebuddy.cn` / `workbuddy.ai` / `codebuddy.ai`,`copilot.tencent.com` 匹配不上任何一个 → 返回 `undefined` → 计入 `skipped`。于是插件导出得出、导入不认,自相矛盾。
|
|
56
|
+
- **修法**:`knownRegionOf` 的 CN 表加入 `copilot.tencent.com` 及其子域。仍按可注册域匹配,仿冒主机(`copilot.tencent.com.evil.com`、`evilcopilot.tencent.com`)依旧被拒,闸门没有放宽成默认归 CN。
|
|
57
|
+
- 回归测试两条:一条钉住网关域名与仿冒主机的判定,一条复刻「5 个登录域名 + 3 个网关域名混在一个导出文件」的完整往返。
|
|
58
|
+
|
|
59
|
+
## 4.1.2 (2026-10-10)
|
|
60
|
+
|
|
61
|
+
### Features
|
|
62
|
+
|
|
63
|
+
- **账号可自行添加、迁移与逐个管理,不必再装桌面 App。**(合并自 PR #38)
|
|
64
|
+
- **扫码添加账号**:卡片新增「扫码添加账号」,点开授权链接在浏览器完成登录即可,无需先装 WorkBuddy 桌面 App。会话只存在进程内(600 秒超时),拿到凭据即终结,token 不落独立账号库。
|
|
65
|
+
- **统一凭据库**:所有凭据统一落盘到 `$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件——扫码添加的、桌面扫描到的、插件刷新过的,都汇入同一目录。桌面 App 的认证文件**始终只读**,插件永不写入或删除它们。
|
|
66
|
+
- **账号行级「测试 / 删除」**:表格每行新增两个按钮。单账号测试复用批处理同一套探测依赖与模型解析,因此行级与批量测的是同一个模型、产生同一种测量。删除只删插件自己的 vault 副本;若该账号来自桌面端,下次扫描会重新出现,文案如实说明。
|
|
67
|
+
- **账号批量导出 / 导入**:多选导出 JSON 文件,选文件后给出**脱敏预览**(只展示字段与 `hasToken`,token 明文不进预览),再勾选导入。文件格式与同类凭据管理工具互通;`uin` 会从 `auth_raw` 恢复,保证「导出 → 换机 → 导入」收敛到同一账号 id,不产生影子账号。关闭 issue #41。
|
|
68
|
+
|
|
69
|
+
### Fixes
|
|
70
|
+
|
|
71
|
+
- **扫码添加账号与导出/导入账号移入「账号池设置」区块。**
|
|
72
|
+
- 两处原先夹在积分区与账号池表格之间,语义上是悬空的——上面是余额、下面是表格,它们自己像两个孤儿。现在归入账号池设置区块,与账号管理其它操作收在一起。
|
|
73
|
+
- **放在该区块的偏好设置之上**,这是有意的:区块开头写着「点保存才生效」,而扫码登录与导入导出是**即时生效**、没有草稿的动作。放在那句提示语之下,会让用户以为要点保存——反之亦然。
|
|
74
|
+
- 两者共用一个虚线边框容器,与下方「点保存」的实线设置行区分开:虚线标记「点了就生效」,实线标记「要保存」。
|
|
75
|
+
- **未登录时同样显示**:账号池区块在未登录时不渲染,而这恰恰是这两项最要紧的时候——扫码是不装桌面 App 时唯一能加账号的方式,从别的电脑导出的文件也是这台机器凭空获得账号的唯一途径。两种状态渲染的是同一个 `accountTools` 节点,顺序与容器完全一致,不会让用户以为这是两个功能。
|
|
76
|
+
- **扫码添加账号与导出/导入账号改为各占一行,不再各占一个大区块。**
|
|
77
|
+
- 原先两者各是一个带虚线边框、内边距 12px 的整块,静止状态下就要占掉三行(标题 + 说明 + 按钮),把账号表格和模型列表挤到需要滚动才能看完。现在两处都是**一行工具条**:标签、状态文字、按钮同处一行,按钮右对齐。
|
|
78
|
+
- **状态文字与标签同行**,并且是卡片变窄时**最先被截断**的那一段(`text-overflow: ellipsis`),而不是把整条栏撑成两行。
|
|
79
|
+
- 导出/导入的**静止说明文字从常驻栏移进了导出确认弹窗**:那句「文件里是账号凭据——请妥善保管」在凭据真正离开机器的那一刻才有意义,常驻只是白白占掉三分之一行。
|
|
80
|
+
- 弹窗本身保持块级布局不变:它要容纳可滚动的账号列表和逐条勾选框,塞不进一行。
|
|
81
|
+
- **修复每次重启都把全部账号重新刷一遍 token 的循环。**
|
|
82
|
+
- 插件刷新出的 token 会写入 vault,但**取用**时仍无条件让桌面 App 的 live 文件优先(该优先是实测结论:上游会吊销备份文件里的 token,不能推翻)。结果每次重启都重新读到桌面那份较旧的 token、判定临近过期、再刷一次,刷新循环从未真正停止。
|
|
83
|
+
- 修法是给 vault 条目加一个**来源标记**:记录该条目是对哪个桌面文件刷新后写下的。取用时若标记与当前读到的文件同名,说明这份副本就是它的刷新结果、必然更新,于是可以取代桌面那份;而**仅靠「活得更久」仍然不能**越过 live 文件。
|
|
84
|
+
- 回归测试模拟 5 次重启并断言刷新只发生 1 次——这正是此前没有任何测试守住的场景。
|
|
85
|
+
- **修复扫码登录与账号导入导出在界面上完全不可用。** 卡片的两处请求漏拼 `action` 参数,宿主一律返回 400。新增 `withWorkBuddyRouteAction` 统一构造,并补上**客户端 ↔ 宿主 URL 契约测试**:此前测试都是手写带 `action` 的 URL,客户端拼出来的地址从未被验证过。
|
|
86
|
+
- **导入的区域门禁由「默认归 CN」改为「正向识别」。** 原实现把无法识别的域名一律当作 CN,而导入文件里的 `domain` 是文件作者可控的伪造值,仿冒域名(如 `workbuddy.ai.evil.com`)会被写进 CN vault。现在只有**确认属于目标区域**的域名才入库,认不出的一律计入 `skipped`;`regionOf` 保持原样,因为它服务的是上游签发的域名。
|
|
87
|
+
- **修复删除账号时删除任意文件。** `accountId` 取自查询参数并直接拼进文件路径,`join` 会规范化 `..`,可越出 vault 目录。现在按账号 id 的真实形态(sha256 前 24 位十六进制)校验,路由层与存储层各一道。
|
|
88
|
+
- **删除账号改为在文件锁内进行。** 原实现绕过锁并删掉 `.lock`,与并发的刷新写入相撞时会让两个写者同时进入临界区。
|
|
89
|
+
- **导出响应加 `Cache-Control: no-store`。** 导出会把 access/refresh token 明文交给浏览器用于下载,不应进入任何缓存。
|
|
90
|
+
|
|
3
91
|
## 4.0.0 (2026-10-04)
|
|
4
92
|
|
|
5
93
|
### ⚠️ Breaking Changes
|
package/README.en.md
CHANGED
|
@@ -34,6 +34,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
|
|
|
34
34
|
<img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
|
|
35
35
|
- **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then higher credits, soonest-expiring, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
|
|
36
36
|
- **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
|
|
37
|
+
- **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Add account by QR code** (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
|
|
37
38
|
- **Credits live in the account pool** — the member table shows each account's remaining credits and nearest expiry, and credits are the ranking's second key (more credits serves first). The queries are **read-only** and consume no credits.
|
|
38
39
|
- **Multi-candidate credential paths** — probes the platform defaults for macOS / Windows / Linux in turn, overridable by environment variable or directly in the card. **When nothing is found, the card lists which paths were probed and why each one failed**: five distinct reasons (absent / unreadable / no usable token / encrypted with no key available / belongs to the other region), so the easiest case to misdiagnose — signed in, but the desktop app is not present — says the app must be there instead of telling you to sign in again. The **belongs to the other region** reason is different in kind: that sign-in is real and usable, it is simply filed under the other tab, so the card states the fix outright — switch tabs, no need to sign in again. The list says it once: the paragraph states the conclusion, the collapsed list supplies the per-path detail, and the two never repeat each other.
|
|
39
40
|
- **Secure loopback shim** — one random port + in-process random secret per region; the real WorkBuddy token is never handed to pi-ai.
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
<img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
|
|
42
42
|
- **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**一键签到所有账号**、**一键测试所有账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次积分高、快过期的积分包、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
|
|
43
43
|
- **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
|
|
44
|
+
- **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码添加账号」(国际版为「网页登录」——那边是浏览器登录页,没有二维码)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
|
|
44
45
|
- **积分信息在账号池里** —— 成员表逐账号显示剩余积分与最近到期时间,排序的第二键就是积分(谁多谁优先)。查询是**只读**的、不消耗积分。
|
|
45
46
|
- **凭据路径多候选** —— macOS / Windows / Linux 逐一探测默认位置,也支持环境变量与卡片内直接指定。**未登录时卡片会列出「探测过哪些路径、各自为什么没成」**:五档原因(不存在 / 读不了 / 没有可用 token / 字段已加密且拿不到密钥 / 属于另一个地区)分开呈现,因此「已经登录了、但桌面 App 不在场」这种最容易误判的情况会明说需要 App 在场,而不是叫你重新登录一次。**属于另一个地区**那一档尤其不同:登录信息是真实可用的,只是挂在另一个标签页下,卡片会直接给出结论「切换标签页即可,不用重新登录」。列表只讲一次——提示段落说结论,折叠列表给路径细节,两处不重复同一件事。
|
|
46
47
|
- **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret,真实 WorkBuddy token 不交给 pi-ai。
|
package/docs/WINDOWS.md
CHANGED
|
@@ -77,6 +77,42 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
|
|
|
77
77
|
| issue #13 | Windows 上账号切换被判死 | profile 配置瞬时占用超出了重试预算的全部持有期 |
|
|
78
78
|
| issue #15 | Windows 上加密凭据读不出 | 探测路径缺 `%LOCALAPPDATA%\WorkBuddy`(用户级安装) |
|
|
79
79
|
| 2.1.0(本次) | 宿主内每次进程存活判定都闪一次黑色控制台窗口 | 心跳探测未设 `windowsHide`,而宿主是无控制台的 GUI 进程(§1-5、§2 A 档取证) |
|
|
80
|
+
| 未发布(PR #44) | 「重新检测账号」没能把删掉的账号找回来,而界面什么都不说 | 隐藏名单删除被拒时异常被吞掉(`rm` 的 `force` 不忽略 `EPERM`)——详见下节(B 档) |
|
|
81
|
+
| 未发布(PR #44 追加) | `tests/windows-file-lock.spec.ts` 在真机上恒红,而 CI 恒绿 | **测试自身的清场竞态**(不是产品缺陷):`kill()` 只投递信号,内核关闭句柄在其后数毫秒,此窗口内 `rm` 必得 `EBUSY`——详见下节 |
|
|
82
|
+
|
|
83
|
+
### 「重新检测账号」的静默失败(PR #44,**B 档 CI 实测**)
|
|
84
|
+
|
|
85
|
+
**现象**:用户删掉一个账号(语义是「暂时隐藏」,文案承诺「点重新检测账号即可找回」),随后点「重新检测账号」——账号**没有回来**,界面**没有任何提示**,按钮看起来像成功了。
|
|
86
|
+
|
|
87
|
+
**根因**:`clearDismissals` 把「清空隐藏名单」写成 `rm(path, { force: true })` 外面套一个吞掉异常的 `catch`。`force` 只忽略 `ENOENT`(文件不存在),**不忽略拒绝**。POSIX 的 `unlink` 对另一个进程持有的打开文件照样成功,所以 macOS/Linux 看不出问题;Windows 上杀毒扫描器 / OneDrive / 编辑器持有句柄时返回 `EPERM`/`EBUSY`/`EACCES`(与 issue #11、#13 同一类占用,`isFileContentionWriteError` 就是为分类它而存在)。`catch` 又把它吞掉,于是失败在用户侧表现为「按钮没反应」。
|
|
88
|
+
|
|
89
|
+
**为什么这条必须进 §3**:它是本文件反复描述的那个形态的又一例——**静默**(不报错、只是没生效)且**只在 Windows 上出现**。
|
|
90
|
+
|
|
91
|
+
**修法**:unlink 被拒时回退为「经同一把锁原子写入空名单」(对任何读者而言两种结果等价,`readDismissed` 都得到空集合),并把结果**读回来验证**后返回布尔值;路由把 `dismissalsCleared` 转给卡片;卡片新增账户级错误行(`role="alert"`)如实显示——该状态此前只被写入、从未渲染。
|
|
92
|
+
|
|
93
|
+
**证据档位:B 档(CI 实测),不是 C 档约定推导。** 关键区别在于**占用是真的由操作系统造成的**,不是注入一个假异常:
|
|
94
|
+
|
|
95
|
+
- `tests/windows-file-lock.spec.ts` 用 PowerShell 的 `[System.IO.File]::Open($path, 'Open', 'Read', 'Read')` 真占住 `.dismissed.json`,再断言 `clearDismissals()` 返回 `false` 且账号保持隐藏;释放后同一调用转为成功。该文件只在 Windows 上运行,其余平台 skip(`FileShare` 没有 POSIX 等价物,macOS/Linux 上 unlink 对打开的文件照样成功,没有可断言的失败)。
|
|
96
|
+
- **变异验证(在 `windows-latest` 上真跑)**:把 `clearDismissals` 还原成「裸 `rm` + 吞异常、恒返回 `true`」,该文件 **2 例全红**,报错正是 `expected true to be false`——静默成功这个缺陷本身在真实 Windows 上复现并被咬住。还原修复后重新全绿。
|
|
97
|
+
- `FileShare::Read` 是被刻意选中的共享模式(不是 `FileShare::None`):读仍能成功,所以隐藏**确实仍然生效**,`false` 是诚实答案而非保守猜测。若换成 `FileShare::None`,连读都失败,`readDismissed` 会走「无隐藏」分支、账号自己就回来了,反倒没有可报的失败——这两种模式**相反**的后果见 `tests/dismissal-clear-failure.spec.ts` 里 `both` 与 `write-blocked-read-ok` 两个模式。
|
|
98
|
+
|
|
99
|
+
未升 A 档的原因:这是 `windows-latest` 虚拟机上的 CI 取证,不是用户真机(未覆盖「某个具体杀毒软件实际持有句柄」的行为)。按 §2 的定义,CI 实测即 B 档;若你在真机上复现出与 CI 不同的形态,欢迎回报以便继续升档。
|
|
100
|
+
|
|
101
|
+
### 收尾阶段的同一种占用:`kill()` 不等于句柄已释放(PR #44 追加)
|
|
102
|
+
|
|
103
|
+
**现象**:`tests/windows-file-lock.spec.ts` 在一台 Windows 真机上**每次都红**,而同一提交在 CI 的 `windows-latest` 上**每次全绿**。报错是 `EBUSY: resource busy or locked, unlink '…\.dismissed.json'`。
|
|
104
|
+
|
|
105
|
+
**为什么它不是产品缺陷(先排除,再动手)**:报错发生在 `afterEach` 的清场里,**测试体内的断言一条都没失败**——`clearDismissals()` 仍如实返回 `false`、账号仍保持隐藏。文件被删不掉,是**测试自己还握着那个句柄**。把这个形态误当成产品 bug 去改 `clearDismissals`,会把一个正确的实现改坏。
|
|
106
|
+
|
|
107
|
+
**根因**:`kill()` 是**投递**信号,不是等待进程结束。该进程持有的 `FileShare.Read` 句柄由内核在其后数毫秒才关闭,而紧跟的 `rm` 已经走到 `unlink` 了。本机实测:`rm` 在 `kill()` 返回后 **1ms** 就到达 `unlink`,句柄直到 **~9ms** 才释放——窗口内 8/8 全红;而**盲等 50ms 后同样的裸 `rm` 8/8 全绿**,这条对照证明缺的只是时间,与删什么东西无关。
|
|
108
|
+
|
|
109
|
+
**为什么 CI 绿而真机红**:这是**竞态**,胜负取决于 `rm` 走到 `unlink` 用了多久。慢 runner(冷启动、遍历临时目录更久)常常在窗口之外才到达 `unlink`,于是恒绿;快机器反而恒红。**「CI 绿」因此不能否证本机红**,反之亦然——两个方向的推断都不成立。
|
|
110
|
+
|
|
111
|
+
**修法**:`afterEach` 改为 `await` **进程真正 `exit`** 之后再 `rm`(等的是这个清场所需的条件,不是一个够长的常数),并保留 `rm` 自身的重试预算作为第二层。同一文件第二个用例里原先的 `setTimeout(1500)` 一并换成同一个等待——**那个盲等正是同一竞态被掩盖的地方**(一个用例靠 1.5s 蒙对,另一个没蒙,于是只有后者暴露)。换掉之后该用例耗时从 7782ms 降到 1615ms:等待条件比猜时间既更准也更快。
|
|
112
|
+
|
|
113
|
+
**变异验证(本机真跑)**:把 `afterEach` 还原成「裸 `kill()` + 立即 `rm`」→ 立刻复现 `EBUSY`,与原始故障逐字一致;恢复修复后 6/6 连续通过。**这条守卫只保护测试自身**:它不会因为产品代码回退而变红(产品侧由 `tests/dismissal-clear-failure.spec.ts` 的注入式用例与上文那条变异验证负责)。
|
|
114
|
+
|
|
115
|
+
**推论(写给后人)**:凡是「起子进程占住文件 → 杀了它 → 立刻清场」的测试,都要 `await` 退出事件,而不是假定 `kill()` 已经把文件还回来了。本仓库目前只有本文件起子进程占文件;新增同类测试时照此办理。
|
|
80
116
|
|
|
81
117
|
**共同形态**:Windows 的故障几乎都是**静默**的(不报错、只是没生效),且**只在 Windows 上出现**。因此 Windows 相关的修复必须配「可被变异咬住」的测试,否则回归无人拦。
|
|
82
118
|
|
|
@@ -89,6 +125,7 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
|
|
|
89
125
|
- [ ] 若涉及文件写入:Windows 上可能被占用,是否需要回读校验?
|
|
90
126
|
- [ ] 若走外部命令:Windows 上有无等价物?(`ps -o lstart=` → PowerShell;`which` → `where`)
|
|
91
127
|
- [ ] **若 spawn 子进程:是否设了 `windowsHide: true`?** 宿主无控制台,漏设即闪黑框(§1-5)。注意**在终端里验证不出来**——那种形态下子进程只是继承控制台
|
|
128
|
+
- [ ] **若测试起子进程占住文件:清场前是否 `await` 了它的 `exit`?** `kill()` 只投递信号,句柄在其后数毫秒才关闭;此窗口内 `rm` 必得 `EBUSY`(§3 末)。窗口是竞态,**CI 绿不代表真机绿**
|
|
92
129
|
- [ ] 新增测试是否**在任意主机上都能跑**(注入 `platform`/`home`/`env`),而不是断言「跑测试的机器是什么平台」?
|
|
93
130
|
- [ ] 结论措辞是否与§2 的证据档位相称?(C 档不要写成「已验证」)
|
|
94
131
|
- [ ] 本文件与两份 README 是否同步?
|
|
@@ -102,6 +139,7 @@ WMI 服务(`Win32_Process.Create`)创建的进程与 Electron 宿主同为
|
|
|
102
139
|
| 可执行文件候选与测试 | `src/at-rest.ts` `workbuddyAppExecutableCandidates()`;`tests/at-rest.spec.ts` |
|
|
103
140
|
| 心跳时间戳与测试 | `src/host-heartbeat.ts` `processStartTimeMs()` / `processStartProbe()`;`tests/host-heartbeat.spec.ts` |
|
|
104
141
|
| 子进程启动选项(`windowsHide`)与测试 | `src/host-heartbeat.ts` `PROCESS_PROBE_OPTIONS`、`src/at-rest.ts` `fetchAtRestKeyPayload()`;`tests/platform-standing.spec.ts`、`tests/host-heartbeat.spec.ts` |
|
|
142
|
+
| 真机文件占用取证与测试自身清场 | `tests/windows-file-lock.spec.ts`(`kill()` 后必须 `await` `exit` 再 `rm`,见 §3 末) |
|
|
105
143
|
| 写入回读校验与测试 | `src/client/account-selection.ts`;`tests/client-account-selection.spec.ts` |
|
|
106
144
|
| 设置隔离(供测试) | `vitest.config.ts`(`LOCALAPPDATA` / `APPDATA` / `HOME` / `USERPROFILE` / `XDG_CONFIG_HOME`) |
|
|
107
145
|
| 诊断脚本的跨平台写法 | `scripts/verify-dsml.mjs`:用 Node 读源码代替 `grep`(Windows 上没有 `grep`,而 `tryExec` 会吞掉失败,于是「工具缺失」会伪装成「没找到」);仓库根用 `fileURLToPath` 而不是 `new URL(...).pathname`(后者在 Windows 上给出 `/C:/...`,任何 fs 调用都不接受) |
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-jNlsUMZO.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|