dsh-connect-workbuddy 5.8.4 → 5.8.5
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 +36 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +22 -3
- package/lib/{host-heartbeat-CAX0oKQT.js → host-heartbeat-4Ao_1fKu.js} +1 -1
- package/lib/index.js +250 -75
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.8.5 (2026-10-24)
|
|
4
|
+
|
|
5
|
+
> 安全与健壮性版本:给设置卡片的 13 条路由(10 条 POST + 3 条 GET)补上缺失的 `Host` 校验(DNS-rebinding),并让测速探针不再发送一个自造的档位值。**发往上游的请求体、provider 注册、凭据、计费与换号规则一律未变**——唯一的上游侧行为变化是测速请求少了一个字段;入站侧(本插件对浏览器请求的接受范围)**确有变化**,见下文两条:Host/Origin 判据的扩展与空 Origin 的统一。
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
|
|
9
|
+
- **测速探针硬编码 `reasoning_effort: 'none'`,导致部分模型的速度永远测不出来。**
|
|
10
|
+
- **症状**:在 global 区对 `gpt-6-astra`(以及其它拒绝该字面量的模型)点「测速」,三次采样全部失败,卡片显示无速度;同类模型在 CN 区正常。
|
|
11
|
+
- **根因(推论,不是本仓实测)**:`src/tps.ts` 的 `tpsProbeRequestBody()` 无条件带上 `reasoning_effort: 'none'`,而 `none` 不是任何模型目录声明过的档位值(`supportedEfforts` 只有 low/medium/high/xhigh/max),属于我们自造的拼写。**本仓库没有记录过任何模型对该字面量的反应**——这条结论来自对一个社区分支(`longge1213812138/dsh-workbuddy-connect`)源码的阅读,其注释称 `none` 被 GPT-5.6 家族与 GLM 接受、被 `gpt-6-astra` 拒绝,我们未复测。已知的相邻事实只有 `src/off-thinking.ts` 记的「10 个模型拒绝字面量 `off`,而省略该字段全数返回 200」。因此这个修复的**确切收益是「不再发送一个自造的、未经验证的档位值」**,而不是「修好了一个已证实的 400」。
|
|
12
|
+
- **修法**:**不发这个字段**,而不是换一个拼写。依据是 `docs/reasoning-investigation.md` §4/§5 的两网关实测:请求不带 `reasoning_effort` 时上游返回 **0 个思考字符**——注意那批实测覆盖的是单数 `effort` 形态的模型(deepseek / hy3 / kimi / minimax 一类),**不含 `gpt-6-astra`**,所以对后者「省略即零思考」是合理推断而非已证事实。结论上仍然成立:不思考本来就不需要任何拼写,而任何拼写都可能被某个模型拒绝。注意这与 `dropRefusedReasoning()` 只是**结论**一致,不是同一段代码——那个函数只对 `REFUSES_OFF_MODEL_IDS` 名单内的模型删字面量 `'off'`,TPS 根本不经过它。
|
|
13
|
+
- **验证**:`tests/tps.spec.ts` 的断言从 `reasoning_effort === 'none'` 改为 `'reasoning_effort' in body === false`,并把模型 id 换成 `gpt-6-astra` 且额外断言 `body.model` 确实进入请求体——这样这条用例描述的是「该模型 + 无该字段」这个组合,而不是任意 id 都成立的巧合。用 `in` 而不是 `=== undefined` 是有意的:后者在「忘了做决定」的 body 上也会通过。变异验证:把字面量加回去 → 该条变红。
|
|
14
|
+
|
|
15
|
+
- **设置卡片的 13 条路由(10 条 POST + 3 条 GET)只校验 `Origin`、不校验 `Host`,DNS-rebinding 页面可以打到其中的读路由。**
|
|
16
|
+
- **症状**:安全缺口,无用户可见现象。**真实的暴露面是 3 条读路由**(`usage` / `account-credits` / `cli-live`)——浏览器对同源 POST 一定带 `Origin`,所以 rebinding 页面打 10 条 POST 时会被旧的 Origin 门拒掉;而同源 GET 不带 `Origin`,旧门直接放行。修复价值真实,但「13 条写路由全可被打」的说法不成立。
|
|
17
|
+
- **根因**:回环判定被写了两份。`src/shim.ts` 的 chat 路由有 `hostIsLoopback` + `originIsLoopback` 两道门,而 `src/web-status.ts` 的 13 条卡片路由只有 `originIsLoopback`(名为 `loopbackOrigin`)。**Origin 挡不住 rebinding**:攻击页面由攻击者域名提供,浏览器把**那个域名**写进 `Host`,socket 却落在 127.0.0.1,于是一条只查 Origin 的 GET 路由会放它过去。这个缺口在 `docs/UPSTREAM-0.7.0-REVIEW.md:27` 早有记录,不是新发现;两份实现各自内部一致,是这次抽共享模块才把差异摆到一处。
|
|
18
|
+
- **修法**:把回环判定抽到新模块 `src/loopback.ts`,shim 与 web-status 共用同一份;13 条路由统一改走 `requireLoopback(req, res)`,即 Host + Origin 两道门,拒绝时分别回 `host-not-trusted` / `origin-not-trusted`。**缺失 `Host` 一律拒绝**(HTTP/1.1 要求该头,在能花积分面上不值得网开一面);**缺失 `Origin` 仍然放行**(这正是插件自己 fetch 与 curl 的形态,拒了会打断插件自身调用)。
|
|
19
|
+
- **Host 判据不是「必须是回环」,而是「必须是本机地址」**(`hostMatchesListener(host, req.socket.localAddress)`),且「本机」包含整个 IPv4 回环段 `127/8` 与本次连接落在的那个接口。这一点是审核阶段两次纠正的:初稿只认 `127.0.0.1`/`localhost`/`[::1]` 三个字面量,而**宿主实际运行的 0.2.1-rc.3 虽然拒绝 wildcard 绑定(其报错原文要求「bind one concrete IPv4 or IPv6 address of a local interface」),却允许绑定任意一个具体的本机地址——包括局域网 IP**;宿主自己的信任层就接受「监听绑定的那个 IP 字面量,任意端口」。只认回环字面量会把这类部署的 3 条**读路由**从「可用」打成 403(10 条 POST 在旧代码下本来就因 Origin 是局域网地址被拒,所以回归面只在这 3 条 GET)。`localAddress` 是连接实际落到哪个接口的事实,攻击者伪造不了:rebinding 的 Host 是攻击者域名而连接落在 127.0.0.1 或某个本机地址,两者不等即被拦下。`0.0.0.0` / `[::]` 显式排除(bind 通配符,不是客户端会合法发出的地址)。**同时把 Host 与 Origin 两条路径的「本机」判定统一为同一个 `isLocalHostname()`**:此前 `127.0.0.2` 与尾点(`localhost.`)在两条路径上给出不同答案,同一个概念两套解析必然漂移。
|
|
20
|
+
- **审核阶段自查出的一处实现缺陷(已修)**:初版 `hostMatchesListener` 直接用 `hostname === localAddress` 比较,于是当两侧都是同一个**主机名**时也返回 true。生产环境里 `localAddress` 来自 socket、永远是 IP 字面量,所以这不是可达的漏洞;但它把「比较两个地址」写成了「比较两个字符串」,一旦有人把主机名传进来(测试替身、未来某个代理头),门禁就会静默放宽成「只要名字相同就放行」。现在右侧必须是 IP 字面量才参与比较,答案重新变成关于地址的判断。同时处理了 IPv6 的括号差异(Host 带括号、`localAddress` 不带)。
|
|
21
|
+
- **Origin 判据同步对齐(审核发现的「只修了一半」)**:Host 判据升级为「本机地址」后,`originIsLoopback` 仍只认回环,而真实局域网浏览器对**同源 POST** 一定发 `Origin: http://192.168.1.5:3000` ——于是 Host 门放行、Origin 门拒绝,10 条写路由(签到 / 测速 / 探针 / 保存模型 / OAuth / 转存 / 账号池)对局域网部署仍然全 403。初版的一处测试把 LAN 场景写成「只有 Host、没有 Origin」,恰好让这个缺口躲过了自己的测试。现在 `originIsLoopback(origin, localAddress)` 与 Host 复用同一套 `isLocalHostname` + `hostMatchesListener` 判定(只比 hostname,不比端口——Origin 永远带页面端口而 `localAddress` 不带),LAN 测试的 fixture 也补上了真实浏览器会发的 `Origin`。变异验证:把 Origin 门改回不传 `localAddress` → 该条立即变红。
|
|
22
|
+
- **已知限制(未能消除,两条)**:
|
|
23
|
+
1. **任何主机名形式的访问都会 403**,不只是 `trustedHosts` 里的域名——`my-mac.local`(mDNS)、反向代理域名、自定义 hosts 条目都算,因为判据只认 IP 字面量与 `localhost`。插件无从知道某个任意域名指向本机,也不该去猜;要用域名访问就得让插件路由进入宿主的信任层,那是另一个改动。
|
|
24
|
+
2. **Origin 只比对 hostname,不比对端口**。同主机其它端口上的页面会通过 Origin 门。这不是本次能消除的威胁(那需要本机已运行代码,等价于本地权限),且反向代理常常在另一个端口终结 TLS、再转发到本机的本端口,比对端口会误伤这类部署。这一取舍写在 `src/loopback.ts` 的注释里。
|
|
25
|
+
- **一处有意的语义统一**:空字符串 / 纯空白的 `Origin` 从「拒绝」变成「放行」。旧 `web-status` 只判 `origin === undefined`,而 shim 的实现一直判 `origin.trim() === ''`——两个文件对同一件事有两种答案,正是这次重构要消除的分裂,所以统一取 shim 的语义。安全性无变化(浏览器对 http(s) 请求不可能发空 Origin,能发空 Origin 的非浏览器客户端本就伪造得了 Host),在此记录以免日后被当成缺陷。
|
|
26
|
+
- **这不是认证,也没有更硬的第二道防线**:两者都能被本机进程、或被任何能连到该端口的对端伪造。它挡的是「外部页面里的浏览器」(浏览器替我们决定 Origin,rebinding 又过不了 Host 比对),挡不住「已经能对这个端口开 socket 的程序」。**`src/web-status.ts` 里没有任何按路由的密钥**——`req.headers` 只读 `host` 与 `origin` 两个字段。所以会花积分、会返回 token 材料的路由与只读路由受同等保护,一旦把监听绑到非回环地址,这些路由就等于对局域网开放。这是整个面的性质,不是本函数能解决的;审核阶段发现初稿把「已有进程内密钥」当成了既有防线,那是错的,已删。
|
|
27
|
+
- **验证**:路由级逐条用例(13 条路径的表驱动块,含「注册集合与表一致」的守卫断言,并断言每条 403 来自门禁而非上游、且上游计数器为 0)与 5 条专项用例——恶意 `Host` → 403 且 `error: 'host-not-trusted'`;完全无 `Host` → 403;四种合法回环 `Host` → 200(**正向对照**,没有它前两条在「全拒」的实现上也会通过);局域网 `Host` + 同接口 `localAddress` → 200(**证明 0.0.0.0 回归已消除**);局域网接口上伪造 `Host` → 403。另有 `tests/loopback.spec.ts` 29 条单元用例覆盖两个判据本身。变异验证三次:删掉 Host 那一支 → 2 条红;把判据退回「只认回环」 → LAN 放行那条红;删掉 Origin 那一支 → Origin 门禁条红。
|
|
28
|
+
- **顺带修掉一个既有缺陷**:`hostnameOfHost()` 对 `[::1]evil.example` 这种「方括号回环前缀 + 任意尾巴」的输入会截成 `[::1]` 并判为回环——也就是说它把**外来源**读成了本机。真实浏览器不会发这种 Host,但这是安全边界,解析器应当 fail closed 而不是抢救前缀。现在方括号后只允许空或 `:<数字>`,其余一律整串返回(不匹配任何回环名)。这个缺陷此前一直躺在 `shim.ts` 里,抽出共享模块时才暴露出来。
|
|
29
|
+
- **代价**:测试替身需要补 `host` 头(5 个调用点 + 新增的 loopback 单测),因为它们此前构造的是浏览器**永远不会发出**的请求形态。这是修 fixture 而不是放宽实现。
|
|
30
|
+
|
|
31
|
+
### Docs
|
|
32
|
+
|
|
33
|
+
- **修正注释里一句与代码不符的描述(issue #138)。** `WorkBuddyModelLabel.tsx` 的文件头写着本组件「renders nothing at all for a provider that is not ours」,但它实际返回 `<span>{model.name}</span>`——**渲染纯目录名**,只是不去装饰。
|
|
34
|
+
- 这不是纯文字问题:该槽是 `kind: "single"`,**占位由注册决定,与组件输出什么无关**,所以返回 `null` 也不会释放格子。那句注释让「丢掉别人的装饰」这个代价看起来无害,issue #138 报告者正是引着它来论证,并在外部把它当成了 bug。
|
|
35
|
+
- **行为一行未改**:保留 §零 的取舍(不做联动、不读别人的注册、`-0.5` 优先级),也**不加**「不占用模型标签槽」开关、**不做**按 provider 动态注册。理由是注册是全局一次性的而 `provider` 是逐行传入的,动态注册会造成空格闪烁并引入对宿主选中状态的观察;这三条处置与理由已写进 `docs/DESIGN.md` §零,供下次讨论直接引用,不必重新论证。
|
|
36
|
+
- 三处注释同步:`WorkBuddyModelLabel.tsx` 文件头(说清「渲染纯目录名 ≠ 什么都不渲染」)、`WorkBuddyModelLabel()` 里那个 `return <span>` 分支(说清代价发生在这行)、`index.tsx` 的 `registerModelLabel` 上方(说清代价由宿主 1017 的行承担,且这是 §零 的价格而非缺陷)。
|
|
37
|
+
- 守卫测试的 docblock 也补了对应关系:`tests/model-label-slot-coexistence.spec.tsx` 那条 `renders the plain catalog name for a foreign row` 的 fixture 正是宿主的 1017 行(`deepseek-official` + `deepseek-flash` + `DeepSeek-V4.1-Flash`),断言 `<span>DeepSeek-V4.1-Flash</span>` 且不含 `1017` —— 它就是 issue #138 的机器可验证形式。
|
|
38
|
+
|
|
3
39
|
## 5.8.4 (2026-10-24)
|
|
4
40
|
|
|
5
41
|
> 修复版本:热重载一次之后设置卡片上一半按钮永久 405 的那一格(issue #86)。根因不是「宿主在跑旧代码」——是 `benefit/claim` 路由的 disposer 从未被调用,泄漏的路由让**下一次**热重载在它身上抛错,于是排在它后面的 8 条路由一条都没注册上。**请求路径、provider 注册、凭据、计费与换号规则一律未变**;改动是 cleanup 列表里的一行。
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as hostPeersInfo, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, V as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, kt as findWorkbuddyAppExecutableWithSource, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-
|
|
2
|
+
import { C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as hostPeersInfo, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, V as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, kt as findWorkbuddyAppExecutableWithSource, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-4Ao_1fKu.js";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
package/lib/client.js
CHANGED
|
@@ -9524,8 +9524,18 @@ window.__ModuleLoader__.load({
|
|
|
9524
9524
|
*
|
|
9525
9525
|
* WHY THE HOST STILL OWNS THE NAME. The contract says to keep `model.name` as
|
|
9526
9526
|
* the text and render the addition beside it, so this component returns the
|
|
9527
|
-
* name first
|
|
9528
|
-
*
|
|
9527
|
+
* name first and never replaces it.
|
|
9528
|
+
*
|
|
9529
|
+
* FOR A PROVIDER THAT IS NOT OURS IT RENDERS THE PLAIN CATALOG NAME — not
|
|
9530
|
+
* nothing. The distinction is not cosmetic: this slot is `kind: "single"`, so
|
|
9531
|
+
* whatever registers becomes the cell's only occupant, and an occupant that
|
|
9532
|
+
* outputs plain text still occupies it. A previous version of this paragraph
|
|
9533
|
+
* said the component "renders nothing at all" for a foreign provider, which
|
|
9534
|
+
* made the lost-decoration cost look harmless; issue #138 is what that
|
|
9535
|
+
* mis-description looks like from the outside (the host's own 1017 badge
|
|
9536
|
+
* disappears on ITS rows while this plugin holds the cell). Returning `null`
|
|
9537
|
+
* would not free the cell either — occupancy is decided by registration, never
|
|
9538
|
+
* by what the component outputs.
|
|
9529
9539
|
*/
|
|
9530
9540
|
/** The provider ids this plugin registers, and the region each one serves. */
|
|
9531
9541
|
const PROVIDER_REGIONS = /* @__PURE__ */ new Map([["workbuddy", "cn"], ["workbuddy-global", "global"]]);
|
|
@@ -11112,7 +11122,16 @@ window.__ModuleLoader__.load({
|
|
|
11112
11122
|
*
|
|
11113
11123
|
* See `MODEL_LABEL_PRIORITY` in `./WorkBuddyModelLabel.tsx` for the full
|
|
11114
11124
|
* ordering, the measured evidence, and the deliberate absence of any
|
|
11115
|
-
* inter-plugin coordination (project invariant, `docs/DESIGN.md`
|
|
11125
|
+
* inter-plugin coordination (project invariant, `docs/DESIGN.md` §零).
|
|
11126
|
+
*
|
|
11127
|
+
* THE COST IS PAID ON ANOTHER PLUGIN'S ROWS, and it is worth stating where
|
|
11128
|
+
* the registration happens: holding this cell means the host's own
|
|
11129
|
+
* `dsh-client-ui-deepseek-model-label-1017` badge is NOT rendered on the
|
|
11130
|
+
* DeepSeek rows it was written for. That is not a defect and not something
|
|
11131
|
+
* to fix by inspecting the slot ledger — it is the price of §零, recorded in
|
|
11132
|
+
* `docs/DESIGN.md` and argued in the 5.8.2 CHANGELOG. Issue #138 is a user
|
|
11133
|
+
* meeting that price for the first time; the disposition is to state it
|
|
11134
|
+
* plainly rather than to coordinate at runtime.
|
|
11116
11135
|
*/
|
|
11117
11136
|
const registerModelLabel = () => {
|
|
11118
11137
|
try {
|
|
@@ -6940,7 +6940,7 @@ function describeRosterSeenPayload(raw) {
|
|
|
6940
6940
|
//#endregion
|
|
6941
6941
|
//#region src/version.ts
|
|
6942
6942
|
/** The npm package version this build was produced from. */
|
|
6943
|
-
const WORKBUDDY_CONNECT_VERSION = "5.8.
|
|
6943
|
+
const WORKBUDDY_CONNECT_VERSION = "5.8.5";
|
|
6944
6944
|
//#endregion
|
|
6945
6945
|
//#region src/host-heartbeat.ts
|
|
6946
6946
|
/**
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-
|
|
1
|
+
import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-4Ao_1fKu.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -13,6 +13,7 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { createServer } from "node:http";
|
|
15
15
|
import { Readable } from "node:stream";
|
|
16
|
+
import { isIP } from "node:net";
|
|
16
17
|
//#region src/catalog-sync.ts
|
|
17
18
|
/**
|
|
18
19
|
* The saved model directory (`lastCatalog`) and the LIVE upstream roster.
|
|
@@ -2320,6 +2321,203 @@ function attachUsage(directory, report, onError) {
|
|
|
2320
2321
|
})();
|
|
2321
2322
|
};
|
|
2322
2323
|
}
|
|
2324
|
+
//#endregion
|
|
2325
|
+
//#region src/loopback.ts
|
|
2326
|
+
/**
|
|
2327
|
+
* Who is allowed to talk to the plugin's own HTTP surfaces.
|
|
2328
|
+
*
|
|
2329
|
+
* There are two distinct questions and they stop two different attacks, so both
|
|
2330
|
+
* live here rather than being re-derived per route. A previous version kept the
|
|
2331
|
+
* Host check inside `shim.ts` and only the Origin check inside `web-status.ts`,
|
|
2332
|
+
* which meant the shim's chat route had both guards while the settings card's
|
|
2333
|
+
* thirteen routes (ten POST + three GET) had one — the asymmetry was invisible
|
|
2334
|
+
* because each file looked internally consistent.
|
|
2335
|
+
*
|
|
2336
|
+
* - **Host** answers "is this request addressed to the interface it landed on?".
|
|
2337
|
+
* A DNS-rebinding page sends requests whose `Host` is the *attacker's* domain
|
|
2338
|
+
* (that is the whole trick: the browser resolves the attacker name to
|
|
2339
|
+
* 127.0.0.1), so requiring the Host to match the arrival interface drops it.
|
|
2340
|
+
* This is the guard an Origin-only check does **not** provide.
|
|
2341
|
+
* - **Origin** answers "did a page on a foreign origin cause this?". A
|
|
2342
|
+
* non-browser client sends no `Origin` at all and must pass, or the plugin's
|
|
2343
|
+
* own `fetch` calls would be refused.
|
|
2344
|
+
*
|
|
2345
|
+
* Neither is authentication: any local process can forge both headers. They are
|
|
2346
|
+
* the minimum for these surfaces, not a substitute for a secret when a route
|
|
2347
|
+
* spends credit.
|
|
2348
|
+
*
|
|
2349
|
+
* ## Two callers, two strictnesses (deliberate)
|
|
2350
|
+
*
|
|
2351
|
+
* - `src/web-status.ts` serves the settings card over the HOST's webserver, which
|
|
2352
|
+
* the harness may configure to bind a concrete non-loopback address. It uses
|
|
2353
|
+
* `hostMatchesListener` / `originIsLoopback(origin, localAddress)`, i.e. "is
|
|
2354
|
+
* this addressed to / coming from the interface the connection landed on".
|
|
2355
|
+
* - `src/shim.ts` binds `listen(0, '127.0.0.1')` itself, so no LAN request can
|
|
2356
|
+
* ever reach it. It uses the stricter `hostIsLoopback`, which is not a
|
|
2357
|
+
* different notion of "local" but the same one narrowed to the only case that
|
|
2358
|
+
* can occur.
|
|
2359
|
+
*
|
|
2360
|
+
* Keep that in mind before "unifying" the two: the strict form is not a bug in
|
|
2361
|
+
* the shim, and the loose form is not laxness in the card.
|
|
2362
|
+
*
|
|
2363
|
+
* @module dsh-connect-workbuddy/loopback
|
|
2364
|
+
*/
|
|
2365
|
+
/**
|
|
2366
|
+
* Hostnames that count as this machine without comparing against a bind address.
|
|
2367
|
+
*
|
|
2368
|
+
* `localhost` is here because it names this machine by definition. The IPv4
|
|
2369
|
+
* entries cover the loopback /8 rather than the single address 127.0.0.1: the
|
|
2370
|
+
* host harness itself treats the whole 127/8 as loopback (its own
|
|
2371
|
+
* `isLoopbackHostname` accepts it, and 127.0.0.2 is a usable second loopback
|
|
2372
|
+
* address on Linux), so refusing 127.0.0.2 here would reject a request the
|
|
2373
|
+
* host considers local.
|
|
2374
|
+
*/
|
|
2375
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "[::1]"]);
|
|
2376
|
+
/**
|
|
2377
|
+
* Whether a string is a bare IP literal (IPv4 dotted quad, or IPv6 with or
|
|
2378
|
+
* without the brackets a Host header puts around it).
|
|
2379
|
+
*
|
|
2380
|
+
* Deliberately narrow: this decides whether a value is an ADDRESS that may be
|
|
2381
|
+
* compared with a Host header, so it must reject hostnames and host:port forms.
|
|
2382
|
+
* A loose "looks like hex" check would label `[deadbeef]` an address, and while
|
|
2383
|
+
* that cannot open the gate on its own (both sides must still be equal), a
|
|
2384
|
+
* predicate that answers "yes" to a non-address erodes the property the check is
|
|
2385
|
+
* supposed to have. `node:net`'s own parser is the authority for IPv6.
|
|
2386
|
+
*/
|
|
2387
|
+
function isIpLiteral(value) {
|
|
2388
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(value)) return value.split(".").every((part) => Number(part) <= 255 && !(part.length > 1 && part.startsWith("0")));
|
|
2389
|
+
const inner = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
|
|
2390
|
+
return isIP(inner) === 6;
|
|
2391
|
+
}
|
|
2392
|
+
/** Whether a bare hostname is inside IPv4 loopback space (127.0.0.0/8). */
|
|
2393
|
+
function isIpv4Loopback(hostname) {
|
|
2394
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname);
|
|
2395
|
+
if (match === null) return false;
|
|
2396
|
+
const octets = match.slice(1, 5).map((part) => Number(part));
|
|
2397
|
+
if (octets.some((octet) => octet > 255)) return false;
|
|
2398
|
+
if (match.slice(1, 5).some((part) => part.length > 1 && part.startsWith("0"))) return false;
|
|
2399
|
+
return octets[0] === 127;
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Strip the optional `:port` from a Host header value, IPv6-bracket aware.
|
|
2403
|
+
*
|
|
2404
|
+
* The bracketed form is returned WITH brackets, because {@link LOOPBACK_HOSTS}
|
|
2405
|
+
* stores it that way and the two must agree.
|
|
2406
|
+
*
|
|
2407
|
+
* A `]` that is not at the end of the value (or followed only by `:<digits>`)
|
|
2408
|
+
* makes the whole thing unparseable, and unparseable returns the input
|
|
2409
|
+
* unchanged so no caller can mistake it for a loopback name. That matters: a
|
|
2410
|
+
* naive `slice(0, end + 1)` turns `[::1]evil.example` into `[::1]` and reads a
|
|
2411
|
+
* foreign host as this machine. Browsers never send such a value, but this
|
|
2412
|
+
* function is a security boundary, so "never" is the wrong bet.
|
|
2413
|
+
*/
|
|
2414
|
+
function hostnameOfHost(host) {
|
|
2415
|
+
let hostname = host.trim().toLowerCase();
|
|
2416
|
+
if (hostname.startsWith("[")) {
|
|
2417
|
+
const end = hostname.indexOf("]");
|
|
2418
|
+
if (end === -1) return hostname;
|
|
2419
|
+
const after = hostname.slice(end + 1);
|
|
2420
|
+
if (after !== "" && !/^:\d+$/.test(after)) return hostname;
|
|
2421
|
+
return hostname.slice(0, end + 1);
|
|
2422
|
+
}
|
|
2423
|
+
const colon = hostname.lastIndexOf(":");
|
|
2424
|
+
if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
|
|
2425
|
+
return hostname;
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Whether a Host header names the loopback interface.
|
|
2429
|
+
*
|
|
2430
|
+
* An absent Host is treated as NOT loopback. HTTP/1.1 requires the header, so
|
|
2431
|
+
* its absence is either a malformed request or an attempt to dodge the check —
|
|
2432
|
+
* neither deserves the benefit of the doubt on a surface that can spend credit.
|
|
2433
|
+
*/
|
|
2434
|
+
function hostIsLoopback(host) {
|
|
2435
|
+
if (host === void 0 || host.trim() === "") return false;
|
|
2436
|
+
return isLocalHostname(hostnameOfHost(host));
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Whether a parsed hostname names this machine.
|
|
2440
|
+
*
|
|
2441
|
+
* The single definition both predicates use, so the Host and Origin paths cannot
|
|
2442
|
+
* disagree about what "local" means — they did before (`127.0.0.2` and a trailing
|
|
2443
|
+
* dot were treated differently by each), which is exactly the kind of split this
|
|
2444
|
+
* module exists to prevent.
|
|
2445
|
+
*/
|
|
2446
|
+
function isLocalHostname(hostname) {
|
|
2447
|
+
const name = hostname.endsWith(".") && !hostname.endsWith("..") ? hostname.slice(0, -1) : hostname;
|
|
2448
|
+
return LOOPBACK_HOSTS.has(name) || name === "::1" || isIpv4Loopback(name);
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Whether a Host header names the address this connection actually arrived on.
|
|
2452
|
+
*
|
|
2453
|
+
* This is the guard the routes use, and "must be loopback" is the wrong question.
|
|
2454
|
+
* The host harness does NOT allow wildcard binds in the version this plugin runs
|
|
2455
|
+
* against (0.2.x rejects `0.0.0.0` outright: "bind one concrete IPv4 or IPv6
|
|
2456
|
+
* address of a local interface instead"), but it DOES allow binding one concrete
|
|
2457
|
+
* local address — including a LAN address. A user who binds their LAN IP and
|
|
2458
|
+
* opens the GUI at `http://192.168.1.5:3000` sends exactly that in Host, and the
|
|
2459
|
+
* harness itself considers such a request trusted (its own trust layer accepts
|
|
2460
|
+
* "the listener's bind IP literal on any port"). A loopback-only check would 403
|
|
2461
|
+
* that user's read routes while the rest of the harness works, which is a real
|
|
2462
|
+
* regression for a supported configuration.
|
|
2463
|
+
*
|
|
2464
|
+
* So the question is "was this request addressed to the socket it landed on?".
|
|
2465
|
+
* `localAddress` answers that: it is the interface the CONNECTION was accepted
|
|
2466
|
+
* on, not the bind literal. Comparing the two drops a rebinding request — whose
|
|
2467
|
+
* Host is the attacker's domain while the socket landed on 127.0.0.1 or a LAN
|
|
2468
|
+
* address — while accepting every genuine one, loopback or LAN.
|
|
2469
|
+
*
|
|
2470
|
+
* The local branch is not merely an optimisation: a loopback-bound listener
|
|
2471
|
+
* reports `127.0.0.1` from `localAddress`, so the comparison alone would accept
|
|
2472
|
+
* `Host: 127.0.0.1` anyway. It is stated first so the answer does not depend on
|
|
2473
|
+
* how the platform spells its own loopback, and so an undefined `localAddress`
|
|
2474
|
+
* (a proxy, a test double) still admits genuine local traffic.
|
|
2475
|
+
*/
|
|
2476
|
+
function hostMatchesListener(host, localAddress) {
|
|
2477
|
+
if (host === void 0 || host.trim() === "") return false;
|
|
2478
|
+
const hostname = hostnameOfHost(host);
|
|
2479
|
+
if (isLocalHostname(hostname)) return true;
|
|
2480
|
+
if (hostname === "0.0.0.0" || hostname === "[::]") return false;
|
|
2481
|
+
if (localAddress === void 0 || localAddress.trim() === "") return false;
|
|
2482
|
+
const local = localAddress.trim().toLowerCase();
|
|
2483
|
+
if (!isIpLiteral(local) && !isIpLiteral(`[${local}]`)) return false;
|
|
2484
|
+
const bare = (value) => value.startsWith("[") && value.endsWith("]") && isIP(value.slice(1, -1)) === 6 ? value.slice(1, -1) : value;
|
|
2485
|
+
return bare(hostname) === bare(local);
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Whether a browser-sent Origin may drive this route.
|
|
2489
|
+
*
|
|
2490
|
+
* An ABSENT Origin passes: that is the shape of a non-browser client (the
|
|
2491
|
+
* plugin's own fetch, a curl, the tests), and refusing it would break the
|
|
2492
|
+
* plugin's own calls.
|
|
2493
|
+
*
|
|
2494
|
+
* A PRESENT Origin must name this machine — judged by the SAME rule as the Host
|
|
2495
|
+
* header, which is why this takes `localAddress` too. Leaving it loopback-only
|
|
2496
|
+
* while Host accepted any local interface was a real split: a LAN browser's
|
|
2497
|
+
* same-origin POST carries `Origin: http://192.168.1.5:3000`, so the Host gate
|
|
2498
|
+
* admitted it and this one then refused all ten write routes. The two predicates
|
|
2499
|
+
* use one definition now (see `isLocalHostname`), because a browser reaching the
|
|
2500
|
+
* server on its LAN address is exactly as legitimate as one reaching it on
|
|
2501
|
+
* loopback — the harness trusts the bind literal on any port either way.
|
|
2502
|
+
*
|
|
2503
|
+
* The comparison is over the HOSTNAME only. `localAddress` carries no port, and
|
|
2504
|
+
* the route's own port is what a same-origin page must already be using, so a
|
|
2505
|
+
* port comparison here would add nothing: a page on the same host but another
|
|
2506
|
+
* port is not a threat this gate is meant to stop (it would need code already
|
|
2507
|
+
* running on this machine), and treating it as one would break legitimate
|
|
2508
|
+
* setups behind a proxy that terminates on a different port than it forwards to.
|
|
2509
|
+
* That intentional limit is recorded rather than silently assumed.
|
|
2510
|
+
*/
|
|
2511
|
+
function originIsLoopback(origin, localAddress) {
|
|
2512
|
+
if (origin === void 0 || origin.trim() === "") return true;
|
|
2513
|
+
try {
|
|
2514
|
+
const { hostname } = new URL(origin);
|
|
2515
|
+
if (isLocalHostname(hostname)) return true;
|
|
2516
|
+
return hostMatchesListener(hostname, localAddress);
|
|
2517
|
+
} catch {
|
|
2518
|
+
return false;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2323
2521
|
/** One or two full-width bars — the count is a spelling variable. */
|
|
2324
2522
|
const BARS = `||?`;
|
|
2325
2523
|
new RegExp(`${BARS}DSML${BARS}`, "g");
|
|
@@ -3229,45 +3427,6 @@ const REQUEST_BODY_LIMIT = 67108864;
|
|
|
3229
3427
|
* rate-limiting.
|
|
3230
3428
|
*/
|
|
3231
3429
|
const FAILOVER_ACCOUNT_GAP_MS = 2e3;
|
|
3232
|
-
/** Loopback hostnames the shim's own in-process client uses. */
|
|
3233
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
3234
|
-
"127.0.0.1",
|
|
3235
|
-
"localhost",
|
|
3236
|
-
"[::1]"
|
|
3237
|
-
]);
|
|
3238
|
-
/** Strip the optional :port from a Host header value, IPv6-bracket aware. */
|
|
3239
|
-
function hostnameOfHost(host) {
|
|
3240
|
-
let hostname = host.trim().toLowerCase();
|
|
3241
|
-
if (hostname.startsWith("[")) {
|
|
3242
|
-
const end = hostname.indexOf("]");
|
|
3243
|
-
return end === -1 ? hostname : hostname.slice(0, end + 1);
|
|
3244
|
-
}
|
|
3245
|
-
const colon = hostname.lastIndexOf(":");
|
|
3246
|
-
if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
|
|
3247
|
-
return hostname;
|
|
3248
|
-
}
|
|
3249
|
-
/**
|
|
3250
|
-
* The request's Host header must name the loopback interface. A DNS-rebinding
|
|
3251
|
-
* page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
|
|
3252
|
-
* Host, so this check drops those before any routing happens.
|
|
3253
|
-
*/
|
|
3254
|
-
function hostIsLoopback(host) {
|
|
3255
|
-
if (host === void 0 || host.trim() === "") return false;
|
|
3256
|
-
return LOOPBACK_HOSTS.has(hostnameOfHost(host));
|
|
3257
|
-
}
|
|
3258
|
-
/**
|
|
3259
|
-
* A browser-sent Origin (present header) must be loopback. Non-browser
|
|
3260
|
-
* clients (the plugin's own fetch calls) send no Origin at all and pass.
|
|
3261
|
-
*/
|
|
3262
|
-
function originIsLoopback(origin) {
|
|
3263
|
-
if (origin === void 0 || origin.trim() === "") return true;
|
|
3264
|
-
try {
|
|
3265
|
-
const { hostname } = new URL(origin);
|
|
3266
|
-
return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
|
|
3267
|
-
} catch {
|
|
3268
|
-
return false;
|
|
3269
|
-
}
|
|
3270
|
-
}
|
|
3271
3430
|
/** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
|
|
3272
3431
|
function isJsonContentType(req) {
|
|
3273
3432
|
const type = req.headers["content-type"];
|
|
@@ -7281,21 +7440,10 @@ function tpsProbeRequestBody(modelId) {
|
|
|
7281
7440
|
content: "List the numbers from 1 to 400 in words, separated by single spaces."
|
|
7282
7441
|
}],
|
|
7283
7442
|
max_tokens: 256,
|
|
7284
|
-
...OFF_REASONING_FIELDS,
|
|
7285
7443
|
stream: true
|
|
7286
7444
|
});
|
|
7287
7445
|
}
|
|
7288
7446
|
/**
|
|
7289
|
-
* Ask for the lowest reasoning effort available.
|
|
7290
|
-
*
|
|
7291
|
-
* A reasoning model asked to count spends its `max_tokens` inside the reasoning
|
|
7292
|
-
* channel; the stream then carries thinking deltas and little or no content, and
|
|
7293
|
-
* timing it would measure the wrong thing. `none` is the widest supported
|
|
7294
|
-
* spelling; gateways that do not know it ignore the field (measured: the CN
|
|
7295
|
-
* gateway answers 200 with it).
|
|
7296
|
-
*/
|
|
7297
|
-
const OFF_REASONING_FIELDS = { reasoning_effort: "none" };
|
|
7298
|
-
/**
|
|
7299
7447
|
* Read one SSE stream INCREMENTALLY, timing each content deltas arrival.
|
|
7300
7448
|
*
|
|
7301
7449
|
* The incremental read is the measurement. `response.text()` buffers the whole
|
|
@@ -8700,16 +8848,46 @@ function readJsonBody(req) {
|
|
|
8700
8848
|
req.on("error", reject);
|
|
8701
8849
|
});
|
|
8702
8850
|
}
|
|
8703
|
-
/**
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8851
|
+
/**
|
|
8852
|
+
* The full gate a state-changing route must clear: Host AND Origin.
|
|
8853
|
+
*
|
|
8854
|
+
* Origin alone is not enough. It answers "did a foreign page cause this?", but a
|
|
8855
|
+
* DNS-rebinding attack arrives with the attacker's domain in `Host` and no
|
|
8856
|
+
* useful Origin, so the Host check is what drops it — and until this function
|
|
8857
|
+
* existed, all thirteen routes below (ten POST, three GET) had only the Origin
|
|
8858
|
+
* half while the shim's chat route had both. The asymmetry was invisible per-file.
|
|
8859
|
+
*
|
|
8860
|
+
* Both halves are cheap and NEITHER IS AUTHENTICATION. A local process — or any
|
|
8861
|
+
* peer that can reach the listener when it is bound to a LAN address — may send
|
|
8862
|
+
* whatever Host and Origin it likes. What these two checks buy is narrow and
|
|
8863
|
+
* worth stating precisely:
|
|
8864
|
+
*
|
|
8865
|
+
* - they stop a BROWSER on a foreign page from driving these routes (the browser
|
|
8866
|
+
* chooses Origin for us, and rebinding fails the Host comparison), which is
|
|
8867
|
+
* the CSRF / rebinding family;
|
|
8868
|
+
* - they do NOT stop a program that can already open a socket to the port.
|
|
8869
|
+
*
|
|
8870
|
+
* There is no per-route secret here: `req.headers` is read for `host` and
|
|
8871
|
+
* `origin` and nothing else. Every route that spends credit or returns token
|
|
8872
|
+
* material is protected by the same two forgeable headers as the read routes, so
|
|
8873
|
+
* binding this listener to a non-loopback address exposes those routes to the
|
|
8874
|
+
* local network. That is a property of the whole surface, not of this function,
|
|
8875
|
+
* and it must not be described as though a stronger guard existed.
|
|
8876
|
+
*
|
|
8877
|
+
* Absent Host is refused: HTTP/1.1 requires it, and its absence on a
|
|
8878
|
+
* credit-spending surface is not worth the benefit of the doubt. Absent Origin
|
|
8879
|
+
* passes, because that is the shape of the plugin's own fetch and of curl.
|
|
8880
|
+
*/
|
|
8881
|
+
function requireLoopback(req, res) {
|
|
8882
|
+
if (!hostMatchesListener(req.headers.host, req.socket?.localAddress)) {
|
|
8883
|
+
json(res, 403, { error: "host-not-trusted" });
|
|
8711
8884
|
return false;
|
|
8712
8885
|
}
|
|
8886
|
+
if (!originIsLoopback(req.headers.origin, req.socket?.localAddress)) {
|
|
8887
|
+
json(res, 403, { error: "origin-not-trusted" });
|
|
8888
|
+
return false;
|
|
8889
|
+
}
|
|
8890
|
+
return true;
|
|
8713
8891
|
}
|
|
8714
8892
|
/** Map the credit answer to the card's compact document. */
|
|
8715
8893
|
function toCredits(answer) {
|
|
@@ -9126,10 +9304,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9126
9304
|
json(res, 405, { error: "method not allowed" });
|
|
9127
9305
|
return;
|
|
9128
9306
|
}
|
|
9129
|
-
if (!
|
|
9130
|
-
json(res, 403, { error: "origin-not-trusted" });
|
|
9131
|
-
return;
|
|
9132
|
-
}
|
|
9307
|
+
if (!requireLoopback(req, res)) return;
|
|
9133
9308
|
const region = requestRegion(req, res);
|
|
9134
9309
|
if (region === void 0) return;
|
|
9135
9310
|
const detail = fastDetailOf(req.url ?? "") ? "fast" : "full";
|
|
@@ -9145,7 +9320,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9145
9320
|
path: WORKBUDDY_ACCOUNT_CREDITS_PATH,
|
|
9146
9321
|
handler: async (req, res) => {
|
|
9147
9322
|
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
9148
|
-
if (!
|
|
9323
|
+
if (!requireLoopback(req, res)) return;
|
|
9149
9324
|
const region = requestRegion(req, res);
|
|
9150
9325
|
if (region === void 0) return;
|
|
9151
9326
|
try {
|
|
@@ -9160,7 +9335,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9160
9335
|
path: WORKBUDDY_ACCOUNTS_REFRESH_PATH,
|
|
9161
9336
|
handler: async (req, res) => {
|
|
9162
9337
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9163
|
-
if (!
|
|
9338
|
+
if (!requireLoopback(req, res)) return;
|
|
9164
9339
|
const region = requestRegion(req, res);
|
|
9165
9340
|
if (region === void 0) return;
|
|
9166
9341
|
try {
|
|
@@ -9182,7 +9357,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9182
9357
|
path: WORKBUDDY_CHECKIN_PATH,
|
|
9183
9358
|
handler: async (req, res) => {
|
|
9184
9359
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9185
|
-
if (!
|
|
9360
|
+
if (!requireLoopback(req, res)) return;
|
|
9186
9361
|
const region = requestRegion(req, res);
|
|
9187
9362
|
if (region === void 0) return;
|
|
9188
9363
|
try {
|
|
@@ -9208,7 +9383,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9208
9383
|
path: WORKBUDDY_BENEFIT_CLAIM_PATH,
|
|
9209
9384
|
handler: async (req, res) => {
|
|
9210
9385
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9211
|
-
if (!
|
|
9386
|
+
if (!requireLoopback(req, res)) return;
|
|
9212
9387
|
const region = requestRegion(req, res);
|
|
9213
9388
|
if (region === void 0) return;
|
|
9214
9389
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -9246,7 +9421,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9246
9421
|
path: WORKBUDDY_MODELS_REFRESH_PATH,
|
|
9247
9422
|
handler: async (req, res) => {
|
|
9248
9423
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9249
|
-
if (!
|
|
9424
|
+
if (!requireLoopback(req, res)) return;
|
|
9250
9425
|
if (deps.discoverModels === void 0) return json(res, 503, { error: "model refresh unavailable" });
|
|
9251
9426
|
const region = requestRegion(req, res);
|
|
9252
9427
|
if (region === void 0) return;
|
|
@@ -9262,7 +9437,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9262
9437
|
path: WORKBUDDY_PROBE_PATH,
|
|
9263
9438
|
handler: async (req, res) => {
|
|
9264
9439
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9265
|
-
if (!
|
|
9440
|
+
if (!requireLoopback(req, res)) return;
|
|
9266
9441
|
if (deps.probeModels === void 0) return json(res, 503, { error: "model probe unavailable" });
|
|
9267
9442
|
const region = requestRegion(req, res);
|
|
9268
9443
|
if (region === void 0) return;
|
|
@@ -9284,7 +9459,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9284
9459
|
path: WORKBUDDY_TPS_PATH,
|
|
9285
9460
|
handler: async (req, res) => {
|
|
9286
9461
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9287
|
-
if (!
|
|
9462
|
+
if (!requireLoopback(req, res)) return;
|
|
9288
9463
|
if (deps.measureTps === void 0) return json(res, 503, { error: "tps measurement unavailable" });
|
|
9289
9464
|
const region = requestRegion(req, res);
|
|
9290
9465
|
if (region === void 0) return;
|
|
@@ -9306,7 +9481,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9306
9481
|
path: WORKBUDDY_POOL_PATH,
|
|
9307
9482
|
handler: async (req, res) => {
|
|
9308
9483
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9309
|
-
if (!
|
|
9484
|
+
if (!requireLoopback(req, res)) return;
|
|
9310
9485
|
const region = requestRegion(req, res);
|
|
9311
9486
|
if (region === void 0) return;
|
|
9312
9487
|
const action = poolActionOf(req.url ?? "/");
|
|
@@ -9449,7 +9624,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9449
9624
|
path: "/plugins/dsh-connect-workbuddy/__save",
|
|
9450
9625
|
handler: async (req, res) => {
|
|
9451
9626
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9452
|
-
if (!
|
|
9627
|
+
if (!requireLoopback(req, res)) return;
|
|
9453
9628
|
const settings = ctx.get?.("settings");
|
|
9454
9629
|
if (settings === void 0) return json(res, 503, { error: "settings service unavailable to this fiber" });
|
|
9455
9630
|
try {
|
|
@@ -9536,7 +9711,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9536
9711
|
path: WORKBUDDY_OAUTH_PATH,
|
|
9537
9712
|
handler: async (req, res) => {
|
|
9538
9713
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9539
|
-
if (!
|
|
9714
|
+
if (!requireLoopback(req, res)) return;
|
|
9540
9715
|
const region = requestRegion(req, res);
|
|
9541
9716
|
if (region === void 0) return;
|
|
9542
9717
|
const action = oauthActionOf(req.url ?? "/");
|
|
@@ -9564,7 +9739,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9564
9739
|
path: WORKBUDDY_TRANSFER_PATH,
|
|
9565
9740
|
handler: async (req, res) => {
|
|
9566
9741
|
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
9567
|
-
if (!
|
|
9742
|
+
if (!requireLoopback(req, res)) return;
|
|
9568
9743
|
const region = requestRegion(req, res);
|
|
9569
9744
|
if (region === void 0) return;
|
|
9570
9745
|
const action = transferActionOf(req.url ?? "/");
|
|
@@ -9618,7 +9793,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
9618
9793
|
path: WORKBUDDY_CLI_LIVE_PATH,
|
|
9619
9794
|
handler: (req, res) => {
|
|
9620
9795
|
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
9621
|
-
if (!
|
|
9796
|
+
if (!requireLoopback(req, res)) return;
|
|
9622
9797
|
try {
|
|
9623
9798
|
json(res, 200, deps.cliLive?.() ?? {
|
|
9624
9799
|
turns: [],
|
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": "5.8.
|
|
5
|
+
"version": "5.8.5",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|