@wenbin_wb/dsh-bridge 2.11.3 → 2.12.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.en.md +25 -25
  3. package/README.md +25 -25
  4. package/client/client.js +49 -2
  5. package/client/index.js +54 -4
  6. package/lib/bridge-rpc-constants.js +2 -0
  7. package/lib/bridge-rpc.js +33 -2
  8. package/lib/index.js +263 -16
  9. package/lib/platform/conversation-bridge.js +21 -6
  10. package/lib/platform/dsh-storage.js +64 -10
  11. package/package.json +4 -5
  12. package/docs/CODE_REVIEW.md +0 -220
  13. package/docs/banner.jpg +0 -0
  14. package/docs/cloudflare-fixed-domain.md +0 -158
  15. package/docs/custom-tunnel.md +0 -220
  16. package/docs/feishu-usage.md +0 -116
  17. package/docs/qq-usage.md +0 -387
  18. package/docs/release-process.md +0 -45
  19. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  20. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  21. package/docs/screenshots/feishu-chat.jpg +0 -0
  22. package/docs/screenshots/lan-access.jpg +0 -0
  23. package/docs/screenshots/mobile-chat.jpg +0 -0
  24. package/docs/screenshots/mobile-drawer.jpg +0 -0
  25. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  26. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  27. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  28. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  29. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  30. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  31. package/docs/screenshots/qq-bot-config.jpg +0 -0
  32. package/docs/screenshots/qq-chat.jpg +0 -0
  33. package/docs/screenshots/qq-group.jpg +0 -0
  34. package/docs/screenshots/qr-scan.jpg +0 -0
  35. package/docs/screenshots/remote-auth-login.jpg +0 -0
  36. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  37. package/docs/screenshots/security-auth-config.jpg +0 -0
  38. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  39. package/docs/screenshots/tunnel-access.jpg +0 -0
  40. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  41. package/docs/screenshots/wechat-chat.jpg +0 -0
  42. package/docs/telegram-usage.md +0 -93
  43. package/docs/wechat-usage.md +0 -104
package/lib/bridge-rpc.js CHANGED
@@ -16,7 +16,10 @@ function ok(value) {
16
16
 
17
17
  function fail(code, message, details = {}) {
18
18
  const allowedCodes = new Set([
19
- 'bad-request', 'cancelled', 'internal', 'settings-rejected', 'command-error'
19
+ 'bad-request', 'cancelled', 'internal', 'settings-rejected', 'command-error',
20
+ // 'busy':重启等操作因「有任务在跑」被拦下,需与普通 bad-request 区分,
21
+ // 面板据此弹二次确认(而不是报错)。
22
+ 'busy'
20
23
  ]);
21
24
  const safeCode = allowedCodes.has(code) ? code : 'bad-request';
22
25
  return {
@@ -82,12 +85,18 @@ function checkAdminAuth(authManager, payload, { requireConfigured = false } = {}
82
85
  return fail('bad-request', '操作已被拦截:需要管理员权限,请先在控制台输入管理密码解锁');
83
86
  }
84
87
 
85
- export function installBridgeRpc(ctx, { service, authManager, platformManager, logger, saveCustomTunnelConfig, exportBackup, importBackup }) {
88
+ export function installBridgeRpc(ctx, { service, authManager, platformManager, logger, saveCustomTunnelConfig, exportBackup, importBackup, runtime }) {
86
89
  if (!ctx?.connection?.rpc?.handle) {
87
90
  logger.warn('dsh-bridge: Connection RPC unavailable — UI will not work');
88
91
  return () => {};
89
92
  }
90
93
 
94
+ // 运行中任务探测所需的引用(由 apply 注入)。缺失时探测退化为「无任务」,不阻断重启。
95
+ if (runtime) {
96
+ service.runtimeCtx = runtime.ctx ?? null;
97
+ service.platformManager = runtime.platformManager ?? null;
98
+ }
99
+
91
100
  // 经 connection-compat 注册:兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归
92
101
  // (上游 register() 里对 connection 自身 ctx 取 webServer 而未加 inject 作用域)
93
102
  return registerRpcChannel(
@@ -152,6 +161,12 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
152
161
  return ok(authManager.getStatus({ masked: false }));
153
162
  }
154
163
 
164
+ if (endpoint === BRIDGE_ENDPOINTS.dismissFirstRunGuide) {
165
+ // 无管理权限要求:这是纯本地提示状态,不涉及安全配置变更。
166
+ // 与 authUpdateConfig 中「切换 enabled 不需管理权限」同理,避免提示无法关闭。
167
+ return ok(await service.dismissFirstRunGuide());
168
+ }
169
+
155
170
  if (endpoint === BRIDGE_ENDPOINTS.authRegenerateToken) {
156
171
  if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
157
172
  const adminErr = checkAdminAuth(authManager, payload);
@@ -305,6 +320,22 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
305
320
  const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
306
321
  if (adminErr) return adminErr;
307
322
 
323
+ // 打断保护:有任务在跑且调用方未显式确认时,**不重启**,改为把现场回给面板,
324
+ // 由面板弹二次确认。已确认(payload.confirm === true)则照常重启。
325
+ // 探测本身失败会降级为「无任务」,绝不会把重启锁死。
326
+ if (payload?.confirm !== true) {
327
+ let work;
328
+ try {
329
+ work = service.getActiveWork?.() ?? { total: 0, sessions: [], subagentSessions: [], pendingApprovals: [] };
330
+ } catch (err) {
331
+ logger?.debug?.('dsh-bridge: 重启前探测任务失败(按无任务处理):%s', err?.message ?? err);
332
+ work = { total: 0, sessions: [], subagentSessions: [], pendingApprovals: [] };
333
+ }
334
+ if (work.total > 0) {
335
+ return fail('busy', '当前有任务正在运行,重启会打断它们', { activeWork: work });
336
+ }
337
+ }
338
+
308
339
  const result = await service.restartDsh();
309
340
  return ok(result);
310
341
  }
package/lib/index.js CHANGED
@@ -11,13 +11,14 @@ import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform,
11
11
  import { join, dirname, basename, resolve, normalize } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { readFileSync, appendFileSync, existsSync, realpathSync, mkdirSync } from 'node:fs';
14
- import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
14
+ import { readFile, writeFile, mkdir, unlink, readdir, stat, access, chmod } from 'node:fs/promises';
15
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';
19
19
  import { CloudflaredManager, CLOUDFLARED_LOG_NAME } from './cloudflared-manager.mjs';
20
20
  import { PlatformManager } from './platform/manager.js';
21
+ import { describeOpenTurn } from './platform/conversation-bridge.js';
21
22
  import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
22
23
  import { WechatService } from './wechat/index.js';
23
24
  import { QqService } from './qq/index.js';
@@ -316,6 +317,49 @@ export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
316
317
  return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
317
318
  }
318
319
 
320
+ /**
321
+ * 判断当前宿主是否跑在 DSH 桌面版(Electron)内(纯函数,便于单测)。
322
+ *
323
+ * 背景:桌面端宿主是 Electron 以 RunAsNode 模式拉起的同一套 @deepseek-ai/dsh,
324
+ * 进程生死由 Electron 壳经 IPC 管理(shutdown/quit-inspection),没有 systemd、
325
+ * DSH_DAEMON/PM2 这类托管器。插件若按 Web/CLI 版的假设自行重启/升级宿主,
326
+ * 会拉起脱离壳管理的孤儿进程(抢占 19387 端口、触发壳的恢复对话框)或把包
327
+ * 装进错误的 profile(桌面端 profile 名为 `desktop`,不是 `web`)。
328
+ *
329
+ * 判据(按可靠性排序,任一命中即判为桌面端):
330
+ * 1. `process.versions.electron` 存在 —— Electron(含 RunAsNode 模式)的官方
331
+ * 行为保证该字段存在,跨平台最可靠;
332
+ * 2. 主模块路径含 `desktop-host` —— 桌面宿主入口固定为 desktop-host 包。
333
+ *
334
+ * fail-open:判不准时一律按 Web/CLI 版走,绝不能把正常用户的重启/升级拦掉。
335
+ *
336
+ * @param {object} [opts]
337
+ * @param {object} [opts.versions] process.versions(默认 process.versions)
338
+ * @param {string} [opts.argv1] process.argv[1](默认 process.argv[1])
339
+ * @returns {boolean}
340
+ */
341
+ export function isDesktopHost({ versions = process.versions, argv1 = process.argv[1] } = {}) {
342
+ try {
343
+ if (versions && typeof versions.electron === 'string' && versions.electron.length > 0) return true;
344
+ } catch { /* 忽略,继续下一个判据 */ }
345
+ try {
346
+ // 必须按路径分段匹配(前后是分隔符或串 ends):避免用户目录名恰好包含
347
+ // desktop-host 子串(如本仓库的 test/desktop-host-compat.test.mjs)时误判。
348
+ // 生产路径形如 .../dsh-desktop-host/lib/index.js。
349
+ if (typeof argv1 === 'string' && /(?:^|[/\\])(?:dsh-)?desktop-host(?:[/\\]|$)/i.test(argv1)) return true;
350
+ } catch { /* 忽略,fail-open */ }
351
+ return false;
352
+ }
353
+
354
+ /**
355
+ * 当前宿主加载的 profile 名(纯函数,便于单测)。
356
+ * 桌面端为 `desktop`,其余(Web/CLI)为 `web`。供 `dsh plugin --profile <name> add`
357
+ * 类命令使用,避免把包装进宿主不加载的 profile。
358
+ */
359
+ export function currentProfileName(opts = {}) {
360
+ return isDesktopHost(opts) ? 'desktop' : 'web';
361
+ }
362
+
319
363
  class ProxyServer {
320
364
  constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
321
365
  this.localPort = localPort;
@@ -571,6 +615,27 @@ class ProxyServer {
571
615
  return;
572
616
  }
573
617
 
618
+ // 纵深防御:浏览器发起的升级请求若带 Origin 且不在已知面板来源内,拒绝握手,
619
+ // 防止恶意页面借用户浏览器已有的 Cookie 建立 WebSocket(CSWSH)。
620
+ // 认证已在上方完成,此处只做额外收紧,不构成绕过认证的路径。
621
+ // 关键兼容性约束:Origin 缺失一律放行——非浏览器客户端(IM 机器人、
622
+ // ws 库、命令行工具、隧道转发)通常不发 Origin,若按缺失拒绝会直接
623
+ // 打断现有用户的机器人通道。
624
+ const origin = req.headers?.origin;
625
+ if (origin) {
626
+ let allowed;
627
+ try {
628
+ allowed = this.allowedOrigins?.() ?? [];
629
+ } catch { allowed = []; }
630
+ if (!allowed.includes(origin)) {
631
+ // 直接内联 origin,避免依赖 logger 的 printf 插值(该 logger 在部分调用形态下不做替换)
632
+ this.logger?.warn?.(`dsh-bridge: 拒绝来源不明的 WebSocket 升级请求 (Origin=${origin})`);
633
+ socket.write('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nForbidden\r\n');
634
+ socket.destroy();
635
+ return;
636
+ }
637
+ }
638
+
574
639
  const headers = loopbackHeaders(req.headers, this.targetPort);
575
640
  const proxyReq = httpRequest({
576
641
  host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
@@ -666,9 +731,19 @@ class BridgeService {
666
731
  this.onPersist = onPersist ?? null;
667
732
  this.logger = logger;
668
733
 
734
+ // 运行中任务探测所需的运行时引用(由 apply 在构造后注入):
735
+ // ctx.sessions —— 枚举 live 会话及其事件流,判断是否有 turn 在跑
736
+ // platformManager —— 遍历各 IM 平台 bridge,取其 pending 审批
737
+ // 二者缺失时探测退化为「未检测到任务」,重启照常执行(不得因探测失败而阻断重启)。
738
+ this.runtimeCtx = null;
739
+ this.platformManager = null;
740
+
669
741
  this.qrCache = new QrCache();
670
742
  this.proxy = null;
671
743
 
744
+ // 首次启用引导:是否仍需要向用户展示「开门禁」提示(由 apply 的启动链按保守判据置位)
745
+ this.firstRunGuidePending = false;
746
+
672
747
  this.customTunnel = null;
673
748
  this.customTunnelState = { phase: 'idle', detail: '' };
674
749
 
@@ -742,8 +817,17 @@ class BridgeService {
742
817
  return this._dshVersion;
743
818
  }
744
819
 
745
- async setLanIp({ ip } = {}) {
746
- const trimmed = ip ? String(ip).trim() : null;
820
+ /**
821
+ * 标记「首次启用引导」已展示/已确认,持久化后不再打扰用户。
822
+ * 与访问认证状态解耦:即便用户仍未开启门禁,确认过后也不再重复弹窗。
823
+ */
824
+ async dismissFirstRunGuide() {
825
+ this.firstRunGuidePending = false;
826
+ await this.onPersist?.({ wizard: { guideShown: true } });
827
+ return { ok: true };
828
+ }
829
+
830
+ async setLanIp({ ip } = {}) { const trimmed = ip ? String(ip).trim() : null;
747
831
  this.selectedLanIp = trimmed || null;
748
832
  await this.onPersist?.({ lan: { selectedIp: this.selectedLanIp } });
749
833
  this.logger?.info('局域网选定 IP 更新为: %s', this.selectedLanIp || '自动推荐');
@@ -767,8 +851,17 @@ class BridgeService {
767
851
  `http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
768
852
  ];
769
853
  try {
770
- for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
771
- if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
854
+ // LAN 面板与回环同理需要同时覆盖 3082(代理)与 dshPort(DSH 原生端口直连):
855
+ // 浏览器从 LAN IP 直连原生端口时 Origin 是 http://<lanIp>:<dshPort>,
856
+ // 只配 proxyPort 会导致页面能打开但 WebSocket 升级被拒(实时通道静默失效)。
857
+ for (const iface of listAllLanIPv4()) {
858
+ origins.push(`http://${iface.address}:${this.proxyPort}`);
859
+ origins.push(`http://${iface.address}:${this.dshPort}`);
860
+ }
861
+ if (this.selectedLanIp) {
862
+ origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
863
+ origins.push(`http://${this.selectedLanIp}:${this.dshPort}`);
864
+ }
772
865
  if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
773
866
  if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
774
867
  if (this.externalTunnelConfig?.url) origins.push(new URL(this.externalTunnelConfig.url).origin);
@@ -818,6 +911,13 @@ class BridgeService {
818
911
 
819
912
  auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
820
913
 
914
+ // 首次启用引导:为 true 时面板应提示用户开启访问门禁(默认 0.0.0.0 且认证默认关闭)
915
+ firstRunGuide: {
916
+ pending: Boolean(this.firstRunGuidePending),
917
+ // 当前是否处于「监听 0.0.0.0 但认证未开启」的开箱敞开状态
918
+ exposedWithoutAuth: this.authManager?.enabled !== true,
919
+ },
920
+
821
921
  proxy: {
822
922
  running: !!this.proxy,
823
923
  port: this.proxyPort,
@@ -1113,16 +1213,24 @@ class BridgeService {
1113
1213
  try {
1114
1214
  dshRealPath = realpathSync(dshBin) || dshBin;
1115
1215
  } catch { /* 保留原始路径继续判断 */ }
1116
- // 2. 当前 node 对应的全局 node_modules 根
1216
+ // npm root -g 返回的是 prefix 下的 node_modules(如 /prefix/lib/node_modules),
1217
+ // 不能直接 dirname 后再作为 --prefix,否则会变成 /prefix/lib,实际安装到
1218
+ // /prefix/lib/lib/node_modules,dsh 命令仍会加载旧的 /prefix/lib/node_modules。
1219
+ // npm prefix -g 才是可传给 --prefix 的真实全局安装前缀。
1220
+ const globalPrefix = await runCmd('npm', ['prefix', '-g']);
1117
1221
  const globalNodeModules = await runCmd('npm', ['root', '-g']);
1118
- const rootGlobal = globalNodeModules ? dirname(globalNodeModules) : dirname(nodeDir);
1222
+ const rootGlobal = globalPrefix || (globalNodeModules ? dirname(dirname(globalNodeModules)) : dirname(nodeDir));
1119
1223
  const marker = join('node_modules', '@deepseek-ai', 'dsh');
1120
1224
  if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
1121
1225
  return { upgradable: true, rootGlobal, dshRealPath };
1122
1226
  }
1123
1227
  return {
1124
1228
  upgradable: false,
1125
- reason: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1229
+ // 桌面端 dsh 装在 App 包内:走桌面应用内更新,不要用 npm 动它。
1230
+ // 桌面检测是 fail-open 的,判不准时仍是这条通用文案,不影响 Web/CLI 用户。
1231
+ reason: isDesktopHost()
1232
+ ? '桌面版 DSH 由应用内更新统一管理(一键升级仅支持 npm 全局安装的 CLI 版);请用桌面应用菜单的"检查更新"升级,勿用 npm 改动应用包内文件'
1233
+ : 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1126
1234
  };
1127
1235
  } catch {
1128
1236
  return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
@@ -1183,7 +1291,9 @@ class BridgeService {
1183
1291
  }
1184
1292
 
1185
1293
  // 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
1186
- async upgradePlugin({ version } = {}) {
1294
+ // opts.profile 供测试注入;默认按当前宿主推导(桌面端为 `desktop`,其余为 `web`),
1295
+ // 绝不能写死 --profile web——桌面端会把包装进宿主不加载的 profile,造成"显示成功实际没升"。
1296
+ async upgradePlugin({ version, spawnImpl = spawn, profile } = {}) {
1187
1297
  const targetVersion = version ? String(version).trim() : 'latest';
1188
1298
  // 严格 SemVer 白名单正则校验
1189
1299
  if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
@@ -1222,9 +1332,15 @@ class BridgeService {
1222
1332
  const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
1223
1333
  const siblingNpx = join(nodeDir, isWin ? 'npx.cmd' : 'npx');
1224
1334
 
1335
+ // 目标 profile:显式传入优先,否则按宿主形态推导(桌面端 `desktop`,其余 `web`)。
1336
+ // 桌面端 profile 名固定,见官方 desktop-host(runProfile profile: 'desktop')。
1337
+ // 安全:payload 里的 profile 不可信,白名单只认 desktop/web,其余一律按推导值处理,
1338
+ // 避免未校验字符串进入 shell:true 的 spawn 参数(参数拼接有注入风险)。
1339
+ const targetProfile = (profile === 'desktop' || profile === 'web') ? profile : currentProfileName();
1340
+
1225
1341
  const tasks = [
1226
- { cmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
1227
- { cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
1342
+ { cmd: 'dsh', args: ['plugin', '--profile', targetProfile, 'add', pkgSpec] },
1343
+ { cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', targetProfile, 'add', pkgSpec] },
1228
1344
  { cmd: existsSync(siblingNpm) ? siblingNpm : 'npm', args: ['install', pkgSpec] },
1229
1345
  ];
1230
1346
 
@@ -1235,7 +1351,7 @@ class BridgeService {
1235
1351
  const res = await new Promise((resolve, reject) => {
1236
1352
  let cp;
1237
1353
  try {
1238
- cp = spawn(task.cmd, task.args, {
1354
+ cp = spawnImpl(task.cmd, task.args, {
1239
1355
  windowsHide: true,
1240
1356
  shell: true,
1241
1357
  env: augmentedEnv,
@@ -1272,7 +1388,8 @@ class BridgeService {
1272
1388
 
1273
1389
  // 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
1274
1390
  // 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
1275
- async upgradeDsh({ version } = {}) {
1391
+ // opts.spawnImpl 供测试注入,生产环境用 node:child_process 的 spawn。
1392
+ async upgradeDsh({ version, spawnImpl = spawn } = {}) {
1276
1393
  const targetVersion = version ? String(version).trim() : 'latest';
1277
1394
  // 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
1278
1395
  if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
@@ -1331,7 +1448,7 @@ class BridgeService {
1331
1448
  const res = await new Promise((resolve, reject) => {
1332
1449
  let cp;
1333
1450
  try {
1334
- cp = spawn(task.cmd, task.args, {
1451
+ cp = spawnImpl(task.cmd, task.args, {
1335
1452
  windowsHide: true,
1336
1453
  shell: true,
1337
1454
  env: augmentedEnv,
@@ -1352,6 +1469,27 @@ class BridgeService {
1352
1469
  });
1353
1470
  const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
1354
1471
  this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
1472
+
1473
+ // 升级成功后清理版本缓存,让 getDshVersion/checkVersion 下次重新探测
1474
+ this._dshVersion = null;
1475
+ this._dshVersionLoaded = false;
1476
+ this._versionCheckCache = null;
1477
+
1478
+ // 核验实际 dsh 版本:npm 命令成功不等于正在使用的 dsh 已经变新。
1479
+ // 如果探测到的版本号仍然是旧版(或探测失败),则安装可能装到了错误位置。
1480
+ const actualVersion = await this.getDshVersion();
1481
+ if (actualVersion && targetVersion !== 'latest') {
1482
+ // 用户指定了具体版本号(如 0.2.0-rc.2):安装后 dsh --version 必须精确匹配
1483
+ if (actualVersion !== targetVersion) {
1484
+ return {
1485
+ ok: false,
1486
+ error: `npm 安装成功但 dsh 命令实际版本仍为 ${actualVersion}(预期 ${targetVersion})。可能安装到了错误位置,请手动执行 \`npm install -g @deepseek-ai/dsh@${targetVersion}\` 并重启 DSH。`,
1487
+ version: targetVersion,
1488
+ installedButNotActive: true,
1489
+ };
1490
+ }
1491
+ }
1492
+
1355
1493
  return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
1356
1494
  } catch (err) {
1357
1495
  lastError = err;
@@ -1368,6 +1506,65 @@ class BridgeService {
1368
1506
  // - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
1369
1507
  // 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
1370
1508
  // 因此这里先识别托管方式:
1509
+ // 探测「重启会打断哪些正在跑的任务」。
1510
+ //
1511
+ // 判据(均为可从宿主运行时直接读到的信号,不依赖任何新增宿主 API):
1512
+ // 1. 主会话进行中的 turn:事件流里存在未配对的 turn/start(无对应 turn/end)。
1513
+ // 这是 Web/IM 两端「正在思考/工具调用中」的同一依据
1514
+ // (见 lib/platform/conversation-bridge.js 的 digestLine)。
1515
+ // 2. 子代理/agent 会话:会话 header.origin === 'subagent',同样按未闭合 turn 判断在跑。
1516
+ // 嵌套委托最容易被遗忘,重启损失也最大。
1517
+ // 3. 待审批请求:各 IM 平台 bridge 的 pending(用户尚未在机器人里点「同意/拒绝」)。
1518
+ // 宿主 ctx.approval 只提供 request(),无查询 API,故以插件自身登记的 pending 为准。
1519
+ //
1520
+ // 任何一步探测失败都必须降级为「该项无任务」并继续,绝不因此阻断重启:
1521
+ // 重启是救急能力,不能被探测逻辑锁死。
1522
+ getActiveWork() {
1523
+ const work = { sessions: [], subagentSessions: [], pendingApprovals: [], total: 0 };
1524
+
1525
+ // 1 + 2:遍历 live 会话,按未闭合 turn 判断是否在跑
1526
+ try {
1527
+ const list = this.runtimeCtx?.sessions?.list?.() ?? [];
1528
+ for (const s of list) {
1529
+ if (!s || !s.id) continue;
1530
+ const isSubagent = s.origin === 'subagent' || s.header?.origin === 'subagent';
1531
+ const turn = describeOpenTurn(s.events);
1532
+ if (!turn) continue;
1533
+ const entry = {
1534
+ id: s.id,
1535
+ title: s.title || (s.header?.cwd ? basename(s.header.cwd) : '') || '未命名会话',
1536
+ turn: turn.turn,
1537
+ tools: turn.tools,
1538
+ lastTool: turn.lastTool,
1539
+ };
1540
+ if (isSubagent) work.subagentSessions.push(entry);
1541
+ else work.sessions.push(entry);
1542
+ }
1543
+ } catch (err) {
1544
+ this.logger?.debug?.('dsh-bridge: 探测进行中会话失败(视为无任务):%s', err?.message ?? err);
1545
+ }
1546
+
1547
+ // 3:各 IM 平台的待审批请求
1548
+ try {
1549
+ for (const platform of this.platformManager?.list?.() ?? []) {
1550
+ const pending = platform?.bridge?.pending;
1551
+ if (!pending || typeof pending.forEach !== 'function') continue;
1552
+ pending.forEach((entry, number) => {
1553
+ work.pendingApprovals.push({
1554
+ platform: platform?.id ?? 'unknown',
1555
+ number,
1556
+ summary: entry?.summary || entry?.title || entry?.tool || '待审批请求',
1557
+ });
1558
+ });
1559
+ }
1560
+ } catch (err) {
1561
+ this.logger?.debug?.('dsh-bridge: 探测待审批请求失败(视为无)):%s', err?.message ?? err);
1562
+ }
1563
+
1564
+ work.total = work.sessions.length + work.subagentSessions.length + work.pendingApprovals.length;
1565
+ return work;
1566
+ }
1567
+
1371
1568
  // - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
1372
1569
  // 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
1373
1570
  // - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
@@ -1375,6 +1572,17 @@ class BridgeService {
1375
1572
  // 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
1376
1573
  // opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
1377
1574
  async restartDsh(opts = {}) {
1575
+ // 桌面端拦截(P0):桌面宿主由 Electron 壳经 IPC 管理生死,插件绝不能自行
1576
+ // 派生 helper 重拉进程——那会产生壳外孤儿(抢占 19387 端口、触发壳的恢复
1577
+ // 对话框)。直接拒绝,并指引用户走应用菜单重启。
1578
+ // isDesktopHost 是 fail-open 的:判不准时按 Web/CLI 版走,不拦正常用户。
1579
+ if (isDesktopHost({ versions: opts.processVersions, argv1: opts.argv1 })) {
1580
+ return {
1581
+ ok: false,
1582
+ error: '当前运行在 DSH 桌面版内,宿主由应用统一管理,插件不能自行重启。请用桌面应用菜单的重启/退出重进(有关闭确认保护正在跑的任务),不要用面板重启。',
1583
+ desktopManaged: true,
1584
+ };
1585
+ }
1378
1586
  const env = opts.env ?? process.env;
1379
1587
  const spawnImpl = opts.spawnImpl ?? spawn;
1380
1588
  const readCgroup = opts.readCgroup
@@ -1962,9 +2170,42 @@ function apply(ctx, config = {}) {
1962
2170
  }
1963
2171
  }
1964
2172
 
2173
+ // config.json 含平台 Token / 隧道凭据 / 访问密码哈希,属同机敏感文件:
2174
+ // 落盘固定 0600,与 sessions.json(lib/auth/manager.js)保持一致。
2175
+ const CONFIG_FILE_MODE = 0o600;
2176
+
1965
2177
  async function writeConfig(data) {
1966
2178
  await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
1967
- await writeFile(configFile, JSON.stringify(data, null, 2), 'utf8');
2179
+ // mode 仅对新建文件生效;已存在的旧文件(历史版本落在 0644)由
2180
+ // ensureConfigFileMode 在启动时收敛,两者配合覆盖新建与存量两种情形。
2181
+ await writeFile(configFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
2182
+ }
2183
+
2184
+ // 存量收敛:writeFile 的 mode 不会修改已存在文件的权限位,历史版本已把
2185
+ // config.json 写成 0644(同机其他用户可读)。此处幂等收紧到 0600。
2186
+ // 失败仅告警、不阻断启动(Windows 等平台 chmod 语义不同,且权限收紧
2187
+ // 不应成为插件不可用的原因)。
2188
+ async function ensureConfigFileMode() {
2189
+ try {
2190
+ await access(configFile);
2191
+ } catch (err) {
2192
+ // 仅「文件不存在」属预期(尚未写入,写入时会带上正确 mode)。
2193
+ // 其余错误(EACCES/EIO 等)意味着我们无法确认权限状态,必须出声,
2194
+ // 否则旧版遗留的宽松权限会被静默放过。
2195
+ if (err?.code !== 'ENOENT') {
2196
+ logger.warn('dsh-bridge: 无法检查 config.json 权限状态,跳过收紧:%s', err?.message ?? err);
2197
+ }
2198
+ return;
2199
+ }
2200
+ try {
2201
+ const current = await stat(configFile);
2202
+ const tightened = current.mode & 0o777;
2203
+ if (tightened === CONFIG_FILE_MODE) return;
2204
+ await chmod(configFile, CONFIG_FILE_MODE);
2205
+ logger.info('dsh-bridge: 已将 config.json 权限从 %s 收敛为 0600', tightened.toString(8).padStart(4, '0'));
2206
+ } catch (err) {
2207
+ logger.warn('dsh-bridge: 收紧 config.json 权限失败(不影响运行):%s', err?.message ?? err);
2208
+ }
1968
2209
  }
1969
2210
 
1970
2211
  // 整对象写入(同样入队,避免与进行中的事务交错)
@@ -2022,7 +2263,7 @@ function apply(ctx, config = {}) {
2022
2263
  }
2023
2264
 
2024
2265
  // 启动时读取已保存的 auth 配置并执行保命标记检查
2025
- checkEmergencyReset().then(() => loadConfig()).then((stored) => {
2266
+ checkEmergencyReset().then(() => ensureConfigFileMode()).then(() => loadConfig()).then((stored) => {
2026
2267
  if (stored?.auth) {
2027
2268
  if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
2028
2269
  if (stored.auth.mode) authManager.mode = stored.auth.mode;
@@ -2053,6 +2294,10 @@ function apply(ctx, config = {}) {
2053
2294
 
2054
2295
  // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
2055
2296
  loadConfig().then(async (stored) => {
2297
+ // 首次启用引导判据(保守):仅当「访问认证未开启」且「从未展示过引导」时才提示。
2298
+ // 已开启认证的用户、以及已经看过引导的用户一律不再打扰。
2299
+ // 目的:默认监听 0.0.0.0 且认证默认关闭时,让新用户在开箱状态被明确提醒去开门禁。
2300
+ service.firstRunGuidePending = authManager.enabled !== true && stored?.wizard?.guideShown !== true;
2056
2301
  if (stored?.externalTunnel) {
2057
2302
  service.externalTunnelConfig = stored.externalTunnel;
2058
2303
  }
@@ -2201,6 +2446,8 @@ function apply(ctx, config = {}) {
2201
2446
  telegram,
2202
2447
  platformManager,
2203
2448
  logger,
2449
+ // 运行中任务探测所需的引用:探测失败一律降级为「无任务」,不阻断重启
2450
+ runtime: { ctx, platformManager },
2204
2451
  saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
2205
2452
  const stored = await updateConfig((current) => {
2206
2453
  const prev = service.customTunnelConfig ?? {};
@@ -33,13 +33,30 @@ export { textOfAssistantMessage } from './message-split.js'
33
33
 
34
34
 
35
35
  function digestLine(session) {
36
+ const open = describeOpenTurn(session?.events)
37
+ if (!open) return null
38
+ const steps = open.tools > 0 ? `${open.tools} 次工具调用` : '思考中'
39
+ const last = open.lastTool ? ` | 最近: ${open.lastTool}` : ''
40
+ return `[处理中] 第 ${open.turn} 轮 | ${steps}${last}`
41
+ }
42
+
43
+ /**
44
+ * 判断会话是否有**未闭合**的 turn(即正在跑任务),返回结构化描述或 null。
45
+ * 依据:事件流里最后一次 turn/start 之后没有配对的 turn/end。
46
+ * 这是 Web/IM「正在思考 / 工具调用中」的同一判据;消息摘要与重启前探测共用它,
47
+ * 避免两处各写一套导致结论不一致。
48
+ *
49
+ * @param {Array} events 会话事件流(session.events)
50
+ * @returns {{turn:number, tools:number, lastTool:(string|undefined)}|null}
51
+ */
52
+ export function describeOpenTurn(events) {
36
53
  let turn = 0
37
54
  let tools = 0
38
55
  let lastTool = undefined
39
56
  let inTurn = false
40
- for (const event of session.events ?? []) {
57
+ for (const event of events ?? []) {
41
58
  if (event.type === 'turn/start') {
42
- turn = event.data.turn
59
+ turn = event.data?.turn ?? 0
43
60
  inTurn = true
44
61
  tools = 0
45
62
  lastTool = undefined
@@ -47,13 +64,11 @@ function digestLine(session) {
47
64
  inTurn = false
48
65
  } else if (event.type === 'tool/call' && inTurn) {
49
66
  tools += 1
50
- lastTool = event.data.name
67
+ lastTool = event.data?.name
51
68
  }
52
69
  }
53
70
  if (!inTurn || turn === 0) return null
54
- const steps = tools > 0 ? `${tools} 次工具调用` : '思考中'
55
- const last = lastTool ? ` | 最近: ${lastTool}` : ''
56
- return `[处理中] 第 ${turn} 轮 | ${steps}${last}`
71
+ return { turn, tools, lastTool }
57
72
  }
58
73
 
59
74
  function summarizeError(error) {
@@ -50,19 +50,73 @@ function peekCtxProperty(ctx, key) {
50
50
  try { return ctx[key] } catch { return undefined }
51
51
  }
52
52
 
53
- /** 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等) */
53
+ // 读取单个 per-record 投影缓存文件,返回其 record(消费方期望的形状)。
54
+ // 文件形状:{ version, record: { identity, rows } }
55
+ function readProjRecordFile(file) {
56
+ try {
57
+ const data = JSON.parse(readFileSync(file, 'utf8'))
58
+ const record = data?.record
59
+ if (record && typeof record === 'object') return record
60
+ } catch { /* 损坏/半写文件:视为无缓存(宿主自身也把该格式视为可丢弃的派生数据) */ }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ * 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等)。
66
+ *
67
+ * DSH 0.1.7 起 `session_projcache` 领域改为 **per-record 布局**:
68
+ * `<DSH_HOME>/storages/session_projcache/sessions/<sessionId>.json`(每条会话一个文件,
69
+ * 顶层 `{ version, record: { identity, rows } }`)。
70
+ * 0.1.6 及更早则是单文件 `<DSH_HOME>/storages/session_projcache.json`
71
+ * (`{ tables: { sessions: { <id>: { identity, rows } } } }`)。
72
+ * 见 @deepseek-ai/dsh-session-projection-cache 的 projectionCacheDomainSpec
73
+ * (`layout: 'per-record'`)。
74
+ *
75
+ * 两种布局都必须支持:只读旧路径会让升级到 0.1.7 的用户标题全部退化为「新会话」,
76
+ * 只读新路径则会让未升级用户回归。故此处返回一个**按 id 惰性读取**的代理对象:
77
+ * - 消费方(session-catalog)只按 sessionId 取值,从不遍历 keys,
78
+ * 因此无需为一次 /sessions 同步读取数百个文件(本机实测 470 个);
79
+ * - 形状与旧布局一致(直接给出 `{ identity, rows }`),消费方无需感知差异。
80
+ */
54
81
  export function getSessionProjCache(ctx) {
55
82
  const injected = peekCtxProperty(ctx, 'sessionProjCache')
56
83
  if (injected) return injected
57
- try {
58
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
59
- const cacheFile = join(home, 'storages', 'session_projcache.json')
60
- if (existsSync(cacheFile)) {
61
- const data = JSON.parse(readFileSync(cacheFile, 'utf8'))
62
- return data?.tables?.sessions || {}
63
- }
64
- } catch { /* ignore */ }
65
- return {}
84
+
85
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
86
+ const legacyFile = join(home, 'storages', 'session_projcache.json')
87
+ const recordDir = join(home, 'storages', 'session_projcache', 'sessions')
88
+
89
+ // 旧布局(0.1.6-):整份读入,形状已与消费方一致
90
+ const legacy = (() => {
91
+ try {
92
+ if (!existsSync(legacyFile)) return null
93
+ const data = JSON.parse(readFileSync(legacyFile, 'utf8'))
94
+ const sessions = data?.tables?.sessions
95
+ return sessions && typeof sessions === 'object' ? sessions : null
96
+ } catch { return null }
97
+ })()
98
+
99
+ // 新布局(0.1.7+):按 id 惰性读单文件;legacy 命中时优先用 legacy
100
+ const cache = new Map()
101
+ return new Proxy(legacy ?? {}, {
102
+ get(target, prop) {
103
+ if (typeof prop !== 'string') return Reflect.get(target, prop)
104
+ if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop]
105
+ if (cache.has(prop)) return cache.get(prop)
106
+ let record
107
+ try {
108
+ const file = join(recordDir, `${prop}.json`)
109
+ if (existsSync(file)) record = readProjRecordFile(file)
110
+ } catch { /* 读取失败视为无缓存 */ }
111
+ cache.set(prop, record)
112
+ return record
113
+ },
114
+ has(target, prop) {
115
+ if (typeof prop === 'string' && Object.prototype.hasOwnProperty.call(target, prop)) return true
116
+ if (typeof prop !== 'string') return Reflect.has(target, prop)
117
+ try { return existsSync(join(recordDir, `${prop}.json`)) } catch { return false }
118
+ },
119
+ })
66
120
  }
67
121
 
68
122
  /** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */