@wenbin_wb/dsh-bridge 2.5.0 → 2.5.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/lib/bridge-rpc.js CHANGED
@@ -11,13 +11,14 @@ function ok(value) {
11
11
  }
12
12
 
13
13
  function fail(code, message, details = {}) {
14
- if (code === 'cancelled') {
15
- return { ok: false, error: { code: 'cancelled', message, details: {} } };
16
- }
14
+ const allowedCodes = new Set([
15
+ 'bad-request', 'cancelled', 'internal', 'settings-rejected', 'command-error'
16
+ ]);
17
+ const safeCode = allowedCodes.has(code) ? code : 'bad-request';
17
18
  return {
18
19
  ok: false,
19
20
  error: {
20
- code: 'bad-request',
21
+ code: safeCode,
21
22
  message,
22
23
  details: { issues: [{ message }], ...details },
23
24
  },
@@ -54,6 +55,21 @@ async function wechatStatusValue(wechatService, logger) {
54
55
  return { ...status, login: { ...status.login, qr, qrPayload: undefined, qrKind: undefined } };
55
56
  }
56
57
 
58
+ function checkAdminAuth(authManager, payload) {
59
+ if (!authManager) return null;
60
+ if (authManager.adminPolicy === 'open') return null;
61
+ // 若系统尚未设置任何管理密码或访客密码,允许免密管理
62
+ const hasAnyPassword = authManager.hasAdminPassword || authManager.hasPassword;
63
+ if (!hasAnyPassword) {
64
+ return null;
65
+ }
66
+ // 已设置密码时,必须提供经服务端校验有效的 adminToken(绝不依赖客户端自称的 isLocalhost)
67
+ if (payload?.adminToken && authManager.validateAdminSession(payload.adminToken)) {
68
+ return null;
69
+ }
70
+ return fail('bad-request', '操作已被拦截:需要管理员权限,请先在控制台输入管理密码解锁');
71
+ }
72
+
57
73
  export function installBridgeRpc(ctx, { service, authManager, wechat, platformManager, logger, saveCustomTunnelConfig }) {
58
74
  if (!ctx?.connection?.rpc?.handle) {
59
75
  logger.warn('dsh-bridge: Connection RPC unavailable — UI will not work');
@@ -67,7 +83,8 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
67
83
 
68
84
  try {
69
85
  if (endpoint === BRIDGE_ENDPOINTS.getStatus) {
70
- const status = await service.getStatus();
86
+ const isAdmin = checkAdminAuth(authManager, payload) === null;
87
+ const status = await service.getStatus({ adminAuthValid: isAdmin });
71
88
  return ok(status);
72
89
  }
73
90
 
@@ -75,11 +92,15 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
75
92
 
76
93
  if (endpoint === BRIDGE_ENDPOINTS.authGetStatus) {
77
94
  if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
78
- return ok(authManager.getStatus());
95
+ const isAdmin = checkAdminAuth(authManager, payload) === null;
96
+ return ok(authManager.getStatus({ masked: !isAdmin }));
79
97
  }
80
98
 
81
99
  if (endpoint === BRIDGE_ENDPOINTS.authUpdateConfig) {
82
100
  if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
101
+ const adminErr = checkAdminAuth(authManager, payload);
102
+ if (adminErr) return adminErr;
103
+
83
104
  const { enabled, mode, scope, adminPolicy, password, adminPassword } = payload;
84
105
  if (enabled != null) await authManager.setEnabled(enabled);
85
106
  if (mode != null) await authManager.setMode(mode);
@@ -87,13 +108,16 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
87
108
  if (adminPolicy != null) await authManager.setAdminPolicy(adminPolicy);
88
109
  if (password !== undefined) await authManager.setPassword(password);
89
110
  if (adminPassword !== undefined) await authManager.setAdminPassword(adminPassword);
90
- return ok(authManager.getStatus());
111
+ return ok(authManager.getStatus({ masked: false }));
91
112
  }
92
113
 
93
114
  if (endpoint === BRIDGE_ENDPOINTS.authRegenerateToken) {
94
115
  if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
116
+ const adminErr = checkAdminAuth(authManager, payload);
117
+ if (adminErr) return adminErr;
118
+
95
119
  await authManager.regenerateSecretToken();
96
- return ok(authManager.getStatus());
120
+ return ok(authManager.getStatus({ masked: false }));
97
121
  }
98
122
 
99
123
  if (endpoint === BRIDGE_ENDPOINTS.authAdminUnlock) {
@@ -104,7 +128,16 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
104
128
  return fail('bad-request', res.error || '管理员密码错误');
105
129
  }
106
130
 
131
+ if (endpoint === BRIDGE_ENDPOINTS.authAdminLock) {
132
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
133
+ if (payload?.adminToken) authManager.revokeAdminSession(payload.adminToken);
134
+ return ok({ locked: true });
135
+ }
136
+
107
137
  if (endpoint === BRIDGE_ENDPOINTS.saveCustomTunnelConfig) {
138
+ const adminErr = checkAdminAuth(authManager, payload);
139
+ if (adminErr) return adminErr;
140
+
108
141
  const { serverUrl = '', accessToken = '' } = payload;
109
142
  await saveCustomTunnelConfig(serverUrl.trim(), accessToken.trim());
110
143
  const status = await service.getStatus();
@@ -112,6 +145,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
112
145
  }
113
146
 
114
147
  if (endpoint === BRIDGE_ENDPOINTS.startCustomTunnel) {
148
+ const adminErr = checkAdminAuth(authManager, payload);
149
+ if (adminErr) return adminErr;
150
+
115
151
  try {
116
152
  await service.startCustomTunnel();
117
153
  const status = await service.getStatus();
@@ -123,12 +159,18 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
123
159
  }
124
160
 
125
161
  if (endpoint === BRIDGE_ENDPOINTS.stopCustomTunnel) {
162
+ const adminErr = checkAdminAuth(authManager, payload);
163
+ if (adminErr) return adminErr;
164
+
126
165
  service.stopCustomTunnel();
127
166
  const status = await service.getStatus();
128
167
  return ok(status);
129
168
  }
130
169
 
131
170
  if (endpoint === BRIDGE_ENDPOINTS.startCloudflared) {
171
+ const adminErr = checkAdminAuth(authManager, payload);
172
+ if (adminErr) return adminErr;
173
+
132
174
  try {
133
175
  await service.startCloudflared();
134
176
  const status = await service.getStatus();
@@ -140,12 +182,18 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
140
182
  }
141
183
 
142
184
  if (endpoint === BRIDGE_ENDPOINTS.stopCloudflared) {
185
+ const adminErr = checkAdminAuth(authManager, payload);
186
+ if (adminErr) return adminErr;
187
+
143
188
  service.stopCloudflared();
144
189
  const status = await service.getStatus();
145
190
  return ok(status);
146
191
  }
147
192
 
148
193
  if (endpoint === BRIDGE_ENDPOINTS.resetCloudflared) {
194
+ const adminErr = checkAdminAuth(authManager, payload);
195
+ if (adminErr) return adminErr;
196
+
149
197
  await service.resetCloudflared();
150
198
  const status = await service.getStatus();
151
199
  return ok(status);
@@ -157,6 +205,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
157
205
  }
158
206
 
159
207
  if (endpoint === BRIDGE_ENDPOINTS.upgradePlugin) {
208
+ const adminErr = checkAdminAuth(authManager, payload);
209
+ if (adminErr) return adminErr;
210
+
160
211
  const result = await service.upgradePlugin(payload);
161
212
  return ok(result);
162
213
  }
@@ -180,6 +231,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
180
231
 
181
232
  if (endpoint === BRIDGE_ENDPOINTS.platformLogin) {
182
233
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
234
+ const adminErr = checkAdminAuth(authManager, payload);
235
+ if (adminErr) return adminErr;
236
+
183
237
  const { platformId, qrType } = payload;
184
238
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
185
239
  const platform = platformManager.get(platformId);
@@ -193,6 +247,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
193
247
 
194
248
  if (endpoint === BRIDGE_ENDPOINTS.platformSetAllowFrom) {
195
249
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
250
+ const adminErr = checkAdminAuth(authManager, payload);
251
+ if (adminErr) return adminErr;
252
+
196
253
  const { platformId, allowFrom } = payload;
197
254
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
198
255
  const platform = platformManager.get(platformId);
@@ -205,6 +262,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
205
262
 
206
263
  if (endpoint === BRIDGE_ENDPOINTS.platformSetConfig) {
207
264
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
265
+ const adminErr = checkAdminAuth(authManager, payload);
266
+ if (adminErr) return adminErr;
267
+
208
268
  const { platformId, ...config } = payload;
209
269
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
210
270
  const platform = platformManager.get(platformId);
@@ -217,6 +277,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
217
277
 
218
278
  if (endpoint === BRIDGE_ENDPOINTS.platformStop) {
219
279
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
280
+ const adminErr = checkAdminAuth(authManager, payload);
281
+ if (adminErr) return adminErr;
282
+
220
283
  const { platformId } = payload;
221
284
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
222
285
  const platform = platformManager.get(platformId);
@@ -229,6 +292,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
229
292
 
230
293
  if (endpoint === BRIDGE_ENDPOINTS.platformStart) {
231
294
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
295
+ const adminErr = checkAdminAuth(authManager, payload);
296
+ if (adminErr) return adminErr;
297
+
232
298
  const { platformId } = payload;
233
299
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
234
300
  const platform = platformManager.get(platformId);
@@ -241,6 +307,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
241
307
 
242
308
  if (endpoint === BRIDGE_ENDPOINTS.platformUnbind) {
243
309
  if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
310
+ const adminErr = checkAdminAuth(authManager, payload);
311
+ if (adminErr) return adminErr;
312
+
244
313
  const { platformId } = payload;
245
314
  if (!platformId) return fail('bad-request', '缺少 platformId 参数');
246
315
  const platform = platformManager.get(platformId);
@@ -261,6 +330,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
261
330
 
262
331
  if (endpoint === BRIDGE_ENDPOINTS.wechatLogin) {
263
332
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
333
+ const adminErr = checkAdminAuth(authManager, payload);
334
+ if (adminErr) return adminErr;
335
+
264
336
  const { qrType } = payload;
265
337
  const result = await wechat.login({ qrType });
266
338
  if (!result.ok) return fail('bad-request', result.error ?? '登录启动失败');
@@ -270,6 +342,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
270
342
 
271
343
  if (endpoint === BRIDGE_ENDPOINTS.wechatSetAllowFrom) {
272
344
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
345
+ const adminErr = checkAdminAuth(authManager, payload);
346
+ if (adminErr) return adminErr;
347
+
273
348
  await wechat.setAllowFrom(payload.allowFrom);
274
349
  const value = await wechatStatusValue(wechat, logger);
275
350
  return ok(value);
@@ -277,6 +352,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
277
352
 
278
353
  if (endpoint === BRIDGE_ENDPOINTS.wechatSetConfig) {
279
354
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
355
+ const adminErr = checkAdminAuth(authManager, payload);
356
+ if (adminErr) return adminErr;
357
+
280
358
  await wechat.setConfig(payload);
281
359
  const value = await wechatStatusValue(wechat, logger);
282
360
  return ok(value);
@@ -284,6 +362,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
284
362
 
285
363
  if (endpoint === BRIDGE_ENDPOINTS.wechatStop) {
286
364
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
365
+ const adminErr = checkAdminAuth(authManager, payload);
366
+ if (adminErr) return adminErr;
367
+
287
368
  await wechat.stop();
288
369
  const value = await wechatStatusValue(wechat, logger);
289
370
  return ok(value);
@@ -291,6 +372,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
291
372
 
292
373
  if (endpoint === BRIDGE_ENDPOINTS.wechatStart) {
293
374
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
375
+ const adminErr = checkAdminAuth(authManager, payload);
376
+ if (adminErr) return adminErr;
377
+
294
378
  await wechat.gateway.start().catch((err) => {
295
379
  logger.error('wechat start enabled: %s', err?.message ?? err);
296
380
  });
@@ -300,6 +384,9 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
300
384
 
301
385
  if (endpoint === BRIDGE_ENDPOINTS.wechatUnbind) {
302
386
  if (!wechat) return fail('bad-request', '微信 Bot 未初始化');
387
+ const adminErr = checkAdminAuth(authManager, payload);
388
+ if (adminErr) return adminErr;
389
+
303
390
  await wechat.unbind();
304
391
  const value = await wechatStatusValue(wechat, logger);
305
392
  return ok(value);
@@ -75,13 +75,13 @@ export class FeishuConversationNode extends ConversationBridge {
75
75
  this._inTurn = false
76
76
 
77
77
  // 订阅网关入站消息事件
78
- this.ctx.on('feishu/message', (event) => this._handleInbound(event))
78
+ this.disposers.push(this.ctx.on('feishu/message', (event) => this._handleInbound(event)))
79
79
 
80
80
  // 订阅卡片交互事件(审批按钮点击)
81
- this.ctx.on('feishu/action', (event) => this._handleAction(event))
81
+ this.disposers.push(this.ctx.on('feishu/action', (event) => this._handleAction(event)))
82
82
 
83
83
  // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
84
- this.ctx.on('session/event', (session, event) => {
84
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
85
85
  if (session.id !== this.activeSessionId) return
86
86
  if (event.type === 'turn/start') {
87
87
  this._inTurn = true
@@ -103,7 +103,7 @@ export class FeishuConversationNode extends ConversationBridge {
103
103
  this._streamCardId = null
104
104
  this._streamContent = ''
105
105
  }
106
- })
106
+ }))
107
107
  }
108
108
 
109
109
  async _handleInbound(event) {
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
15
- import { exec } from 'node:child_process';
15
+ import { spawn } from 'node:child_process';
16
16
  import QRCode from 'qrcode';
17
17
  import { installBridgeRpc } from './bridge-rpc.js';
18
18
  import { CustomTunnelClient } from './tunnel-client.mjs';
@@ -186,10 +186,42 @@ class ProxyServer {
186
186
  return;
187
187
  }
188
188
 
189
- // 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status
189
+ // 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status (严格脱敏,不暴露 secretToken)
190
190
  if (req.url === '/__dsh_bridge__/auth-status' && req.method === 'GET') {
191
191
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
192
- res.end(JSON.stringify(this.authManager?.getStatus() ?? { enabled: false }));
192
+ res.end(JSON.stringify(this.authManager?.getPublicStatus() ?? { enabled: false }));
193
+ return;
194
+ }
195
+
196
+ // 3.1 本机特权 Token 签发:仅限真正物理回环连接(127.0.0.1 / ::1,严禁隧道转发流量伪造)
197
+ if (req.url === '/__dsh_bridge__/loopback-token') {
198
+ const corsHeaders = {
199
+ 'Access-Control-Allow-Origin': '*',
200
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
201
+ 'Access-Control-Allow-Headers': 'Content-Type',
202
+ };
203
+ if (req.method === 'OPTIONS') {
204
+ res.writeHead(204, corsHeaders);
205
+ res.end();
206
+ return;
207
+ }
208
+
209
+ const remote = req.socket?.remoteAddress || '';
210
+ const isLoopback = (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1');
211
+ const internalTunnelHeader = req.headers?.['x-dsh-internal-tunnel'];
212
+ const isCustomTunnel = Boolean(isLoopback && internalTunnelHeader && internalTunnelHeader === this.authManager?.internalTunnelSecret);
213
+ const isCloudflare = Boolean(isLoopback && (req.headers?.['cf-ray'] || req.headers?.['cf-connecting-ip']));
214
+ const isPublicTunnel = isCustomTunnel || isCloudflare;
215
+
216
+ if (isLoopback && !isPublicTunnel && this.authManager) {
217
+ const adminToken = this.authManager.createAdminSession();
218
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders });
219
+ res.end(JSON.stringify({ ok: true, adminToken }));
220
+ return;
221
+ }
222
+
223
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders });
224
+ res.end(JSON.stringify({ ok: false, error: 'Forbidden: loopback only' }));
193
225
  return;
194
226
  }
195
227
 
@@ -396,9 +428,9 @@ class BridgeService {
396
428
  return this.proxy;
397
429
  }
398
430
 
399
- async getStatus() {
431
+ async getStatus({ adminAuthValid = false } = {}) {
400
432
  const lanIp = selectLanIPv4();
401
- const token = this.authManager?.secretToken;
433
+ const token = adminAuthValid ? this.authManager?.secretToken : null;
402
434
  const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
403
435
 
404
436
  const isLanProtected = isAuthEnabled && this.authManager?.scope !== 'public_only';
@@ -428,7 +460,7 @@ class BridgeService {
428
460
  return {
429
461
  version: VERSION,
430
462
 
431
- auth: this.authManager?.getStatus() ?? { enabled: false },
463
+ auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
432
464
 
433
465
  proxy: {
434
466
  running: !!this.proxy,
@@ -486,6 +518,7 @@ class BridgeService {
486
518
  serverUrl,
487
519
  accessToken,
488
520
  localPort: this.proxyPort,
521
+ internalTunnelSecret: this.authManager?.internalTunnelSecret,
489
522
  onStateChange: (state) => {
490
523
  this.customTunnelState = state;
491
524
  },
@@ -575,29 +608,57 @@ class BridgeService {
575
608
  }
576
609
  }
577
610
 
578
- // 一键直接升级插件(执行 dsh plugin / pnpm / npm 自动升级)
611
+ // 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
579
612
  async upgradePlugin({ version } = {}) {
580
613
  const targetVersion = version ? String(version).trim() : 'latest';
614
+ // 严格 SemVer 白名单正则校验
615
+ if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
616
+ return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
617
+ }
581
618
  const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
582
- const commands = [
583
- `dsh plugin --profile web add ${pkgSpec}`,
584
- `npx --yes @deepseek-ai/dsh plugin --profile web add ${pkgSpec}`,
585
- `npm install ${pkgSpec}`,
619
+ const isWin = process.platform === 'win32';
620
+
621
+ const tasks = [
622
+ { cmd: isWin ? 'dsh.cmd' : 'dsh', fallbackCmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
623
+ { cmd: isWin ? 'npx.cmd' : 'npx', fallbackCmd: 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
624
+ { cmd: isWin ? 'npm.cmd' : 'npm', fallbackCmd: 'npm', args: ['install', pkgSpec] },
586
625
  ];
587
626
 
588
627
  let lastError = null;
589
- let output = '';
590
628
 
591
- for (const cmd of commands) {
629
+ for (const task of tasks) {
592
630
  try {
593
631
  const res = await new Promise((resolve, reject) => {
594
- exec(cmd, { timeout: 90000, windowsHide: true }, (err, stdout, stderr) => {
595
- if (err) return reject(new Error(stderr || err.message));
596
- resolve({ stdout, stderr });
597
- });
632
+ const runExecutable = (executable) => {
633
+ const cp = spawn(executable, task.args, {
634
+ windowsHide: true,
635
+ shell: false,
636
+ timeout: 90000,
637
+ });
638
+ let stdout = '';
639
+ let stderr = '';
640
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
641
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
642
+ cp.on('error', (err) => {
643
+ if (executable !== task.fallbackCmd) {
644
+ runExecutable(task.fallbackCmd);
645
+ } else {
646
+ reject(err);
647
+ }
648
+ });
649
+ cp.on('close', (code) => {
650
+ if (code === 0) {
651
+ resolve({ stdout, stderr });
652
+ } else {
653
+ reject(new Error(stderr || stdout || `进程退出码 ${code}`));
654
+ }
655
+ });
656
+ };
657
+ runExecutable(task.cmd);
598
658
  });
599
- output = res.stdout || res.stderr || '升级成功';
600
- return { ok: true, command: cmd, output, version: targetVersion };
659
+
660
+ const output = res.stdout || res.stderr || '升级成功';
661
+ return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
601
662
  } catch (err) {
602
663
  lastError = err;
603
664
  }
@@ -634,6 +695,9 @@ function apply(ctx, config = {}) {
634
695
  const configFile = join(dshHome, 'dsh-bridge', 'config.json');
635
696
  const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
636
697
 
698
+ // 配置写入互斥锁队列,杜绝多平台并发写入造成文件覆盖损坏
699
+ let configWriteQueue = Promise.resolve();
700
+
637
701
  // 从 JSON 文件读取持久化配置
638
702
  async function loadConfig() {
639
703
  try {
@@ -644,10 +708,15 @@ function apply(ctx, config = {}) {
644
708
  }
645
709
  }
646
710
 
647
- // 持久化配置到 JSON 文件
711
+ // 持久化配置到 JSON 文件(排队原子写入)
648
712
  async function saveConfig(data) {
649
- await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
650
- await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
713
+ configWriteQueue = configWriteQueue.then(async () => {
714
+ await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
715
+ await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
716
+ }).catch((err) => {
717
+ logger.error('saveConfig failed: %s', err.message);
718
+ });
719
+ return configWriteQueue;
651
720
  }
652
721
 
653
722
  // 访问安全认证管理器
package/lib/qq/node.js CHANGED
@@ -147,8 +147,8 @@ export class QqConversationNode extends ConversationBridge {
147
147
  this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
148
148
 
149
149
  // 订阅网关入站事件
150
- this.ctx.on('qq/message', (event) => this._handleInbound(event))
151
- this.ctx.on('qq/interaction', (event) => this._handleInteraction(event))
150
+ this.disposers.push(this.ctx.on('qq/message', (event) => this._handleInbound(event)))
151
+ this.disposers.push(this.ctx.on('qq/interaction', (event) => this._handleInteraction(event)))
152
152
  }
153
153
 
154
154
  // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
@@ -350,6 +350,13 @@ export class QqConversationNode extends ConversationBridge {
350
350
  })
351
351
  }
352
352
 
353
+ /** 发送多媒体附件,正确传递 c2c 或 group 范围 */
354
+ async sendMediaFile(filePath, opts = {}) {
355
+ const peerInfo = this._currentPeer()
356
+ if (!peerInfo) return
357
+ return this.gateway.sendMediaFile(peerInfo.peerId, filePath, { scope: peerInfo.scope, ...opts })
358
+ }
359
+
353
360
  // ---- 入站 ----
354
361
 
355
362
  async _handleInbound(event) {
@@ -68,13 +68,13 @@ export class TelegramConversationNode extends ConversationBridge {
68
68
  this._inTurn = false
69
69
 
70
70
  // 订阅网关入站消息事件
71
- this.ctx.on('telegram/message', (event) => this._handleInbound(event))
71
+ this.disposers.push(this.ctx.on('telegram/message', (event) => this._handleInbound(event)))
72
72
 
73
73
  // 订阅 Inline 按钮点击交互事件(审批确认)
74
- this.ctx.on('telegram/action', (event) => this._handleAction(event))
74
+ this.disposers.push(this.ctx.on('telegram/action', (event) => this._handleAction(event)))
75
75
 
76
76
  // 监听轮次事件:turn/start 开启流式打字机,turn/end 最终刷新并重置
77
- this.ctx.on('session/event', (session, event) => {
77
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
78
78
  if (session.id !== this.activeSessionId) return
79
79
  if (event.type === 'turn/start') {
80
80
  this._inTurn = true
@@ -91,7 +91,7 @@ export class TelegramConversationNode extends ConversationBridge {
91
91
  this._streamMsgId = null
92
92
  this._streamContent = ''
93
93
  }
94
- })
94
+ }))
95
95
  }
96
96
 
97
97
  async _handleInbound(event) {
@@ -8,10 +8,11 @@ const RECONNECT_DELAY = 5000;
8
8
  const MAX_RECONNECT_ATTEMPTS = 5;
9
9
 
10
10
  export class CustomTunnelClient {
11
- constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
11
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
12
12
  this.serverUrl = serverUrl;
13
13
  this.accessToken = accessToken;
14
14
  this.localPort = localPort;
15
+ this.internalTunnelSecret = internalTunnelSecret;
15
16
  this.signal = signal;
16
17
  this.onStateChange = onStateChange;
17
18
  this.logger = logger;
@@ -126,10 +127,15 @@ export class CustomTunnelClient {
126
127
  Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
127
128
  );
128
129
 
130
+ const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
131
+ if (this.internalTunnelSecret) {
132
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
133
+ }
134
+
129
135
  const req = httpRequest({
130
136
  host: '127.0.0.1', port: this.localPort,
131
137
  method, path: path || '/',
132
- headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
138
+ headers: reqHeaders,
133
139
  }, (res) => {
134
140
  const chunks = [];
135
141
  res.on('data', c => chunks.push(c));
@@ -168,6 +174,9 @@ export class CustomTunnelClient {
168
174
  const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
169
175
  delete reqHeaders['proxy-connection'];
170
176
  delete reqHeaders['proxy-authorization'];
177
+ if (this.internalTunnelSecret) {
178
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
179
+ }
171
180
 
172
181
  const lines = [`GET ${path || '/'} HTTP/1.1`];
173
182
  for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
@@ -187,9 +187,15 @@ export async function downloadMedia({
187
187
  const controller = new AbortController()
188
188
  const timer = setTimeout(() => controller.abort(), timeoutMs)
189
189
  try {
190
- const response = await fetch(url, { signal: controller.signal })
191
- if (!response.ok) throw new Error(`media download HTTP ${response.status}`)
190
+ const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25MB
191
+ const contentLength = Number(response.headers.get('content-length') || 0);
192
+ if (contentLength > MAX_DOWNLOAD_BYTES) {
193
+ throw new Error(`Media file exceeds maximum allowed size (${contentLength} > 25MB)`);
194
+ }
192
195
  const raw = Buffer.from(await response.arrayBuffer())
196
+ if (raw.length > MAX_DOWNLOAD_BYTES) {
197
+ throw new Error(`Media file exceeds maximum allowed size (${raw.length} > 25MB)`);
198
+ }
193
199
  if (aesKeyBase64) {
194
200
  const key = parseAesKey(aesKeyBase64)
195
201
  return aes128EcbDecrypt(raw, key)
@@ -59,9 +59,9 @@ export class WechatConversationNode extends ConversationBridge {
59
59
  onActiveSessionChange,
60
60
  })
61
61
 
62
- this.ctx.on('wechat/message', (message) => {
62
+ this.disposers.push(this.ctx.on('wechat/message', (message) => {
63
63
  void this._handleInbound(message)
64
- })
64
+ }))
65
65
  }
66
66
 
67
67
  // ---- 入站 ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,31 +18,29 @@
18
18
  "README.md",
19
19
  "README.en.md",
20
20
  "LICENSE",
21
- "docs/banner.jpg",
22
- "docs/custom-tunnel.md",
23
- "docs/wechat-usage.md",
24
- "docs/qq-usage.md",
25
- "docs/feishu-usage.md",
26
- "docs/telegram-usage.md"
21
+ "docs"
27
22
  ],
28
23
  "scripts": {
29
24
  "build:client": "node client/build.mjs",
25
+ "build:banner": "node scripts/generate-banner.mjs",
26
+ "prepack": "npm run build:client",
30
27
  "test": "node --test test/*.test.mjs"
31
28
  },
32
29
  "dependencies": {
30
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
33
31
  "@larksuiteoapi/node-sdk": "^1.73.0",
34
32
  "qrcode": "^1.5.3",
35
- "ws": "^8.18.0",
36
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6"
33
+ "ws": "^8.18.0"
37
34
  },
38
35
  "devDependencies": {
39
- "esbuild": "^0.25.9"
36
+ "esbuild": "^0.25.9",
37
+ "puppeteer-core": "^25.8.0"
40
38
  },
41
39
  "peerDependencies": {
42
40
  "@deepseek-ai/cordis": "^4.0.1"
43
41
  },
44
42
  "engines": {
45
- "node": ">=22"
43
+ "node": "^22.19.0 || >=24.0.0"
46
44
  },
47
45
  "license": "MIT",
48
46
  "keywords": [
@@ -62,7 +60,8 @@
62
60
  "bot",
63
61
  "wechat",
64
62
  "qq",
65
- "feishu"
63
+ "feishu",
64
+ "telegram"
66
65
  ],
67
66
  "dsh": {
68
67
  "bundle": {