dsh-connect-workbuddy 4.6.1 → 4.6.2

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.6.2 (2026-10-16)
4
+
5
+ > 版本号是 **patch**:修的是一个启动期缺陷,没有接口、配置或界面变更。
6
+
7
+ ### Fixes
8
+
9
+ - **shim 启动时会避开「HTTP 客户端自己拒绝」的端口**
10
+ - **症状**:某个区域(`cn` 或 `global`)的模型请求**全部失败**,报错只有一句 `Connection error.`,而 shim 本身完全健康 —— `curl`、`node:http`、裸 TCP 连上去都是 200。重启后可能好、可能照样坏。
11
+ - **根因**:`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 一次,不会自己重掷,所以一旦抽中就是**该区域持续失败直到插件重新加载**。
12
+ - **只在特定机器上发生**:`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%`。
13
+ - **为什么难自查**:错误链上真正的 `cause`(`bad port`)被 SDK 归一化时丢弃,界面和日志都只剩 `Connection error.`,读不出是端口问题;用户唯一能做的「换个账号」恰好**救不了**(与账号无关)。顺带确认:这个拒绝与代理无关(把 `HTTP_PROXY` 指向死端口、清空 `NO_PROXY`,回环好端口仍返回 200 —— 说明回环流量根本不经过代理),也不是 `ECONNREFUSED`(后者带 `code`,而它是 `cause.message === 'bad port'`、无 `code`)。
14
+ - **改了什么**:新增 `clientRefusesPort()`(绑定后用自己的 `fetch` 打 `/healthz` 探测:**返回任何 HTTP 码都算客户端接受**,含 401;只有抛异常才算被拒)与 `listenOnClientReachablePort()`(有界重掷,上限 `PORT_ROLL_LIMIT = 5`)。用客户端自身探测而**不是**比对一张抄来的端口表:清单是规范的一部分,抄本会随规范漂移,而这里要满足的规则**本身就是客户端的**。绑定方式仍是 `listen(0)` + 仅回环(随机性不变),端口在 `ready` 之前定格,因此 `baseUrl()` 的消费方一行未改。
15
+ - **顺带修掉一个自己引入的缺陷**:`close()` 落在 `listen()` 未完成期间时,Node **既不触发 `listening` 也不触发 `error`,只触发 `close`**(实测确认)——最初的 `listenOnce` 只监听前两者,会让 `ready` 永久 pending、卡住插件卸载。已补 `close` 监听。
16
+ - **守卫**:`tests/shim.spec.ts` 新增 6 例(真实客户端识别被禁端口、健康端口不误判、拒绝与真实传输错误可区分、重掷后确实服务、上界生效不空转、首抽即通过时不重掷),外加 2 例生命周期守卫(启动期被处置不得挂起、并发 `close()` 安全)。**变异验证(4 个变异全部转红,且各自只打中应打中的用例)**:① 探测恒真(永不重绑,等价修复前)→ 红 3 例;② 探测恒假 → 红 1 例;③ 移除 `close` 监听 → 红 1 例(30s 超时,正是挂起);④ 上界失效 → 红 1 例。
17
+ - **端到端验证(跑在真实 `createWorkBuddyShim` 上,非 mock)**:启动 **500 个**真实 shim,落在被禁端口的 **0 个**、客户端不可达的 **0 个**(修复前期望约 0.68 个);劫持 `fetch` 让首次探测抛出与真实 `bad port` **同形状**的错误后,真实对象从 `4512` 重掷到 `4513` 并在新端口正常服务。全量 `79` 文件/`1308` 用例通过,与改动前的 `1300` 相比净增 `8` 例。
18
+
3
19
  ## 4.6.1 (2026-10-16)
4
20
 
5
21
  > 版本号是 **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-BI7_lfoi.js";
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-De95rlXS.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -3825,7 +3825,7 @@ function generationOf(version) {
3825
3825
  //#endregion
3826
3826
  //#region src/version.ts
3827
3827
  /** The npm package version this build was produced from. */
3828
- const WORKBUDDY_CONNECT_VERSION = "4.6.1";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.6.2";
3829
3829
  //#endregion
3830
3830
  //#region src/host-heartbeat.ts
3831
3831
  /**
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-BI7_lfoi.js";
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-De95rlXS.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
- const ready = new Promise((resolve, reject) => {
1808
- server.once("listening", () => resolve());
1809
- server.once("error", reject);
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, reject) => {
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.1",
5
+ "version": "4.6.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",