@wenbin_wb/dsh-bridge 2.10.8 → 2.10.9

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
@@ -4,6 +4,18 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.10.9] - 2026-09-12
8
+
9
+ ### 🐞 修复
10
+
11
+ - **DSH 0.1.5 系列上 `dsh web` 启动即崩**(issue #38):DSH 从 `0.1.5-alpha.1` 起,宿主自带的 `dsh-client-connection` 调整了插件级服务声明却遗漏了一处同步修改,导致凡是注册 RPC 通道的插件都会让**整棵插件树加载失败**、`dsh web` 直接退出(报 `cannot get property "webServer" without inject`)。现已插件侧兼容:正常路径完全不变,仅在命中该宿主缺陷时自动补上解析兜底并完成通道注册,上游修复后自动空转、无副作用。DSH `0.1.5-alpha.1` / `alpha.2` / `rc.1` / `rc.2`(含当前最新版)均已实测通过,`0.1.0`~`0.1.4` 行为不变。
12
+
13
+ ### ✨ 优化
14
+
15
+ - **与 DSH 原生鉴权(`0.1.2` 起内置)确认兼容并加固**:反代转发时自动注入 DSH 要求的回环会话 cookie,手机 / 局域网 / 隧道访问无需处理 DSH 自身的 `?token=` 登录流程;同时修正凭据解析——当 DSH 凭据文件里存在多条密钥记录时,确保取用浏览器会话记录对应的密钥,避免远程访问出现 401。
16
+
17
+ ---
18
+
7
19
  ## [v2.10.8] - 2026-09-08
8
20
 
9
21
  ### ✨ 新功能
package/README.en.md CHANGED
@@ -48,6 +48,7 @@
48
48
  - [7. 📊 Maintenance Dashboard & Graceful Restart](#7-📊-maintenance-dashboard--graceful-restart)
49
49
  - [💬 FAQ](#-faq)
50
50
  - [🛠️ Development & Contribution](#️-development--contribution)
51
+ - [⭐ Star History](#-star-history)
51
52
  - [📄 License](#-license)
52
53
 
53
54
  ---
@@ -77,6 +78,20 @@ node -v # v22.19+ or v24+
77
78
  dsh --version
78
79
  ```
79
80
 
81
+ ### DSH version compatibility
82
+
83
+ This plugin supports **both old and new DSH releases** — there is no need to pick a plugin version to match your harness.
84
+
85
+ | DSH version | Status |
86
+ | --- | --- |
87
+ | `0.1.0` ~ `0.1.1` | ✅ Supported (loopback-only RPC channel hardening) |
88
+ | `0.1.2` ~ `0.1.4` | ✅ Supported |
89
+ | `0.1.5-alpha.1` ~ `0.1.5-rc.2` | ✅ Supported (since v2.10.9) |
90
+
91
+ > **About the built-in DSH authentication**: since `0.1.2`, `dsh web` ships browser authentication — it prints a URL carrying a one-time token (`http://127.0.0.1:3080/?token=…`), which is exchanged for a session cookie bound to the loopback address. Afterwards `/`, `/api` and every plugin RPC channel require that cookie.
92
+ >
93
+ > This is **complementary to, not a duplicate of**, this plugin's access-password gate: the built-in auth protects "the DSH process on local loopback", while this plugin's gate protects "remote access arriving over LAN or a public tunnel". The plugin's reverse proxy injects a valid loopback session cookie while forwarding, so phone / tunnel access needs **no** manual handling of DSH's `?token=` — just use it as documented on this page.
94
+
80
95
  ### Installation
81
96
 
82
97
  ```bash
@@ -405,6 +420,18 @@ dsh plugin --profile web add .
405
420
 
406
421
  ---
407
422
 
423
+ ## ⭐ Star History
424
+
425
+ <a href="https://www.star-history.com/?repos=wenbin-wb%2Fdsh-bridge&type=date&legend=top-left">
426
+ <picture>
427
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&theme=dark&legend=top-left" />
428
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&legend=top-left" />
429
+ <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&legend=top-left" />
430
+ </picture>
431
+ </a>
432
+
433
+ ---
434
+
408
435
  ## 📄 License
409
436
 
410
437
  MIT © [wenbin-wb](https://github.com/wenbin-wb)
package/README.md CHANGED
@@ -48,6 +48,7 @@
48
48
  - [7. 📊 运维监控看板与一键平滑重启](#7-📊-运维监控看板与一键平滑重启)
49
49
  - [💬 常见问题 (FAQ)](#-常见问题-faq)
50
50
  - [🛠️ 开发与贡献](#️-开发与贡献)
51
+ - [⭐ Star History](#-star-history)
51
52
  - [📄 开源协议](#-开源协议)
52
53
 
53
54
  ---
@@ -79,6 +80,20 @@ node -v # 应显示 v22.19+ 或 v24+
79
80
  dsh --version
80
81
  ```
81
82
 
83
+ ### DSH 版本兼容性
84
+
85
+ 本插件**同时兼容新旧 DSH**,无需按 DSH 版本挑选插件版本:
86
+
87
+ | DSH 版本 | 状态 |
88
+ | --- | --- |
89
+ | `0.1.0` ~ `0.1.1` | ✅ 支持(回环专用 RPC 通道加固) |
90
+ | `0.1.2` ~ `0.1.4` | ✅ 支持 |
91
+ | `0.1.5-alpha.1` ~ `0.1.5-rc.2` | ✅ 支持(v2.10.9 起) |
92
+
93
+ > **关于 DSH 原生鉴权**:DSH 从 `0.1.2` 起为 `dsh web` 内置了浏览器鉴权——启动时会打印带一次性 token 的地址(`http://127.0.0.1:3080/?token=…`),换取一枚绑定回环地址的会话 cookie,此后 `/`、`/api` 及各插件 RPC 通道都要求该 cookie。
94
+ >
95
+ > 这与本插件的**访问密码门禁是两层互补的防护**,不冲突也不重复:原生鉴权保护的是"本机回环上的 DSH 进程",本插件的门禁保护的是"经局域网 / 公网隧道进入的远程访问"。本插件的反向代理会在转发时自动注入合法的回环会话 cookie,因此手机 / 隧道访问**无需**手动处理 DSH 的 `?token=`,按本页说明正常使用即可。
96
+
82
97
  ### 安装插件
83
98
 
84
99
  ```bash
@@ -427,6 +442,18 @@ dsh plugin --profile web add .
427
442
 
428
443
  ---
429
444
 
445
+ ## ⭐ Star History
446
+
447
+ <a href="https://www.star-history.com/?repos=wenbin-wb%2Fdsh-bridge&type=date&legend=top-left">
448
+ <picture>
449
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&theme=dark&legend=top-left" />
450
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&legend=top-left" />
451
+ <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=wenbin-wb/dsh-bridge&type=date&legend=top-left" />
452
+ </picture>
453
+ </a>
454
+
455
+ ---
456
+
430
457
  ## 📄 开源协议
431
458
 
432
459
  本项目基于 [MIT 许可证](LICENSE) 开源发布。
@@ -90,4 +90,4 @@
90
90
  2. **安全白名单拦截**:
91
91
  - 仅白名单内的用户消息会被放行给 Agent;非白名单用户发送的消息会被直接忽略,绝不消耗 Token 或喂给模型。
92
92
  3. **敏感操作审批**:
93
- - 当 Agent 尝试执行系统命令或敏感文件读写时,Telegram 会自动下发 `[✓ 批准执行]` / `[✕ 拒绝执行]` 交互按键,10 分钟未处理自动超时拒绝。
93
+ - 当 Agent 尝试执行系统命令或敏感文件读写时,Telegram 会自动下发 `[✓ 批准执行]` / `[✕ 拒绝执行]` 交互按键,10 分钟未处理自动超时拒绝。
@@ -0,0 +1,148 @@
1
+ // DSH 原生浏览器鉴权的兼容层(lib/auth/dsh-native-cookie.js)
2
+ //
3
+ // 背景
4
+ // ---------------------------------------------------------------
5
+ // DSH 从 0.1.2 起为 `dsh web` 引入了"原生浏览器鉴权":
6
+ // 1. 启动时打印带一次性 launch token 的地址:http://127.0.0.1:<port>/?token=…
7
+ // 2. `GET /?token=<token>` 校验通过后 303 重定向到 `/`,并下发签名会话 cookie
8
+ // 3. 此后所有受保护路由(`/`、`/api`、以及任何经
9
+ // `ctx.connection.rpc.handle()` 注册的通道)都要求该 cookie,否则 401
10
+ //
11
+ // cookie 的构造(见 @deepseek-ai/dsh-client-connection 的 BrowserAuth):
12
+ // 名称:`dsh-auth-` + base64url(sha256(authority)),authority = `127.0.0.1:<port>`
13
+ // 值 :`v1.<base64url(JSON payload)>.<base64url(HMAC-SHA256(secret, body))>`
14
+ // 载荷:`{ version: 1, authority, issuedAt, expiresAt }`(均为安全整数)
15
+ // 密钥:`<DSH_HOME>/.credentials.yaml` 里 record
16
+ // `client-connection/browser-session` 的 `payload.secret`(base64url 32 字节)
17
+ //
18
+ // 为什么本插件需要它
19
+ // ---------------------------------------------------------------
20
+ // 本插件的自建反向代理监听自己的端口(默认 3082),对外提供局域网/隧道访问,
21
+ // 再把请求转发到 DSH 回环地址。浏览器持有的是"代理 origin"的 cookie,
22
+ // 而 DSH 的 cookie 绑定的是 `127.0.0.1:<dshPort>` 这个 authority,两者不是同一个,
23
+ // 因此代理必须自行注入一枚合法的 loopback 会话 cookie,否则远程访问会一律 401。
24
+ //
25
+ // 本模块只做"按 DSH 的格式生成一枚回环会话 cookie",不引入任何三方依赖。
26
+
27
+ import { createHash, createHmac } from 'node:crypto';
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { homedir } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ /** DSH 会话 cookie 的名称前缀(与 dsh-client-connection 的 COOKIE_PREFIX 一致)。 */
33
+ export const DSH_AUTH_COOKIE_PREFIX = 'dsh-auth-';
34
+
35
+ /** cookie 载荷版本(与 dsh-client-connection 的 COOKIE_PAYLOAD_VERSION 一致)。 */
36
+ export const DSH_AUTH_COOKIE_VERSION = 1;
37
+
38
+ /** 会话 cookie 有效期(毫秒)。与 DSH 默认的 cookieMaxAgeDays=30 对齐。 */
39
+ export const DSH_AUTH_COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
40
+
41
+ /** 承载签名密钥的 DSH 凭据记录键。 */
42
+ export const DSH_BROWSER_SESSION_RECORD = 'client-connection/browser-session';
43
+
44
+ function base64Url(value) {
45
+ return Buffer.from(value).toString('base64url');
46
+ }
47
+
48
+ /**
49
+ * 计算 DSH 会话 cookie 的名称(authority 绑定)。
50
+ * @param {number|string} targetPort DSH 回环端口
51
+ * @param {string} [host] 默认 127.0.0.1
52
+ * @returns {string} 形如 `dsh-auth-<base64url(sha256("127.0.0.1:3080"))>`
53
+ */
54
+ export function dshAuthCookieName(targetPort, host = '127.0.0.1') {
55
+ const authority = `${host}:${targetPort}`;
56
+ return DSH_AUTH_COOKIE_PREFIX + base64Url(createHash('sha256').update(authority).digest());
57
+ }
58
+
59
+ /**
60
+ * 从 DSH 凭据文件内容中取出 browser-session 记录的签名密钥。
61
+ *
62
+ * 必须精确定位 `client-connection/browser-session` 这条 record:
63
+ * 凭据文件里还有 `refs:` 段(各家模型 API key)以及可能存在其它插件写入的
64
+ * record,一旦取到别处的 `secret:`,签名就会无效 —— 现象是"远程访问一律 401",
65
+ * 且极难定位。因此先锚定 record 键,再在其后的小范围内取 secret;
66
+ * 仅当锚点缺失(异常/未来格式)时才退回"全文件第一个 secret:"的旧行为。
67
+ *
68
+ * @param {string} content 凭据文件文本
69
+ * @returns {string} base64url 编码的密钥;取不到时返回空串
70
+ */
71
+ export function readBrowserSessionSecret(content) {
72
+ if (typeof content !== 'string' || content === '') return '';
73
+
74
+ // 锚定 record 键所在行,随后只在该行往后的一小段内查找 secret
75
+ const anchorMatch = /^[ \t]*client-connection\/browser-session:[ \t]*$/m.exec(content);
76
+ if (anchorMatch) {
77
+ const tail = content.slice(anchorMatch.index);
78
+ const scoped = /secret:[ \t]*([A-Za-z0-9_-]+)/.exec(tail);
79
+ if (scoped) return scoped[1];
80
+ }
81
+
82
+ // 兜底:老格式/异常格式下沿用全文件首个 secret:
83
+ const fallback = /secret:[ \t]*([A-Za-z0-9_-]+)/.exec(content);
84
+ return fallback ? fallback[1] : '';
85
+ }
86
+
87
+ /**
88
+ * 生成一枚绑定到 loopback authority 的 DSH 原生会话 cookie。
89
+ *
90
+ * @param {object} opts
91
+ * @param {number|string} opts.targetPort DSH 回环端口
92
+ * @param {string} opts.secret base64url 编码的签名密钥
93
+ * @param {string} [opts.host] 默认 127.0.0.1
94
+ * @param {number} [opts.now] 当前时间戳(测试注入用)
95
+ * @param {number} [opts.maxAgeMs] 有效期
96
+ * @returns {string} `name=value`;密钥缺失时返回空串
97
+ */
98
+ export function buildDshLoopbackCookie({
99
+ targetPort,
100
+ secret,
101
+ host = '127.0.0.1',
102
+ now = Date.now(),
103
+ maxAgeMs = DSH_AUTH_COOKIE_MAX_AGE_MS,
104
+ }) {
105
+ if (!secret || !targetPort) return '';
106
+
107
+ const authority = `${host}:${targetPort}`;
108
+ const name = dshAuthCookieName(targetPort, host);
109
+ // 与 DSH 一致:issuedAt 略微回拨,避免与宿主的同秒比较产生边界问题
110
+ const issuedAt = now - 1000;
111
+ const expiresAt = issuedAt + maxAgeMs;
112
+
113
+ const body = base64Url(Buffer.from(JSON.stringify({
114
+ version: DSH_AUTH_COOKIE_VERSION,
115
+ authority,
116
+ issuedAt,
117
+ expiresAt,
118
+ }), 'utf8'));
119
+ const key = Buffer.from(secret, 'base64url');
120
+ const sig = base64Url(createHmac('sha256', key).update(body).digest());
121
+ return `${name}=v${DSH_AUTH_COOKIE_VERSION}.${body}.${sig}`;
122
+ }
123
+
124
+ /**
125
+ * 读取本机 DSH 凭据并生成回环会话 cookie(失败时返回空串,绝不抛出)。
126
+ *
127
+ * @param {number|string} targetPort DSH 回环端口
128
+ * @param {object} [env] 便于测试注入的环境
129
+ * @param {string} [env.dshHome] DSH 主目录(默认 $DSH_HOME 或 ~/.dsh)
130
+ * @param {Function} [env.readFileSync] 文件读取实现(默认 node:fs)
131
+ * @param {Function} [env.existsSync] 存在性判断实现
132
+ * @param {number} [env.now] 当前时间戳
133
+ * @returns {string} `name=value`,或空串
134
+ */
135
+ export function getDshLoopbackCookie(targetPort, env = {}) {
136
+ const readFile = env.readFileSync ?? readFileSync;
137
+ const exists = env.existsSync ?? existsSync;
138
+ try {
139
+ const dshHome = env.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
140
+ const credPath = join(dshHome, '.credentials.yaml');
141
+ if (!exists(credPath)) return '';
142
+ const secret = readBrowserSessionSecret(readFile(credPath, 'utf8'));
143
+ if (!secret) return '';
144
+ return buildDshLoopbackCookie({ targetPort, secret, now: env.now });
145
+ } catch {
146
+ return '';
147
+ }
148
+ }
package/lib/bridge-rpc.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import QRCode from 'qrcode';
5
5
  import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from './bridge-rpc-constants.js';
6
+ import { registerRpcChannel } from './connection-compat.js';
6
7
  import { RateLimiter } from './security/rate-limiter.js';
7
8
 
8
9
  export { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS };
@@ -87,7 +88,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
87
88
  return () => {};
88
89
  }
89
90
 
90
- return ctx.connection.rpc.handle(
91
+ // connection-compat 注册:兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归
92
+ // (上游 register() 里对 connection 自身 ctx 取 webServer 而未加 inject 作用域)
93
+ return registerRpcChannel(
94
+ ctx,
91
95
  BRIDGE_RPC_CHANNEL,
92
96
  async (endpoint, payload = {}, signal) => {
93
97
  if (signal?.aborted) return fail('cancelled', 'Request was cancelled');
@@ -486,7 +490,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
486
490
  return fail('bad-request', err.message);
487
491
  }
488
492
  },
489
- { authority: 'loopback' }
493
+ // DSH 0.1.0/0.1.1 支持 authority 选项(仅回环可达);0.1.2+ 忽略之,透传无副作用。
494
+ // 见 lib/connection-compat.js 的 registerRpcChannel 说明。
495
+ { authority: 'loopback' },
496
+ logger
490
497
  );
491
498
  }
492
499
 
package/lib/compat.js CHANGED
@@ -1,129 +1,129 @@
1
- // 运行时兼容垫片(lib/compat.js)
2
- //
3
- // 背景:DSH 核心依赖链(@deepseek-ai/dsh-timeout ← dsh-llm)在每次 agent 请求上
4
- // 调用 AbortSignal.any([...]),该 API 仅存在于 Node 20.3+/22+。运行在低版本 Node
5
- // (如 Node 18)上时,通过桥接发送消息会直接抛
6
- // "AbortSignal.any is not a function ... (internal)"
7
- // 本模块在缺失时安装规范兼容的垫片;环境本身支持时不做任何事(返回 false)。
8
- //
9
- // 浏览器侧(旧 Safari/WebView)由 lib/index.js 的 HTML_HEAD_INJECTIONS 注入同语义垫片。
10
-
11
- function abortWith(controller, reason) {
12
- try {
13
- controller.abort(reason)
14
- } catch {
15
- try { controller.abort() } catch { /* 不可中止的信号:忽略 */ }
16
- }
17
- }
18
-
19
- /**
20
- * 在目标环境中安装 AbortSignal.any / AbortSignal.timeout 垫片(仅缺失时)。
21
- * @param {object} [target] 可注入的环境(默认 globalThis),便于测试
22
- * @returns {boolean} 是否实际安装了垫片
23
- */
24
- export function installAbortSignalCompat(target = globalThis) {
25
- const Signal = target.AbortSignal
26
- const Controller = target.AbortController
27
- if (!Signal || !Controller) return false
28
-
29
- let installed = false
30
-
31
- // ---- AbortSignal.any ----
32
- if (typeof Signal.any !== 'function') {
33
- Signal.any = (signals) => {
34
- const list = Array.from(signals ?? [])
35
- const controller = new Controller()
36
- // 规范语义:任一源信号已中止 → 立即以该原因中止
37
- for (const s of list) {
38
- if (s && s.aborted) {
39
- abortWith(controller, s.reason)
40
- return controller.signal
41
- }
42
- }
43
- const onAbort = (eventOrSignal) => {
44
- // 真实 EventTarget 的 abort 事件参数是 event(event.target = 信号),
45
- // 部分非标准实现直接传信号本身——两者都兼容取 reason
46
- const src = eventOrSignal && eventOrSignal.target ? eventOrSignal.target : eventOrSignal
47
- cleanup()
48
- abortWith(controller, src ? src.reason : undefined)
49
- }
50
- const cleanup = () => {
51
- for (const s of list) {
52
- try { s.removeEventListener('abort', onAbort) } catch { /* 非标准信号:忽略 */ }
53
- }
54
- }
55
- for (const s of list) {
56
- try { s.addEventListener('abort', onAbort, { once: true }) } catch { /* 非标准信号:忽略 */ }
57
- }
58
- return controller.signal
59
- }
60
- installed = true
61
- }
62
-
63
- // ---- AbortSignal.timeout(Node 17.3+ / 较新浏览器才有,顺手补齐)----
64
- if (typeof Signal.timeout !== 'function') {
65
- Signal.timeout = (delayMs) => {
66
- const controller = new Controller()
67
- const delay = Math.max(0, Number(delayMs) || 0)
68
- const reason = typeof target.DOMException === 'function'
69
- ? new target.DOMException('The operation timed out.', 'TimeoutError')
70
- : new Error('The operation timed out.')
71
- const timer = setTimeout(() => abortWith(controller, reason), delay)
72
- // 规范语义:timeout 信号的超时定时器不阻止进程退出
73
- if (typeof timer.unref === 'function') timer.unref()
74
- return controller.signal
75
- }
76
- installed = true
77
- }
78
-
79
- return installed
80
- }
81
-
82
- /**
83
- * 浏览器侧垫片源码:由代理注入到 HTML <head>(在宿主所有脚本之前执行),
84
- * 为 iOS 16 / 旧 Safari / 旧 WebView(无 AbortSignal.any,Safari 17.4 才加入)
85
- * 上的 DSH 网页客户端补齐。多行可读源码,勿手工压缩成单行(配平易错)。
86
- */
87
- export const BROWSER_ABORT_SIGNAL_POLYFILL = `<script data-dsh-bridge-polyfill="2">
88
- !function () {
89
- try {
90
- var S = self.AbortSignal;
91
- if (typeof S !== 'function') return;
92
- if (typeof S.any !== 'function') {
93
- S.any = function (signals) {
94
- var list = Array.prototype.slice.call(signals || []);
95
- var c = new self.AbortController();
96
- var onAbort = function (ev) {
97
- var src = ev && ev.target ? ev.target : ev;
98
- cleanup();
99
- try { c.abort(src ? src.reason : undefined); } catch (e) { c.abort(); }
100
- };
101
- var cleanup = function () {
102
- for (var i = 0; i < list.length; i++) {
103
- try { list[i].removeEventListener('abort', onAbort); } catch (e) {}
104
- }
105
- };
106
- for (var i = 0; i < list.length; i++) {
107
- if (list[i] && list[i].aborted) {
108
- try { c.abort(list[i].reason); } catch (e) { c.abort(); }
109
- return c.signal;
110
- }
111
- }
112
- for (var i = 0; i < list.length; i++) {
113
- try { list[i].addEventListener('abort', onAbort, { once: true }); } catch (e) {}
114
- }
115
- return c.signal;
116
- };
117
- }
118
- if (typeof S.timeout !== 'function') {
119
- S.timeout = function (ms) {
120
- var c = new self.AbortController();
121
- setTimeout(function () {
122
- try { c.abort(new Error('The operation timed out.')); } catch (e) { c.abort(); }
123
- }, Math.max(0, Number(ms) || 0));
124
- return c.signal;
125
- };
126
- }
127
- } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
128
- }();
129
- </script>`
1
+ // 运行时兼容垫片(lib/compat.js)
2
+ //
3
+ // 背景:DSH 核心依赖链(@deepseek-ai/dsh-timeout ← dsh-llm)在每次 agent 请求上
4
+ // 调用 AbortSignal.any([...]),该 API 仅存在于 Node 20.3+/22+。运行在低版本 Node
5
+ // (如 Node 18)上时,通过桥接发送消息会直接抛
6
+ // "AbortSignal.any is not a function ... (internal)"
7
+ // 本模块在缺失时安装规范兼容的垫片;环境本身支持时不做任何事(返回 false)。
8
+ //
9
+ // 浏览器侧(旧 Safari/WebView)由 lib/index.js 的 HTML_HEAD_INJECTIONS 注入同语义垫片。
10
+
11
+ function abortWith(controller, reason) {
12
+ try {
13
+ controller.abort(reason)
14
+ } catch {
15
+ try { controller.abort() } catch { /* 不可中止的信号:忽略 */ }
16
+ }
17
+ }
18
+
19
+ /**
20
+ * 在目标环境中安装 AbortSignal.any / AbortSignal.timeout 垫片(仅缺失时)。
21
+ * @param {object} [target] 可注入的环境(默认 globalThis),便于测试
22
+ * @returns {boolean} 是否实际安装了垫片
23
+ */
24
+ export function installAbortSignalCompat(target = globalThis) {
25
+ const Signal = target.AbortSignal
26
+ const Controller = target.AbortController
27
+ if (!Signal || !Controller) return false
28
+
29
+ let installed = false
30
+
31
+ // ---- AbortSignal.any ----
32
+ if (typeof Signal.any !== 'function') {
33
+ Signal.any = (signals) => {
34
+ const list = Array.from(signals ?? [])
35
+ const controller = new Controller()
36
+ // 规范语义:任一源信号已中止 → 立即以该原因中止
37
+ for (const s of list) {
38
+ if (s && s.aborted) {
39
+ abortWith(controller, s.reason)
40
+ return controller.signal
41
+ }
42
+ }
43
+ const onAbort = (eventOrSignal) => {
44
+ // 真实 EventTarget 的 abort 事件参数是 event(event.target = 信号),
45
+ // 部分非标准实现直接传信号本身——两者都兼容取 reason
46
+ const src = eventOrSignal && eventOrSignal.target ? eventOrSignal.target : eventOrSignal
47
+ cleanup()
48
+ abortWith(controller, src ? src.reason : undefined)
49
+ }
50
+ const cleanup = () => {
51
+ for (const s of list) {
52
+ try { s.removeEventListener('abort', onAbort) } catch { /* 非标准信号:忽略 */ }
53
+ }
54
+ }
55
+ for (const s of list) {
56
+ try { s.addEventListener('abort', onAbort, { once: true }) } catch { /* 非标准信号:忽略 */ }
57
+ }
58
+ return controller.signal
59
+ }
60
+ installed = true
61
+ }
62
+
63
+ // ---- AbortSignal.timeout(Node 17.3+ / 较新浏览器才有,顺手补齐)----
64
+ if (typeof Signal.timeout !== 'function') {
65
+ Signal.timeout = (delayMs) => {
66
+ const controller = new Controller()
67
+ const delay = Math.max(0, Number(delayMs) || 0)
68
+ const reason = typeof target.DOMException === 'function'
69
+ ? new target.DOMException('The operation timed out.', 'TimeoutError')
70
+ : new Error('The operation timed out.')
71
+ const timer = setTimeout(() => abortWith(controller, reason), delay)
72
+ // 规范语义:timeout 信号的超时定时器不阻止进程退出
73
+ if (typeof timer.unref === 'function') timer.unref()
74
+ return controller.signal
75
+ }
76
+ installed = true
77
+ }
78
+
79
+ return installed
80
+ }
81
+
82
+ /**
83
+ * 浏览器侧垫片源码:由代理注入到 HTML <head>(在宿主所有脚本之前执行),
84
+ * 为 iOS 16 / 旧 Safari / 旧 WebView(无 AbortSignal.any,Safari 17.4 才加入)
85
+ * 上的 DSH 网页客户端补齐。多行可读源码,勿手工压缩成单行(配平易错)。
86
+ */
87
+ export const BROWSER_ABORT_SIGNAL_POLYFILL = `<script data-dsh-bridge-polyfill="2">
88
+ !function () {
89
+ try {
90
+ var S = self.AbortSignal;
91
+ if (typeof S !== 'function') return;
92
+ if (typeof S.any !== 'function') {
93
+ S.any = function (signals) {
94
+ var list = Array.prototype.slice.call(signals || []);
95
+ var c = new self.AbortController();
96
+ var onAbort = function (ev) {
97
+ var src = ev && ev.target ? ev.target : ev;
98
+ cleanup();
99
+ try { c.abort(src ? src.reason : undefined); } catch (e) { c.abort(); }
100
+ };
101
+ var cleanup = function () {
102
+ for (var i = 0; i < list.length; i++) {
103
+ try { list[i].removeEventListener('abort', onAbort); } catch (e) {}
104
+ }
105
+ };
106
+ for (var i = 0; i < list.length; i++) {
107
+ if (list[i] && list[i].aborted) {
108
+ try { c.abort(list[i].reason); } catch (e) { c.abort(); }
109
+ return c.signal;
110
+ }
111
+ }
112
+ for (var i = 0; i < list.length; i++) {
113
+ try { list[i].addEventListener('abort', onAbort, { once: true }); } catch (e) {}
114
+ }
115
+ return c.signal;
116
+ };
117
+ }
118
+ if (typeof S.timeout !== 'function') {
119
+ S.timeout = function (ms) {
120
+ var c = new self.AbortController();
121
+ setTimeout(function () {
122
+ try { c.abort(new Error('The operation timed out.')); } catch (e) { c.abort(); }
123
+ }, Math.max(0, Number(ms) || 0));
124
+ return c.signal;
125
+ };
126
+ }
127
+ } catch (e) { /* 环境异常时不影响页面其余脚本 */ }
128
+ }();
129
+ </script>`