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 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-CAX0oKQT.js";
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 — it never replaces it, and it renders nothing at all for a
9528
- * provider that is not ours.
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.4";
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-CAX0oKQT.js";
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
- /** Loopback browser origins only; other devices are refused until trusted origins exist. */
8704
- function loopbackOrigin(req) {
8705
- const origin = req.headers.origin;
8706
- if (origin === void 0) return true;
8707
- try {
8708
- const { hostname } = new URL(origin);
8709
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
8710
- } catch {
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 (!loopbackOrigin(req)) {
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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 (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
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.4",
5
+ "version": "5.8.5",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",