@wenbin_wb/dsh-bridge 2.10.3 → 2.10.4

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/lib/bridge-rpc.js CHANGED
@@ -157,8 +157,8 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
157
157
  if (adminErr) return adminErr;
158
158
 
159
159
  // 未提供的字段保持 undefined 透传:服务端视为"保留现值"
160
- const { serverUrl, accessToken } = payload;
161
- await saveCustomTunnelConfig(serverUrl, accessToken);
160
+ const { serverUrl, accessToken, sseStreaming } = payload;
161
+ await saveCustomTunnelConfig(serverUrl, accessToken, sseStreaming);
162
162
  const status = await service.getStatus();
163
163
  return ok(status);
164
164
  }
package/lib/index.js CHANGED
@@ -184,6 +184,7 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
184
184
  <link rel="manifest" href="/manifest.webmanifest">
185
185
  <link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
186
186
  <link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
187
+ <style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
187
188
  <script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
188
189
  ${BROWSER_ABORT_SIGNAL_POLYFILL}`;
189
190
  const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
@@ -443,6 +444,10 @@ class ProxyServer {
443
444
  const isLocked = this.authManager?.isIpBlocked(clientIp);
444
445
  const html = renderLoginPage({
445
446
  hasPassword: this.authManager?.hasPassword,
447
+ // 管理员是否从未设置过任何密码(含独立管理密码):登录页需如实提示,
448
+ // 避免出现"输入任意密码都能进"的假门禁
449
+ noPasswordConfigured: !this.authManager?.hasPassword && !this.authManager?.hasAdminPassword,
450
+ mode: this.authManager?.mode,
446
451
  locked: isLocked,
447
452
  error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
448
453
  });
@@ -466,8 +471,13 @@ class ProxyServer {
466
471
  // 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
467
472
  // 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
468
473
  const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
469
- const shouldBuffer = contentType.includes('text/html')
470
- || (isSessionProjection && proxyRes.statusCode === 200 && !isCompressed(proxyRes.headers));
474
+ // session 投影剥离须覆盖压缩(gzip)响应:v2.10.4 起 stripSessionProjections 支持
475
+ // gunzip 解压,因此压缩的 session 响应也应进入缓冲分支进行剥离(此前 !isCompressed
476
+ // 门控会跳过压缩响应,导致 DSH 启用 gzip 后局域网/CF/外部隧道入口剥离失效——只有
477
+ // 自建隧道入口正确剥离)。HTML 注入仍只在未压缩时进行(注入逻辑处理明文)。
478
+ const sessionProjectionBuffered = isSessionProjection && proxyRes.statusCode === 200;
479
+ const shouldBuffer = (contentType.includes('text/html') && !isCompressed(proxyRes.headers))
480
+ || sessionProjectionBuffered;
471
481
  if (shouldBuffer) {
472
482
  const chunks = [];
473
483
  proxyRes.on('data', (c) => chunks.push(c));
@@ -481,8 +491,12 @@ class ProxyServer {
481
491
  }
482
492
  out = Buffer.from(html, 'utf8');
483
493
  } else if (isSessionProjection) {
484
- const { body, stripped } = stripSessionProjections(pathname, out);
485
- if (stripped) out = body;
494
+ const { body, stripped } = stripSessionProjections(pathname, out, proxyRes.headers['content-encoding']);
495
+ if (stripped) {
496
+ out = body;
497
+ // 已修改 body,清除原始压缩编码标记
498
+ delete outHeaders['content-encoding'];
499
+ }
486
500
  }
487
501
  // 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
488
502
  // transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
@@ -801,6 +815,7 @@ class BridgeService {
801
815
  configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
802
816
  serverUrl: this.customTunnelConfig?.serverUrl ?? '',
803
817
  running: !!this.customTunnel?.connected,
818
+ sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
804
819
  url: customUrl,
805
820
  rawUrl: baseCustomUrl,
806
821
  qr: customUrl
@@ -896,6 +911,7 @@ class BridgeService {
896
911
  accessToken,
897
912
  localPort: this.proxyPort,
898
913
  internalTunnelSecret: this.authManager?.internalTunnelSecret,
914
+ sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
899
915
  onStateChange: (state) => {
900
916
  this.customTunnelState = state;
901
917
  },
@@ -1852,13 +1868,14 @@ function apply(ctx, config = {}) {
1852
1868
  telegram,
1853
1869
  platformManager,
1854
1870
  logger,
1855
- saveCustomTunnelConfig: async (serverUrl, accessToken) => {
1871
+ saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
1856
1872
  const stored = await updateConfig((current) => {
1857
1873
  const prev = service.customTunnelConfig ?? {};
1858
1874
  const next = { ...prev };
1859
1875
  // 与 saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
1860
1876
  if (serverUrl !== undefined) next.serverUrl = String(serverUrl).trim();
1861
1877
  if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
1878
+ if (sseStreaming !== undefined) next.sseStreaming = Boolean(sseStreaming);
1862
1879
  current.customTunnel = next;
1863
1880
  return current;
1864
1881
  });
@@ -9,6 +9,12 @@
9
9
  // - tunnel-client.mjs(自建隧道)
10
10
  // - index.js ProxyServer(局域网 / Cloudflare 隧道 / 外部隧道登记 —— 所有公网入口)
11
11
  // 命中失败(非 200 / 解析失败 / 结构不符)时原样返回,绝不破坏响应。
12
+ //
13
+ // DSH web 服务器默认开启 gzip 压缩(compression: gzip, level 1, threshold 1KB),
14
+ // 所以 API 响应可能是 gzip 编码的。必须先解压才能 JSON.parse,否则解析失败,
15
+ // catch {} 静默跳过剥离 → 完整 58MB+ 响应原样传输 → 隧道慢。
16
+
17
+ import { gunzipSync } from 'node:zlib';
12
18
 
13
19
  const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
14
20
 
@@ -16,15 +22,19 @@ const STRIP_PATHS = new Set(['/api/session.list', '/api/session.history']);
16
22
  * 剥离 session.list / session.history 响应中的 contextHeaders 与 contextTimeline。
17
23
  * @param {string} pathname 请求路径(不含 query)
18
24
  * @param {Buffer} bodyBuf 原始响应体
19
- * @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length
25
+ * @param {string} [contentEncoding] 响应的 content-encoding 头值(用于判断是否 gzip
26
+ * @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length 和 content-encoding)
20
27
  */
21
- export function stripSessionProjections(pathname, bodyBuf) {
28
+ export function stripSessionProjections(pathname, bodyBuf, contentEncoding) {
22
29
  const cleanPath = String(pathname || '').split('?')[0];
23
30
  if (!STRIP_PATHS.has(cleanPath) || !Buffer.isBuffer(bodyBuf)) {
24
31
  return { body: bodyBuf, stripped: false };
25
32
  }
26
33
  try {
27
- const json = JSON.parse(bodyBuf.toString('utf8'));
34
+ // 如果 DSH 服务器 gzip 压缩了响应,先解压再解析
35
+ const isGzipped = String(contentEncoding ?? '').toLowerCase() === 'gzip';
36
+ const parseBuf = isGzipped ? gunzipSync(bodyBuf) : bodyBuf;
37
+ const json = JSON.parse(parseBuf.toString('utf8'));
28
38
  if (json?.result?.ok) {
29
39
  const v = json.result.value;
30
40
  let changed = false;
@@ -52,6 +62,6 @@ export function stripSessionProjections(pathname, bodyBuf) {
52
62
  return { body: Buffer.from(JSON.stringify(json), 'utf8'), stripped: true };
53
63
  }
54
64
  }
55
- } catch { /* 解析失败则原样发送 */ }
65
+ } catch { /* 解析/解压失败则原样发送 */ }
56
66
  return { body: bodyBuf, stripped: false };
57
67
  }
@@ -37,34 +37,36 @@ export function createConnectProxyAgent(proxyUrl) {
37
37
  ? 'Basic ' + Buffer.from(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`).toString('base64')
38
38
  : undefined
39
39
 
40
- return new https.Agent({
41
- keepAlive: true,
42
- createConnection(opts, callback) {
43
- const connectReq = http.request({
44
- host: proxyHost,
45
- port: proxyPort,
46
- method: 'CONNECT',
47
- path: `${opts.host}:${opts.port || 443}`,
48
- headers: authHeader ? { 'Proxy-Authorization': authHeader } : {},
49
- })
40
+ // Node >=24 下自定义 createConnection 作为 https.Agent 构造参数会被静默忽略
41
+ // (https.Agent 不拷贝构造入参中的 createConnection,回退到原型默认直连方法)。
42
+ // 改为构造后赋值实例属性:实例自有属性会正确覆盖原型方法,Node 22/24 均生效。
43
+ const agent = new https.Agent({ keepAlive: true })
44
+ agent.createConnection = function createTunnelConnection(opts, callback) {
45
+ const connectReq = http.request({
46
+ host: proxyHost,
47
+ port: proxyPort,
48
+ method: 'CONNECT',
49
+ path: `${opts.host}:${opts.port || 443}`,
50
+ headers: authHeader ? { 'Proxy-Authorization': authHeader } : {},
51
+ })
50
52
 
51
- connectReq.on('connect', (res, socket) => {
52
- if (res.statusCode !== 200) {
53
- socket.destroy()
54
- return callback(new Error(`Proxy CONNECT failed with HTTP ${res.statusCode}`))
55
- }
56
- const tlsSocket = tls.connect({
57
- host: opts.host,
58
- socket,
59
- servername: opts.servername || opts.host,
60
- })
61
- callback(null, tlsSocket)
53
+ connectReq.on('connect', (res, socket) => {
54
+ if (res.statusCode !== 200) {
55
+ socket.destroy()
56
+ return callback(new Error(`Proxy CONNECT failed with HTTP ${res.statusCode}`))
57
+ }
58
+ const tlsSocket = tls.connect({
59
+ host: opts.host,
60
+ socket,
61
+ servername: opts.servername || opts.host,
62
62
  })
63
+ callback(null, tlsSocket)
64
+ })
63
65
 
64
- connectReq.on('error', (err) => callback(err))
65
- connectReq.end()
66
- },
67
- })
66
+ connectReq.on('error', (err) => callback(err))
67
+ connectReq.end()
68
+ }
69
+ return agent
68
70
  }
69
71
 
70
72
  /**
@@ -627,4 +629,4 @@ export class TelegramGateway extends Service {
627
629
  dispose() {
628
630
  void this.stop()
629
631
  }
630
- }
632
+ }
@@ -4,6 +4,7 @@ import { request as httpRequest } from 'node:http';
4
4
  import { connect as netConnect } from 'node:net';
5
5
  import { promisify } from 'node:util';
6
6
  import { gzip as gzipCallback } from 'node:zlib';
7
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto';
7
8
  import { stripSessionProjections } from './session-strip.js';
8
9
 
9
10
  const gzipAsync = promisify(gzipCallback);
@@ -22,7 +23,7 @@ const GZIP_THRESHOLD = 102400; // 100KB
22
23
  const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
23
24
 
24
25
  export class CustomTunnelClient {
25
- constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
26
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger, sseStreaming = false }) {
26
27
  this.serverUrl = serverUrl;
27
28
  this.accessToken = accessToken;
28
29
  this.localPort = localPort;
@@ -30,6 +31,7 @@ export class CustomTunnelClient {
30
31
  this.signal = signal;
31
32
  this.onStateChange = onStateChange;
32
33
  this.logger = logger;
34
+ this.sseStreaming = sseStreaming;
33
35
  this.ws = null;
34
36
  this.publicUrl = null;
35
37
  this.connected = false;
@@ -161,8 +163,33 @@ export class CustomTunnelClient {
161
163
  const chunks = [];
162
164
 
163
165
  if (isSSE) {
164
- // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
165
- // 避免 SSE 永不 end 导致隧道服务器超时返回 504
166
+ if (this.sseStreaming) {
167
+ // ── 流式模式(需服务端支持 response-start/chunk/end 协议)──
168
+ // 逐 chunk 转发,SSE 长连接持续保持,浏览器端不会断连重连
169
+ const respHeaders = Object.fromEntries(
170
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
171
+ );
172
+ this._sendMessage({
173
+ type: 'response-start', requestId,
174
+ statusCode: res.statusCode, headers: respHeaders,
175
+ });
176
+ res.on('data', (c) => {
177
+ this._sendMessage({
178
+ type: 'response-chunk', requestId,
179
+ body: c.toString('base64'),
180
+ });
181
+ });
182
+ res.on('error', () => {
183
+ this._sendMessage({ type: 'response-end', requestId });
184
+ });
185
+ res.on('end', () => {
186
+ this._sendMessage({ type: 'response-end', requestId });
187
+ });
188
+ return;
189
+ }
190
+
191
+ // ── 截断模式(默认,兼容老服务端)──
192
+ // 收集初始数据后立即返回,避免 SSE 永不 end 导致隧道服务器超时 504
166
193
  let sseSent = false;
167
194
  const sseTimer = setTimeout(() => {
168
195
  if (sseSent) return;
@@ -257,17 +284,22 @@ export class CustomTunnelClient {
257
284
  let bodyBuf = Buffer.concat(chunks);
258
285
 
259
286
  // 剥离 session.list / session.history 的 contextHeaders 投影(无 UI 读取的大字段)
287
+ // 传入 content-encoding 以便 stripSessionProjections 先 gunzip 再解析
260
288
  if (res.statusCode === 200) {
261
- const { body, stripped } = stripSessionProjections(path, bodyBuf);
289
+ const { body, stripped } = stripSessionProjections(path, bodyBuf, res.headers['content-encoding']);
262
290
  if (stripped) {
263
291
  bodyBuf = body;
292
+ // 已修改 body,清除原始压缩编码标记,让下方 gzip 逻辑重新压缩
293
+ delete respHeaders['content-encoding'];
264
294
  respHeaders['content-length'] = String(bodyBuf.length);
265
295
  }
266
296
  }
267
297
 
268
298
  // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
269
299
  const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
270
- const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
300
+ // 注意:alreadyEncoded respHeaders 读取(而非 res.headers),
301
+ // 因为上面的剥离逻辑可能已经删除了 respHeaders['content-encoding']
302
+ const alreadyEncoded = String(respHeaders['content-encoding'] ?? res.headers['content-encoding'] ?? '').toLowerCase();
271
303
  const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
272
304
  if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
273
305
  gzipAsync(bodyBuf).then((zipped) => {
@@ -316,9 +348,15 @@ export class CustomTunnelClient {
316
348
  let headerBuf = '';
317
349
  let upgraded = false;
318
350
 
351
+ // WebSocket 帧解析缓冲区 — 拦截 Ping (0x9) 自动回复 Pong (0xA)。
352
+ // DSH API Gateway 的 /api/remote.mux WebSocket 每 2s 发 Ping,连续 2 次没收到
353
+ // Pong 就 terminate。隧道用裸 TCP 转发,Pong 往返延迟可能超时,本地直接回复最可靠。
354
+ let wsFrameBuf = Buffer.alloc(0);
355
+
319
356
  sock.on('data', (chunk) => {
320
357
  if (upgraded) {
321
- this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
358
+ wsFrameBuf = Buffer.concat([wsFrameBuf, chunk]);
359
+ wsFrameBuf = this._processWsFrames(wsId, wsFrameBuf, sock);
322
360
  return;
323
361
  }
324
362
  headerBuf += chunk.toString('binary');
@@ -326,6 +364,7 @@ export class CustomTunnelClient {
326
364
  if (sep === -1) return;
327
365
 
328
366
  upgraded = true;
367
+ const statusLine = headerBuf.slice(0, headerBuf.indexOf('\r\n'));
329
368
  const replyHeaders = {};
330
369
  const headerLines = headerBuf.slice(0, sep).split('\r\n');
331
370
  for (let i = 1; i < headerLines.length; i++) {
@@ -335,7 +374,20 @@ export class CustomTunnelClient {
335
374
  headerLines[i].slice(ci + 1).trim();
336
375
  }
337
376
  }
338
- this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
377
+
378
+ // 解析状态码,传递给服务端以便正确转发(非 101 时浏览器能看到真实错误)
379
+ const statusMatch = /^HTTP\/1\.\d\s+(\d+)\s*(.*)/.exec(statusLine);
380
+ const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 101;
381
+ const statusMessage = statusMatch ? statusMatch[2] : '';
382
+
383
+ if (statusCode === 101) {
384
+ this._sendMessage({ type: 'ws-accept', wsId, statusCode, replyHeaders });
385
+ } else {
386
+ // 非 101 响应:转发状态码和头,让浏览器看到真实错误(如 401)
387
+ this._sendMessage({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
388
+ sock.destroy();
389
+ return;
390
+ }
339
391
 
340
392
  // 握手后紧跟的帧数据
341
393
  const rest = headerBuf.slice(sep + 4);
@@ -355,6 +407,70 @@ export class CustomTunnelClient {
355
407
  });
356
408
  }
357
409
 
410
+ // ── WebSocket 帧解析 ─────────────────────────────────────────────────────
411
+ // 在裸 TCP 层面解析 WebSocket 帧,拦截 Ping (0x9) 自动回复 Pong (0xA),
412
+ // 其余帧原样转发。DSH API Gateway 每 2s 发 Ping,2 次没 Pong 就 terminate。
413
+ _processWsFrames(wsId, buf, sock) {
414
+ while (buf.length >= 2) {
415
+ const b0 = buf[0];
416
+ const b1 = buf[1];
417
+ const opcode = b0 & 0x0f;
418
+ const masked = b1 & 0x80;
419
+ let payloadLen = b1 & 0x7f;
420
+ let offset = 2;
421
+
422
+ if (payloadLen === 126) {
423
+ if (buf.length < 4) break;
424
+ payloadLen = buf.readUInt16BE(2);
425
+ offset = 4;
426
+ } else if (payloadLen === 127) {
427
+ if (buf.length < 10) break;
428
+ payloadLen = Number(buf.readBigUInt64BE(2));
429
+ offset = 10;
430
+ }
431
+ if (masked) offset += 4;
432
+ if (buf.length < offset + payloadLen) break;
433
+
434
+ const frameEnd = offset + payloadLen;
435
+ const frameBytes = buf.subarray(0, frameEnd);
436
+
437
+ if (opcode === 0x9) {
438
+ // Ping → 自动回复 Pong(同 payload,必须 mask,因为这是 client→server 方向)
439
+ // WebSocket 协议规定客户端→服务端的帧必须加 mask,否则服务端 ws 库会判定
440
+ // 协议错误(1002)并关闭连接。
441
+ const payload = buf.subarray(offset, frameEnd);
442
+ const mask = cryptoRandomBytes(4);
443
+ const maskedPayload = Buffer.alloc(payload.length);
444
+ for (let i = 0; i < payload.length; i++) {
445
+ maskedPayload[i] = payload[i] ^ mask[i % 4];
446
+ }
447
+ if (payload.length < 126) {
448
+ const hdr = Buffer.alloc(6);
449
+ hdr[0] = 0x8a; // fin + opcode 0xA (pong)
450
+ hdr[1] = 0x80 | payload.length; // masked + length
451
+ mask.copy(hdr, 2);
452
+ sock.write(Buffer.concat([hdr, maskedPayload]));
453
+ } else if (payload.length < 65536) {
454
+ const hdr = Buffer.alloc(8);
455
+ hdr[0] = 0x8a; hdr[1] = 0x80 | 126;
456
+ hdr.writeUInt16BE(payload.length, 2);
457
+ mask.copy(hdr, 4);
458
+ sock.write(Buffer.concat([hdr, maskedPayload]));
459
+ } else {
460
+ const hdr = Buffer.alloc(14);
461
+ hdr[0] = 0x8a; hdr[1] = 0x80 | 127;
462
+ hdr.writeBigUInt64BE(BigInt(payload.length), 2);
463
+ mask.copy(hdr, 10);
464
+ sock.write(Buffer.concat([hdr, maskedPayload]));
465
+ }
466
+ } else {
467
+ this._sendMessage({ type: 'ws-frame', wsId, data: frameBytes.toString('base64') });
468
+ }
469
+ buf = buf.subarray(frameEnd);
470
+ }
471
+ return buf;
472
+ }
473
+
358
474
  _handleWsFrame(msg) {
359
475
  const sock = this.localWsSockets.get(msg.wsId);
360
476
  if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.3",
3
+ "version": "2.10.4",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.3】\n• 兼容 DSH 原生端口 3080 直连(issue #28):本机直连不再被误判为远程而锁定,走代理与直连行为一致\n• 安全:AI [SEND_FILE] 仅允许发送工作目录内文件,拦截 .ssh/.credentials/.env 等敏感路径\n• 修复:代理响应头透传、QQ 流式重复发送、微信轮询竞态(iLink 403)、飞书群聊审批按钮误拦\n• 会话列表/历史大字段剥离覆盖局域网与 Cloudflare 等全部公网入口",
5
+ "releaseNotes": "【v2.10.4】\n• 修复:全新安装(未设置任何密码)时远程访问被误锁,需输入『管理密码』(任意输入皆可通过)才可进入——现无密码时远程免锁直进,并明确提示先设置访问密码\n• 修复:Telegram 代理在 Node 24 下不生效(createConnection 构造参数被忽略,改为构造后赋值)\n• 修复:移动端点击 workspace 分组名误关抽屉、自建隧道 gzip 会话剥离失效、目录弹窗深色模式适配\n• 新功能:自建隧道 SSE 流式传输 + WebSocket Ping/Pong 保活;远程/移动端禁用下拉刷新防误触",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {