dsh-connect-workbuddy 4.6.1 → 4.6.3
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 +42 -0
- package/lib/bin.js +1 -1
- package/lib/{host-heartbeat-BI7_lfoi.js → host-heartbeat-BnaHx2f6.js} +85 -3
- package/lib/index.js +170 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.3 (2026-10-16)
|
|
4
|
+
|
|
5
|
+
> 版本号是 **patch**:没有接口、配置或界面变更,但它修的是一个**所有国际版模型全部不可用**的缺陷。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **国际版全部模型 400 / `11128 conversation id missing`:网关新增了一道「会话身份」闸,插件一个头都没发(issue #66)**
|
|
10
|
+
- **症状**:`workbuddy-global` 的**每一个**模型、**每一步**都失败,报文是 `400` + 业务码 `11128` / `conversation id missing`。而上游自带的 `displayTips` 把它描述成「这次调用没通过渠道校验,可能不是官方客户端发出的」—— **那句话把人引向客户端身份**(UA / 渠道),真正的可执行信息是字面那句「缺会话 id」。
|
|
11
|
+
- **根因**:国际网关在 3.1.0 修过的「首条必须是 system」之外,**又加了一道**:chat 请求必须携带官方桌面端的会话身份三件套(`X-Conversation-ID` / `X-Conversation-Request-ID` / `X-Conversation-Message-ID`,见官方 bundle 的 `applyGatewayHeaders`),而本插件的 `chatHeaders()` 只发 `X-Product: SaaS` 与 `X-No-*` 系列,**三件套一个都没有**。
|
|
12
|
+
- **两道闸是叠加的,不是替代关系** —— 这是本条最容易改错的地方。报告者用真实国际网关做了矩阵(请求体固定,只改两个变量):
|
|
13
|
+
|
|
14
|
+
| 请求头 | messages | 结果 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 插件现状(含 3.1.0 的 system 兜底) | `["system","user"]` | **400 / 11128** |
|
|
17
|
+
| 现状 **+ 三件套** | `["system","user"]` | **200** |
|
|
18
|
+
| 现状 **+ 三件套** | `["user"]` | **400 / 11128** |
|
|
19
|
+
|
|
20
|
+
末行是**不能把 `ensureSystemHead()` 当成「已经不需要了」而删掉**的证据:system 规则从**充分**变成了**必要不充分**。CHANGELOG 的 3.1.0 那节记着当年的矩阵(空格 system → 200),今天同样的形状是 11128 —— 上游在这期间收紧了校验,而这次收紧**没有**替换掉旧规则。
|
|
21
|
+
- **改了什么**:新增 `conversationHeaders()`,三个头各用一个**新生成的 UUID**(`randomUUID()`,与官方客户端每次请求 `randomUUID()` 的行为一致),在 `chatHeaders()` 里展开。这是报告者逐项二分测出的**最小必需集**:单独一个 `X-Conversation-ID` 仍然 11128,官方还发的 `acp-connection-id` / `X-Agent-*` / `X-IDE-*` / `X-Private-Data` / trace 系列**都不需要**。三个 id 的值上游**不校验来源**,但不复用同一个值 —— 复用等于拿一个 id 冒充三次会话身份,而上游拿它做的就是关联。
|
|
22
|
+
- **两个区域都加,是刻意的**:国内网关当前容忍缺失,所以加在那边无害;而「只在一边发某个头」正是 3.1.0 那个缺陷长成**国际版专有、且在国内侧完全看不见**的原因。一条代码路径、两个区域,少一个分支就少一种只在一边复现的故障形状。
|
|
23
|
+
- **范围**:只加在 chat 端点(`chatHeaders()`)。billing / refresh 等端点**不加** —— 官方客户端调它们时也不带,报告者的矩阵也只在 chat 端点上取过数。
|
|
24
|
+
- **`11128` 的错误分类(报告者建议的第 3 条)本次没有采纳**:他建议把它并入 `policy_reject`(与 `11140` 同类)。但另一位报告者提供了反例 —— 同一批测试、同一毫秒内,一个账号 `ok`、另一个 `11128`,两者**同样缺三件套**。若 11128 其实是按账号生效的,那么「换号能过」意味着它可恢复,落进 `failed` + 冷却 30 分钟后回池反而更合适(`policy_reject` 会让它**不冷却、仍当候选**)。分类改错会让账号池做出更差的调度,所以先把确定的正向做掉,分类问题留待更多数据。
|
|
25
|
+
- **守卫**:`tests/upstream.spec.ts` 新增 7 例 —— ① 国际网关三个头都发;② 国内网关也发(钉住「一条代码路径」这个决定,而不是国内的需求);③ **三个值互不相同**;④ 每个都是合法 UUID 形态;⑤ **每个请求新生成**(防止有人把它提升成模块级常量「优化」掉);⑥ **探针路径也带**(探针打的就是 chat 端点,这也是「一键测试所有账号」能看出 11128 的原因 —— 只修 `chatStream()` 会让账号池拿着不满足规则的请求去测账号);⑦ billing 端点**不带**。
|
|
26
|
+
- **第 ⑦ 条的形态本身踩过一个坑**:初版是 **grep 源码文本**来判断 `billingHeaders()` 里有没有 `conversationHeaders`。变异验证时把一个变异体加进去,**它没红** —— 因为那段文本切片压根没匹配上,测试是在对一个空片段做断言。改为**行为断言**(真的调 `fetchCredits()` 并看发出去的头)后,同一个变异体立刻转红。**变异验证(5 个变异)**:① 从 `chatHeaders` 去掉三件套 → 红 6 例(等价修复前);② 三个值写成同一个 → 红 1 例;③ 提升为模块级常量 → 红 1 例;④ 三件套泄漏到 billing 端点 → 红 1 例;⑤ UUID 形态被破坏 → 红 1 例。
|
|
27
|
+
- **端到端**:报告者已在本地对 `4.6.2` 打同一处补丁并**实测国际版恢复正常**;本仓库侧改动与其独立实现等价(他手写十六进制拼 UUID,本仓库用 `node:crypto` 的 `randomUUID`,后者本仓库 `oauth.ts` / `dsml-recovery.ts` 已在用)。
|
|
28
|
+
|
|
29
|
+
## 4.6.2 (2026-10-16)
|
|
30
|
+
|
|
31
|
+
> 版本号是 **patch**:修的是一个启动期缺陷,没有接口、配置或界面变更。
|
|
32
|
+
|
|
33
|
+
### Fixes
|
|
34
|
+
|
|
35
|
+
- **shim 启动时会避开「HTTP 客户端自己拒绝」的端口**
|
|
36
|
+
- **症状**:某个区域(`cn` 或 `global`)的模型请求**全部失败**,报错只有一句 `Connection error.`,而 shim 本身完全健康 —— `curl`、`node:http`、裸 TCP 连上去都是 200。重启后可能好、可能照样坏。
|
|
37
|
+
- **根因**:`server.listen(0)` 让内核挑端口,而 Fetch 规范定义了一张**必须拒绝**的端口清单(`fetch.spec.whatwg.org#block-bad-port`,undici 实现为 `badPorts`,当前最大到 `10080`)。`fetch` 在**开 socket 之前**就拒绝这些端口,所以请求根本没上过网线。pi-ai 驱动官方 `openai` SDK,而它的 `getDefaultFetch()` 返回 `globalThis.fetch` —— **生产路径走的就是这一层**,不是只有测试才会中招(已实测 `openai@6.40.0`:坏端口报 `Connection error. → fetch failed → bad port`,正常端口正常返回)。端口只在启动时 bind 一次,不会自己重掷,所以一旦抽中就是**该区域持续失败直到插件重新加载**。
|
|
38
|
+
- **只在特定机器上发生**:`listen(0)` 的抽取范围是系统的动态端口范围,三平台默认值(Windows/macOS `49152-65535`、Linux `32768-60999`)**整体高于**清单最大值 `10080`,交集为空 —— 默认机器**结构上不可能中招**。只有当范围被下调到触及 `10080` 以下才会发生,例如本机的遗留注册表值 `MaxUserPort = 15000`(实测 `netsh` 报 `1024-15000`,范围内有 **19 个**被禁端口,约 `0.136%`/次绑定;每次插件加载建 2 个 shim,约 `0.27%`/次加载)。这一条**已按三方独立实测核对**:`0.105%`(60000 次采样)、`0.115%`(20000 次采样)、理论值 `0.136%`。
|
|
39
|
+
- **为什么难自查**:错误链上真正的 `cause`(`bad port`)被 SDK 归一化时丢弃,界面和日志都只剩 `Connection error.`,读不出是端口问题;用户唯一能做的「换个账号」恰好**救不了**(与账号无关)。顺带确认:这个拒绝与代理无关(把 `HTTP_PROXY` 指向死端口、清空 `NO_PROXY`,回环好端口仍返回 200 —— 说明回环流量根本不经过代理),也不是 `ECONNREFUSED`(后者带 `code`,而它是 `cause.message === 'bad port'`、无 `code`)。
|
|
40
|
+
- **改了什么**:新增 `clientRefusesPort()`(绑定后用自己的 `fetch` 打 `/healthz` 探测:**返回任何 HTTP 码都算客户端接受**,含 401;只有抛异常才算被拒)与 `listenOnClientReachablePort()`(有界重掷,上限 `PORT_ROLL_LIMIT = 5`)。用客户端自身探测而**不是**比对一张抄来的端口表:清单是规范的一部分,抄本会随规范漂移,而这里要满足的规则**本身就是客户端的**。绑定方式仍是 `listen(0)` + 仅回环(随机性不变),端口在 `ready` 之前定格,因此 `baseUrl()` 的消费方一行未改。
|
|
41
|
+
- **顺带修掉一个自己引入的缺陷**:`close()` 落在 `listen()` 未完成期间时,Node **既不触发 `listening` 也不触发 `error`,只触发 `close`**(实测确认)——最初的 `listenOnce` 只监听前两者,会让 `ready` 永久 pending、卡住插件卸载。已补 `close` 监听。
|
|
42
|
+
- **守卫**:`tests/shim.spec.ts` 新增 6 例(真实客户端识别被禁端口、健康端口不误判、拒绝与真实传输错误可区分、重掷后确实服务、上界生效不空转、首抽即通过时不重掷),外加 2 例生命周期守卫(启动期被处置不得挂起、并发 `close()` 安全)。**变异验证(4 个变异全部转红,且各自只打中应打中的用例)**:① 探测恒真(永不重绑,等价修复前)→ 红 3 例;② 探测恒假 → 红 1 例;③ 移除 `close` 监听 → 红 1 例(30s 超时,正是挂起);④ 上界失效 → 红 1 例。
|
|
43
|
+
- **端到端验证(跑在真实 `createWorkBuddyShim` 上,非 mock)**:启动 **500 个**真实 shim,落在被禁端口的 **0 个**、客户端不可达的 **0 个**(修复前期望约 0.68 个);劫持 `fetch` 让首次探测抛出与真实 `bad port` **同形状**的错误后,真实对象从 `4512` 重掷到 `4513` 并在新端口正常服务。全量 `79` 文件/`1308` 用例通过,与改动前的 `1300` 相比净增 `8` 例。
|
|
44
|
+
|
|
3
45
|
## 4.6.1 (2026-10-16)
|
|
4
46
|
|
|
5
47
|
> 版本号是 **patch**:只加了一行提示文案,没有行为、接口或配置变更。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-
|
|
2
|
+
import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BnaHx2f6.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join, resolve, win32 } from "node:path";
|
|
5
5
|
import { execFile, execFileSync } from "node:child_process";
|
|
@@ -847,6 +847,34 @@ function clearAtRestKeyCache() {
|
|
|
847
847
|
}
|
|
848
848
|
//#endregion
|
|
849
849
|
//#region src/upstream.ts
|
|
850
|
+
/**
|
|
851
|
+
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
852
|
+
* token refresh, model catalog, and credit balance.
|
|
853
|
+
*
|
|
854
|
+
* 参考:corrinehu/dsh-workbuddy-connect(MIT,Copyright (c) 2026 Corrine Hu)
|
|
855
|
+
* — 端点与 wire behavior 由其实现,上游协议本身参照
|
|
856
|
+
* Sliverkiss/workbuddy2api(MIT)。照搬的部分:按 domain 选择
|
|
857
|
+
* CN/global base、强制 stream:true、tool_choice 压平为字符串、
|
|
858
|
+
* CLI 形态请求头、chat 请求绝不携带 refresh token 的安全红线、
|
|
859
|
+
* 中英文额度不足标记与错误分类、token 刷新的 X-Refresh-Token 头。
|
|
860
|
+
* 改动:原版的 `fetchModels` 只保留 id/name/maxInputTokens/maxOutputTokens,
|
|
861
|
+
* 丢弃了上游其余 20 个字段。实测上游每个模型还给出 `credits` 积分倍率、
|
|
862
|
+
* `supportsImages` 多模态、`reasoning.supportedEfforts` 推理档位、
|
|
863
|
+
* `descriptionZh/En` 描述等,这些正是模型管理卡片所需的信息,
|
|
864
|
+
* 本实现将其完整解析(解析不出则留空,不虚构)。
|
|
865
|
+
* `reasoning` 另有两种拼写:复数 `supportedEfforts` 形态与单数 `effort`
|
|
866
|
+
* 形态(只声明默认档;两网关实测这些模型接受全阶梯,不发参数即不
|
|
867
|
+
* 思考),解析层将单数形态折叠为复数(见 parseReasoning,issue #7)。
|
|
868
|
+
* 另:积分接口改为按套餐名聚合,实测单个账号下同名「运营裂变包」
|
|
869
|
+
* 可达 19 个,逐条渲染会淹没卡片。
|
|
870
|
+
* 模型目录路径改为 `/v2/enterprises/personal/models`(原版为
|
|
871
|
+
* `/console/enterprises/personal/models`):两版官方桌面 App 均调用
|
|
872
|
+
* `/v2` 形态,CN 网关两条路径逐字节同答,而国际版网关(workbuddy.ai)
|
|
873
|
+
* 只答 `/v2`、`/console` 形态返回 HTTP 500——统一 `/v2` 即同时覆盖
|
|
874
|
+
* 国内版与国际版(WorkBuddy AI)账号,区域由 `domain` 自动路由。
|
|
875
|
+
*
|
|
876
|
+
* @module dsh-connect-workbuddy/upstream
|
|
877
|
+
*/
|
|
850
878
|
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
851
879
|
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
852
880
|
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
@@ -1135,6 +1163,59 @@ function commonHeaders(credential) {
|
|
|
1135
1163
|
"User-Agent": CLIENT_UA
|
|
1136
1164
|
};
|
|
1137
1165
|
}
|
|
1166
|
+
/**
|
|
1167
|
+
* The conversation-identity triple the chat endpoint now requires (issue #66).
|
|
1168
|
+
*
|
|
1169
|
+
* WHY THIS EXISTS. The international gateway added a second gate on top of the
|
|
1170
|
+
* `system`-head rule from 3.1.0: a chat request must carry the official desktop
|
|
1171
|
+
* client's conversation identity, or every step is refused with business code
|
|
1172
|
+
* `11128` / `conversation id missing`. That error's own `displayTips` blames
|
|
1173
|
+
* "渠道校验 / 不是官方客户端发出的", which reads as a client-identity problem —
|
|
1174
|
+
* the literal message is the actionable part, and it names a MISSING CONVERSATION
|
|
1175
|
+
* ID, not a missing or wrong user agent.
|
|
1176
|
+
*
|
|
1177
|
+
* The two gates are independent, and both must hold (measured 2026-10-16 on the
|
|
1178
|
+
* real international gateway, plugin body shape unchanged):
|
|
1179
|
+
*
|
|
1180
|
+
* headers messages result
|
|
1181
|
+
* ----------------- ---------------- --------------
|
|
1182
|
+
* plugin as-shipped ["system","user"] 400 / 11128
|
|
1183
|
+
* plugin + triple ["system","user"] 200
|
|
1184
|
+
* plugin + triple ["user"] 400 / 11128
|
|
1185
|
+
*
|
|
1186
|
+
* So `ensureSystemHead()` must NOT be dropped as "no longer needed": the system
|
|
1187
|
+
* rule went from sufficient to necessary-but-not-sufficient. The last row is what
|
|
1188
|
+
* keeps this function from being mistaken for a replacement for it.
|
|
1189
|
+
*
|
|
1190
|
+
* MINIMAL SET. The official bundle's `applyGatewayHeaders` also sends
|
|
1191
|
+
* `acp-connection-id`, `X-Agent-Intent` / `X-Agent-Purpose`, `X-IDE-*`,
|
|
1192
|
+
* `X-Private-Data` and trace headers. Bisecting them one at a time showed NONE of
|
|
1193
|
+
* those is required: `X-Conversation-Request-ID` and `-Message-ID` are needed
|
|
1194
|
+
* alongside `X-Conversation-ID` (that one alone still fails), and each of the
|
|
1195
|
+
* extras is satisfied by the triple without it. Hence exactly three.
|
|
1196
|
+
*
|
|
1197
|
+
* VALUES. The gateway does not verify where these ids come from — any well-formed
|
|
1198
|
+
* UUID passes — so nothing here is a secret and nothing is persisted. They are
|
|
1199
|
+
* still freshly generated per request, matching the official client's
|
|
1200
|
+
* `randomUUID()` per call: reusing one value across requests would forge a
|
|
1201
|
+
* conversation identity the plugin does not actually have, and the first thing
|
|
1202
|
+
* that would break is the upstream's own correlation of a session it is being
|
|
1203
|
+
* told about twice under one id.
|
|
1204
|
+
*
|
|
1205
|
+
* APPLIED TO BOTH REGIONS on purpose. The CN gateway tolerates their absence
|
|
1206
|
+
* today, so this is harmless there — and a header that is only ever sent on one
|
|
1207
|
+
* side is exactly how the 3.1.0 defect became international-only and invisible
|
|
1208
|
+
* from the CN side. One code path, both regions.
|
|
1209
|
+
*
|
|
1210
|
+
* @returns the three headers, every value distinct.
|
|
1211
|
+
*/
|
|
1212
|
+
function conversationHeaders() {
|
|
1213
|
+
return {
|
|
1214
|
+
"X-Conversation-ID": randomUUID(),
|
|
1215
|
+
"X-Conversation-Request-ID": randomUUID(),
|
|
1216
|
+
"X-Conversation-Message-ID": randomUUID()
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1138
1219
|
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
1139
1220
|
function chatHeaders(credential) {
|
|
1140
1221
|
return {
|
|
@@ -1143,7 +1224,8 @@ function chatHeaders(credential) {
|
|
|
1143
1224
|
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
1144
1225
|
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
1145
1226
|
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
1146
|
-
"X-Product": "SaaS"
|
|
1227
|
+
"X-Product": "SaaS",
|
|
1228
|
+
...conversationHeaders()
|
|
1147
1229
|
};
|
|
1148
1230
|
}
|
|
1149
1231
|
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
@@ -3825,7 +3907,7 @@ function generationOf(version) {
|
|
|
3825
3907
|
//#endregion
|
|
3826
3908
|
//#region src/version.ts
|
|
3827
3909
|
/** The npm package version this build was produced from. */
|
|
3828
|
-
const WORKBUDDY_CONNECT_VERSION = "4.6.
|
|
3910
|
+
const WORKBUDDY_CONNECT_VERSION = "4.6.3";
|
|
3829
3911
|
//#endregion
|
|
3830
3912
|
//#region src/host-heartbeat.ts
|
|
3831
3913
|
/**
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BnaHx2f6.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1111,8 +1111,12 @@ function finish(parts, calls) {
|
|
|
1111
1111
|
* JSON、bearer 必须匹配进程内随机 secret)、常量时间比对、
|
|
1112
1112
|
* 随机端口绑定、body 上限、上游错误分类到 HTTP 状态码的映射,
|
|
1113
1113
|
* 均由该项目设计并验证。
|
|
1114
|
-
*
|
|
1115
|
-
|
|
1114
|
+
* 改动:随机端口绑定加了「先确认客户端能用」一步(`listenOnClientReachablePort`)。
|
|
1115
|
+
* 绑定方式仍是 `listen(0)` + 仅回环,随机性不变;改的只是抽到 HTTP 客户端
|
|
1116
|
+
* 禁用端口(fetch 规范的 block-bad-port 清单)时重抽,而不是带着一个自己
|
|
1117
|
+
* 客户端连不上的端口启动。四重校验、常量时间比对、body 上限、
|
|
1118
|
+
* 错误分类均未改动。
|
|
1119
|
+
*
|
|
1116
1120
|
* @module dsh-connect-workbuddy/shim
|
|
1117
1121
|
*/
|
|
1118
1122
|
const REQUEST_BODY_LIMIT = 67108864;
|
|
@@ -1776,6 +1780,147 @@ function contentFrame(chunk, choice, content) {
|
|
|
1776
1780
|
return `data: ${JSON.stringify(next)}\n\n`;
|
|
1777
1781
|
}
|
|
1778
1782
|
/**
|
|
1783
|
+
* Whether this runtime's `fetch` refuses `port` WITHOUT ever connecting.
|
|
1784
|
+
*
|
|
1785
|
+
* `server.listen(0)` lets the kernel pick the port, and the Fetch standard bans
|
|
1786
|
+
* a list of ports that `fetch` must reject before opening a socket
|
|
1787
|
+
* (`fetch.spec.whatwg.org#block-bad-port`, implemented as undici's `badPorts`).
|
|
1788
|
+
* Every client of this shim goes through that same `fetch`: pi-ai drives the
|
|
1789
|
+
* OpenAI SDK, whose `getDefaultFetch()` returns `globalThis.fetch`. So a shim
|
|
1790
|
+
* that lands on a banned port is unreachable to its own client while being
|
|
1791
|
+
* perfectly healthy on the wire — measured on port 6000: `fetch` refuses,
|
|
1792
|
+
* while `node:http`, a raw TCP socket and `curl` all connect and get 200.
|
|
1793
|
+
*
|
|
1794
|
+
* That combination is what makes it worth preventing rather than diagnosing: it
|
|
1795
|
+
* is silent (the SDK surfaces only `Connection error.`, with the `bad port`
|
|
1796
|
+
* cause dropped), total (every request in the region fails), and it does not
|
|
1797
|
+
* re-roll on its own.
|
|
1798
|
+
*
|
|
1799
|
+
* Whether it can happen at all depends on the machine's dynamic port range,
|
|
1800
|
+
* because that is the set `listen(0)` draws from:
|
|
1801
|
+
*
|
|
1802
|
+
* Windows default 49152-65535, Linux default 32768-60999, macOS 49152-65535
|
|
1803
|
+
* — every banned port is <= 10080 (the current list ends there), so the
|
|
1804
|
+
* intersection is EMPTY and these machines cannot be affected.
|
|
1805
|
+
* A range lowered to reach below 10080 — e.g. the legacy `MaxUserPort`
|
|
1806
|
+
* registry value pinned to 15000, which yields 1024-15000 — draws from a
|
|
1807
|
+
* set that CONTAINS banned ports. Measured there: 19 of 13977 ports are
|
|
1808
|
+
* banned, i.e. ~0.14% per bind and ~0.27% per plugin load (two regions,
|
|
1809
|
+
* one bind each).
|
|
1810
|
+
*
|
|
1811
|
+
* Probing with the client itself, rather than comparing against a copy of the
|
|
1812
|
+
* banned list, is deliberate: a hard-coded list drifts the moment the standard
|
|
1813
|
+
* gains a port, whereas the rule being satisfied here IS the client's own, so
|
|
1814
|
+
* asking the client cannot disagree with it. `tests/shim.spec.ts` pins the
|
|
1815
|
+
* error shape this relies on, so a Node change that renames it fails loudly
|
|
1816
|
+
* instead of silently disabling the guard.
|
|
1817
|
+
*/
|
|
1818
|
+
async function clientRefusesPort(port) {
|
|
1819
|
+
try {
|
|
1820
|
+
await fetch(`http://127.0.0.1:${port}/healthz`);
|
|
1821
|
+
return false;
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
return isBadPortRefusal(error);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Whether `error` is the client's PORT REFUSAL rather than a real transport
|
|
1828
|
+
* failure.
|
|
1829
|
+
*
|
|
1830
|
+
* The two must never be conflated: a refused port is worth re-rolling, while
|
|
1831
|
+
* `ECONNREFUSED`/`ECONNRESET` are ordinary transport errors that re-rolling
|
|
1832
|
+
* would only paper over. Measured shapes on Node 24:
|
|
1833
|
+
*
|
|
1834
|
+
* refused port : TypeError('fetch failed') -> cause Error('bad port') (no `code`)
|
|
1835
|
+
* closed port : TypeError('fetch failed') -> cause Error('connect ECONNREFUSED …') (code `ECONNREFUSED`)
|
|
1836
|
+
*
|
|
1837
|
+
* The cause chain is walked, with a bound, rather than read one level deep:
|
|
1838
|
+
* the OpenAI SDK wraps this same refusal in its own `APIConnectionError`, and a
|
|
1839
|
+
* caller should not have to know how deep the active client happens to nest it.
|
|
1840
|
+
*/
|
|
1841
|
+
function isBadPortRefusal(error) {
|
|
1842
|
+
let current = error;
|
|
1843
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
1844
|
+
if (!(current instanceof Error)) return false;
|
|
1845
|
+
if (current.message === "bad port") return true;
|
|
1846
|
+
current = current.cause;
|
|
1847
|
+
}
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Listen on an ephemeral loopback port, rejecting on failure.
|
|
1852
|
+
*
|
|
1853
|
+
* Also settles on `close`, which is NOT redundant: measured on Node 24, a
|
|
1854
|
+
* `close()` that lands while `listen()` is still pending fires neither
|
|
1855
|
+
* `listening` nor `error` — only `close`. Without that third listener a dispose
|
|
1856
|
+
* during startup would leave this promise (and therefore the shim's `ready`)
|
|
1857
|
+
* pending forever, wedging plugin unload. Resolving there is deliberate:
|
|
1858
|
+
* disposal is a satisfied end state, not a startup failure.
|
|
1859
|
+
*/
|
|
1860
|
+
function listenOnce(server) {
|
|
1861
|
+
return new Promise((resolve, reject) => {
|
|
1862
|
+
const settle = () => {
|
|
1863
|
+
server.off("listening", onListening);
|
|
1864
|
+
server.off("error", onError);
|
|
1865
|
+
server.off("close", onClose);
|
|
1866
|
+
};
|
|
1867
|
+
const onListening = () => {
|
|
1868
|
+
settle();
|
|
1869
|
+
resolve();
|
|
1870
|
+
};
|
|
1871
|
+
const onError = (error) => {
|
|
1872
|
+
settle();
|
|
1873
|
+
reject(error);
|
|
1874
|
+
};
|
|
1875
|
+
const onClose = () => {
|
|
1876
|
+
settle();
|
|
1877
|
+
resolve();
|
|
1878
|
+
};
|
|
1879
|
+
server.once("listening", onListening);
|
|
1880
|
+
server.once("error", onError);
|
|
1881
|
+
server.once("close", onClose);
|
|
1882
|
+
server.listen(0, "127.0.0.1");
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
/** Stop listening, resolving once the listener is down. */
|
|
1886
|
+
function closeOnce(server) {
|
|
1887
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Bind an ephemeral loopback port that the CLIENT can actually reach.
|
|
1891
|
+
*
|
|
1892
|
+
* `listen(0)` stays the right way to get a port — the kernel picks a free one
|
|
1893
|
+
* instead of this plugin guessing a number — but the first pick is not always
|
|
1894
|
+
* usable, so the roll repeats while the client refuses it. See
|
|
1895
|
+
* {@link clientRefusesPort} for why the client's own answer is the criterion,
|
|
1896
|
+
* and {@link PORT_ROLL_LIMIT} for why the loop is bounded.
|
|
1897
|
+
*
|
|
1898
|
+
* The probe is injected rather than imported so the re-roll path can be driven
|
|
1899
|
+
* deterministically: no test can make `listen(0)` return a banned port on
|
|
1900
|
+
* demand, so this seam is the only way to pin the behaviour that actually
|
|
1901
|
+
* matters.
|
|
1902
|
+
*
|
|
1903
|
+
* `ready` (the returned promise) therefore resolves only once a port is
|
|
1904
|
+
* settled, which is what lets every consumer keep reading `baseUrl()` as it
|
|
1905
|
+
* always has.
|
|
1906
|
+
*/
|
|
1907
|
+
async function listenOnClientReachablePort(options) {
|
|
1908
|
+
const { server, clientRefuses, logger, cancelled } = options;
|
|
1909
|
+
for (let attempt = 1;; attempt++) {
|
|
1910
|
+
if (cancelled?.() === true) return;
|
|
1911
|
+
await listenOnce(server);
|
|
1912
|
+
const address = server.address();
|
|
1913
|
+
const port = address === null || typeof address === "string" ? void 0 : address.port;
|
|
1914
|
+
if (port === void 0 || !await clientRefuses(port)) return;
|
|
1915
|
+
if (attempt >= 5) {
|
|
1916
|
+
logger?.warn(`dsh-connect-workbuddy: port ${port} is on the HTTP client's refused list and 5 rolls did not avoid one; serving on it anyway`);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
logger?.warn(`dsh-connect-workbuddy: port ${port} is refused by the HTTP client; re-rolling (attempt ${attempt})`);
|
|
1920
|
+
await closeOnce(server);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1779
1924
|
* Start the loopback endpoint. Requests must carry the shim's shared secret;
|
|
1780
1925
|
* the loopback bind alone is not a trust boundary.
|
|
1781
1926
|
*/
|
|
@@ -1804,11 +1949,27 @@ function createWorkBuddyShim(options) {
|
|
|
1804
1949
|
const server = createServer((req, res) => {
|
|
1805
1950
|
handle(req, res);
|
|
1806
1951
|
});
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
server.
|
|
1952
|
+
let disposed = false;
|
|
1953
|
+
server.on("error", (error) => {
|
|
1954
|
+
if (server.listening) logger?.error("dsh-connect-workbuddy: loopback listener error", error);
|
|
1955
|
+
});
|
|
1956
|
+
/**
|
|
1957
|
+
* Resolves once the listener is up on a port the client can actually reach.
|
|
1958
|
+
*
|
|
1959
|
+
* The port is settled HERE — `listenOnClientReachablePort` re-rolls while the
|
|
1960
|
+
* client refuses the assigned one — so every consumer can keep resolving
|
|
1961
|
+
* `baseUrl()` exactly as before, with no invalidation or rebuild step. A
|
|
1962
|
+
* genuine bind failure still rejects, the one behaviour the caller relies on.
|
|
1963
|
+
*/
|
|
1964
|
+
const ready = listenOnClientReachablePort({
|
|
1965
|
+
server,
|
|
1966
|
+
clientRefuses: clientRefusesPort,
|
|
1967
|
+
logger,
|
|
1968
|
+
cancelled: () => disposed
|
|
1969
|
+
}).catch((error) => {
|
|
1970
|
+
logger?.error("dsh-connect-workbuddy: loopback listener failed to start", error);
|
|
1971
|
+
throw error;
|
|
1810
1972
|
});
|
|
1811
|
-
server.listen(0, "127.0.0.1");
|
|
1812
1973
|
const baseUrl = () => {
|
|
1813
1974
|
const address = server.address();
|
|
1814
1975
|
if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
|
|
@@ -2054,10 +2215,10 @@ function createWorkBuddyShim(options) {
|
|
|
2054
2215
|
ready,
|
|
2055
2216
|
baseUrl,
|
|
2056
2217
|
token: () => SHARED_SECRET,
|
|
2057
|
-
close: () => new Promise((resolve
|
|
2218
|
+
close: () => new Promise((resolve) => {
|
|
2219
|
+
disposed = true;
|
|
2058
2220
|
server.close(() => resolve());
|
|
2059
2221
|
server.closeAllConnections();
|
|
2060
|
-
server.once("error", reject);
|
|
2061
2222
|
})
|
|
2062
2223
|
};
|
|
2063
2224
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "4.6.
|
|
5
|
+
"version": "4.6.3",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|