@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.
- package/CHANGELOG.md +76 -0
- package/README.en.md +25 -25
- package/README.md +25 -25
- package/client/client.js +49 -2
- package/client/index.js +54 -4
- package/lib/bridge-rpc-constants.js +2 -0
- package/lib/bridge-rpc.js +33 -2
- package/lib/index.js +263 -16
- package/lib/platform/conversation-bridge.js +21 -6
- package/lib/platform/dsh-storage.js +64 -10
- package/package.json +4 -5
- package/docs/CODE_REVIEW.md +0 -220
- package/docs/banner.jpg +0 -0
- package/docs/cloudflare-fixed-domain.md +0 -158
- package/docs/custom-tunnel.md +0 -220
- package/docs/feishu-usage.md +0 -116
- package/docs/qq-usage.md +0 -387
- package/docs/release-process.md +0 -45
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -0
- package/docs/screenshots/mobile-chat.jpg +0 -0
- package/docs/screenshots/mobile-drawer.jpg +0 -0
- package/docs/screenshots/mobile-remote-settings.jpg +0 -0
- package/docs/screenshots/mobile-settings-im.jpg +0 -0
- package/docs/screenshots/mobile-settings-lan.jpg +0 -0
- package/docs/screenshots/mobile-settings-security.jpg +0 -0
- package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
- package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
- package/docs/screenshots/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +0 -93
- 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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
1227
|
-
{ cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile',
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
57
|
+
for (const event of events ?? []) {
|
|
41
58
|
if (event.type === 'turn/start') {
|
|
42
|
-
turn = event.data
|
|
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
|
|
67
|
+
lastTool = event.data?.name
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
if (!inTurn || turn === 0) return null
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 列表 */
|