@wenbin_wb/dsh-bridge 2.11.3 → 2.12.0
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 +55 -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 +192 -11
- 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';
|
|
@@ -571,6 +572,27 @@ class ProxyServer {
|
|
|
571
572
|
return;
|
|
572
573
|
}
|
|
573
574
|
|
|
575
|
+
// 纵深防御:浏览器发起的升级请求若带 Origin 且不在已知面板来源内,拒绝握手,
|
|
576
|
+
// 防止恶意页面借用户浏览器已有的 Cookie 建立 WebSocket(CSWSH)。
|
|
577
|
+
// 认证已在上方完成,此处只做额外收紧,不构成绕过认证的路径。
|
|
578
|
+
// 关键兼容性约束:Origin 缺失一律放行——非浏览器客户端(IM 机器人、
|
|
579
|
+
// ws 库、命令行工具、隧道转发)通常不发 Origin,若按缺失拒绝会直接
|
|
580
|
+
// 打断现有用户的机器人通道。
|
|
581
|
+
const origin = req.headers?.origin;
|
|
582
|
+
if (origin) {
|
|
583
|
+
let allowed;
|
|
584
|
+
try {
|
|
585
|
+
allowed = this.allowedOrigins?.() ?? [];
|
|
586
|
+
} catch { allowed = []; }
|
|
587
|
+
if (!allowed.includes(origin)) {
|
|
588
|
+
// 直接内联 origin,避免依赖 logger 的 printf 插值(该 logger 在部分调用形态下不做替换)
|
|
589
|
+
this.logger?.warn?.(`dsh-bridge: 拒绝来源不明的 WebSocket 升级请求 (Origin=${origin})`);
|
|
590
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nForbidden\r\n');
|
|
591
|
+
socket.destroy();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
574
596
|
const headers = loopbackHeaders(req.headers, this.targetPort);
|
|
575
597
|
const proxyReq = httpRequest({
|
|
576
598
|
host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false,
|
|
@@ -666,9 +688,19 @@ class BridgeService {
|
|
|
666
688
|
this.onPersist = onPersist ?? null;
|
|
667
689
|
this.logger = logger;
|
|
668
690
|
|
|
691
|
+
// 运行中任务探测所需的运行时引用(由 apply 在构造后注入):
|
|
692
|
+
// ctx.sessions —— 枚举 live 会话及其事件流,判断是否有 turn 在跑
|
|
693
|
+
// platformManager —— 遍历各 IM 平台 bridge,取其 pending 审批
|
|
694
|
+
// 二者缺失时探测退化为「未检测到任务」,重启照常执行(不得因探测失败而阻断重启)。
|
|
695
|
+
this.runtimeCtx = null;
|
|
696
|
+
this.platformManager = null;
|
|
697
|
+
|
|
669
698
|
this.qrCache = new QrCache();
|
|
670
699
|
this.proxy = null;
|
|
671
700
|
|
|
701
|
+
// 首次启用引导:是否仍需要向用户展示「开门禁」提示(由 apply 的启动链按保守判据置位)
|
|
702
|
+
this.firstRunGuidePending = false;
|
|
703
|
+
|
|
672
704
|
this.customTunnel = null;
|
|
673
705
|
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
674
706
|
|
|
@@ -742,8 +774,17 @@ class BridgeService {
|
|
|
742
774
|
return this._dshVersion;
|
|
743
775
|
}
|
|
744
776
|
|
|
745
|
-
|
|
746
|
-
|
|
777
|
+
/**
|
|
778
|
+
* 标记「首次启用引导」已展示/已确认,持久化后不再打扰用户。
|
|
779
|
+
* 与访问认证状态解耦:即便用户仍未开启门禁,确认过后也不再重复弹窗。
|
|
780
|
+
*/
|
|
781
|
+
async dismissFirstRunGuide() {
|
|
782
|
+
this.firstRunGuidePending = false;
|
|
783
|
+
await this.onPersist?.({ wizard: { guideShown: true } });
|
|
784
|
+
return { ok: true };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async setLanIp({ ip } = {}) { const trimmed = ip ? String(ip).trim() : null;
|
|
747
788
|
this.selectedLanIp = trimmed || null;
|
|
748
789
|
await this.onPersist?.({ lan: { selectedIp: this.selectedLanIp } });
|
|
749
790
|
this.logger?.info('局域网选定 IP 更新为: %s', this.selectedLanIp || '自动推荐');
|
|
@@ -767,8 +808,17 @@ class BridgeService {
|
|
|
767
808
|
`http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
|
|
768
809
|
];
|
|
769
810
|
try {
|
|
770
|
-
|
|
771
|
-
|
|
811
|
+
// LAN 面板与回环同理需要同时覆盖 3082(代理)与 dshPort(DSH 原生端口直连):
|
|
812
|
+
// 浏览器从 LAN IP 直连原生端口时 Origin 是 http://<lanIp>:<dshPort>,
|
|
813
|
+
// 只配 proxyPort 会导致页面能打开但 WebSocket 升级被拒(实时通道静默失效)。
|
|
814
|
+
for (const iface of listAllLanIPv4()) {
|
|
815
|
+
origins.push(`http://${iface.address}:${this.proxyPort}`);
|
|
816
|
+
origins.push(`http://${iface.address}:${this.dshPort}`);
|
|
817
|
+
}
|
|
818
|
+
if (this.selectedLanIp) {
|
|
819
|
+
origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
|
|
820
|
+
origins.push(`http://${this.selectedLanIp}:${this.dshPort}`);
|
|
821
|
+
}
|
|
772
822
|
if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
|
|
773
823
|
if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
|
|
774
824
|
if (this.externalTunnelConfig?.url) origins.push(new URL(this.externalTunnelConfig.url).origin);
|
|
@@ -818,6 +868,13 @@ class BridgeService {
|
|
|
818
868
|
|
|
819
869
|
auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
|
|
820
870
|
|
|
871
|
+
// 首次启用引导:为 true 时面板应提示用户开启访问门禁(默认 0.0.0.0 且认证默认关闭)
|
|
872
|
+
firstRunGuide: {
|
|
873
|
+
pending: Boolean(this.firstRunGuidePending),
|
|
874
|
+
// 当前是否处于「监听 0.0.0.0 但认证未开启」的开箱敞开状态
|
|
875
|
+
exposedWithoutAuth: this.authManager?.enabled !== true,
|
|
876
|
+
},
|
|
877
|
+
|
|
821
878
|
proxy: {
|
|
822
879
|
running: !!this.proxy,
|
|
823
880
|
port: this.proxyPort,
|
|
@@ -1113,9 +1170,13 @@ class BridgeService {
|
|
|
1113
1170
|
try {
|
|
1114
1171
|
dshRealPath = realpathSync(dshBin) || dshBin;
|
|
1115
1172
|
} catch { /* 保留原始路径继续判断 */ }
|
|
1116
|
-
//
|
|
1173
|
+
// npm root -g 返回的是 prefix 下的 node_modules(如 /prefix/lib/node_modules),
|
|
1174
|
+
// 不能直接 dirname 后再作为 --prefix,否则会变成 /prefix/lib,实际安装到
|
|
1175
|
+
// /prefix/lib/lib/node_modules,dsh 命令仍会加载旧的 /prefix/lib/node_modules。
|
|
1176
|
+
// npm prefix -g 才是可传给 --prefix 的真实全局安装前缀。
|
|
1177
|
+
const globalPrefix = await runCmd('npm', ['prefix', '-g']);
|
|
1117
1178
|
const globalNodeModules = await runCmd('npm', ['root', '-g']);
|
|
1118
|
-
const rootGlobal = globalNodeModules ? dirname(globalNodeModules) : dirname(nodeDir);
|
|
1179
|
+
const rootGlobal = globalPrefix || (globalNodeModules ? dirname(dirname(globalNodeModules)) : dirname(nodeDir));
|
|
1119
1180
|
const marker = join('node_modules', '@deepseek-ai', 'dsh');
|
|
1120
1181
|
if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
|
|
1121
1182
|
return { upgradable: true, rootGlobal, dshRealPath };
|
|
@@ -1272,7 +1333,8 @@ class BridgeService {
|
|
|
1272
1333
|
|
|
1273
1334
|
// 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
|
|
1274
1335
|
// 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
|
|
1275
|
-
|
|
1336
|
+
// opts.spawnImpl 供测试注入,生产环境用 node:child_process 的 spawn。
|
|
1337
|
+
async upgradeDsh({ version, spawnImpl = spawn } = {}) {
|
|
1276
1338
|
const targetVersion = version ? String(version).trim() : 'latest';
|
|
1277
1339
|
// 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
|
|
1278
1340
|
if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
|
|
@@ -1331,7 +1393,7 @@ class BridgeService {
|
|
|
1331
1393
|
const res = await new Promise((resolve, reject) => {
|
|
1332
1394
|
let cp;
|
|
1333
1395
|
try {
|
|
1334
|
-
cp =
|
|
1396
|
+
cp = spawnImpl(task.cmd, task.args, {
|
|
1335
1397
|
windowsHide: true,
|
|
1336
1398
|
shell: true,
|
|
1337
1399
|
env: augmentedEnv,
|
|
@@ -1352,6 +1414,27 @@ class BridgeService {
|
|
|
1352
1414
|
});
|
|
1353
1415
|
const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
|
|
1354
1416
|
this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
|
|
1417
|
+
|
|
1418
|
+
// 升级成功后清理版本缓存,让 getDshVersion/checkVersion 下次重新探测
|
|
1419
|
+
this._dshVersion = null;
|
|
1420
|
+
this._dshVersionLoaded = false;
|
|
1421
|
+
this._versionCheckCache = null;
|
|
1422
|
+
|
|
1423
|
+
// 核验实际 dsh 版本:npm 命令成功不等于正在使用的 dsh 已经变新。
|
|
1424
|
+
// 如果探测到的版本号仍然是旧版(或探测失败),则安装可能装到了错误位置。
|
|
1425
|
+
const actualVersion = await this.getDshVersion();
|
|
1426
|
+
if (actualVersion && targetVersion !== 'latest') {
|
|
1427
|
+
// 用户指定了具体版本号(如 0.2.0-rc.2):安装后 dsh --version 必须精确匹配
|
|
1428
|
+
if (actualVersion !== targetVersion) {
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
error: `npm 安装成功但 dsh 命令实际版本仍为 ${actualVersion}(预期 ${targetVersion})。可能安装到了错误位置,请手动执行 \`npm install -g @deepseek-ai/dsh@${targetVersion}\` 并重启 DSH。`,
|
|
1432
|
+
version: targetVersion,
|
|
1433
|
+
installedButNotActive: true,
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1355
1438
|
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
1356
1439
|
} catch (err) {
|
|
1357
1440
|
lastError = err;
|
|
@@ -1368,6 +1451,65 @@ class BridgeService {
|
|
|
1368
1451
|
// - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
|
|
1369
1452
|
// 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
|
|
1370
1453
|
// 因此这里先识别托管方式:
|
|
1454
|
+
// 探测「重启会打断哪些正在跑的任务」。
|
|
1455
|
+
//
|
|
1456
|
+
// 判据(均为可从宿主运行时直接读到的信号,不依赖任何新增宿主 API):
|
|
1457
|
+
// 1. 主会话进行中的 turn:事件流里存在未配对的 turn/start(无对应 turn/end)。
|
|
1458
|
+
// 这是 Web/IM 两端「正在思考/工具调用中」的同一依据
|
|
1459
|
+
// (见 lib/platform/conversation-bridge.js 的 digestLine)。
|
|
1460
|
+
// 2. 子代理/agent 会话:会话 header.origin === 'subagent',同样按未闭合 turn 判断在跑。
|
|
1461
|
+
// 嵌套委托最容易被遗忘,重启损失也最大。
|
|
1462
|
+
// 3. 待审批请求:各 IM 平台 bridge 的 pending(用户尚未在机器人里点「同意/拒绝」)。
|
|
1463
|
+
// 宿主 ctx.approval 只提供 request(),无查询 API,故以插件自身登记的 pending 为准。
|
|
1464
|
+
//
|
|
1465
|
+
// 任何一步探测失败都必须降级为「该项无任务」并继续,绝不因此阻断重启:
|
|
1466
|
+
// 重启是救急能力,不能被探测逻辑锁死。
|
|
1467
|
+
getActiveWork() {
|
|
1468
|
+
const work = { sessions: [], subagentSessions: [], pendingApprovals: [], total: 0 };
|
|
1469
|
+
|
|
1470
|
+
// 1 + 2:遍历 live 会话,按未闭合 turn 判断是否在跑
|
|
1471
|
+
try {
|
|
1472
|
+
const list = this.runtimeCtx?.sessions?.list?.() ?? [];
|
|
1473
|
+
for (const s of list) {
|
|
1474
|
+
if (!s || !s.id) continue;
|
|
1475
|
+
const isSubagent = s.origin === 'subagent' || s.header?.origin === 'subagent';
|
|
1476
|
+
const turn = describeOpenTurn(s.events);
|
|
1477
|
+
if (!turn) continue;
|
|
1478
|
+
const entry = {
|
|
1479
|
+
id: s.id,
|
|
1480
|
+
title: s.title || (s.header?.cwd ? basename(s.header.cwd) : '') || '未命名会话',
|
|
1481
|
+
turn: turn.turn,
|
|
1482
|
+
tools: turn.tools,
|
|
1483
|
+
lastTool: turn.lastTool,
|
|
1484
|
+
};
|
|
1485
|
+
if (isSubagent) work.subagentSessions.push(entry);
|
|
1486
|
+
else work.sessions.push(entry);
|
|
1487
|
+
}
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
this.logger?.debug?.('dsh-bridge: 探测进行中会话失败(视为无任务):%s', err?.message ?? err);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// 3:各 IM 平台的待审批请求
|
|
1493
|
+
try {
|
|
1494
|
+
for (const platform of this.platformManager?.list?.() ?? []) {
|
|
1495
|
+
const pending = platform?.bridge?.pending;
|
|
1496
|
+
if (!pending || typeof pending.forEach !== 'function') continue;
|
|
1497
|
+
pending.forEach((entry, number) => {
|
|
1498
|
+
work.pendingApprovals.push({
|
|
1499
|
+
platform: platform?.id ?? 'unknown',
|
|
1500
|
+
number,
|
|
1501
|
+
summary: entry?.summary || entry?.title || entry?.tool || '待审批请求',
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
this.logger?.debug?.('dsh-bridge: 探测待审批请求失败(视为无)):%s', err?.message ?? err);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
work.total = work.sessions.length + work.subagentSessions.length + work.pendingApprovals.length;
|
|
1510
|
+
return work;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1371
1513
|
// - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
|
|
1372
1514
|
// 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
|
|
1373
1515
|
// - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
|
|
@@ -1962,9 +2104,42 @@ function apply(ctx, config = {}) {
|
|
|
1962
2104
|
}
|
|
1963
2105
|
}
|
|
1964
2106
|
|
|
2107
|
+
// config.json 含平台 Token / 隧道凭据 / 访问密码哈希,属同机敏感文件:
|
|
2108
|
+
// 落盘固定 0600,与 sessions.json(lib/auth/manager.js)保持一致。
|
|
2109
|
+
const CONFIG_FILE_MODE = 0o600;
|
|
2110
|
+
|
|
1965
2111
|
async function writeConfig(data) {
|
|
1966
2112
|
await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
|
|
1967
|
-
|
|
2113
|
+
// mode 仅对新建文件生效;已存在的旧文件(历史版本落在 0644)由
|
|
2114
|
+
// ensureConfigFileMode 在启动时收敛,两者配合覆盖新建与存量两种情形。
|
|
2115
|
+
await writeFile(configFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// 存量收敛:writeFile 的 mode 不会修改已存在文件的权限位,历史版本已把
|
|
2119
|
+
// config.json 写成 0644(同机其他用户可读)。此处幂等收紧到 0600。
|
|
2120
|
+
// 失败仅告警、不阻断启动(Windows 等平台 chmod 语义不同,且权限收紧
|
|
2121
|
+
// 不应成为插件不可用的原因)。
|
|
2122
|
+
async function ensureConfigFileMode() {
|
|
2123
|
+
try {
|
|
2124
|
+
await access(configFile);
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
// 仅「文件不存在」属预期(尚未写入,写入时会带上正确 mode)。
|
|
2127
|
+
// 其余错误(EACCES/EIO 等)意味着我们无法确认权限状态,必须出声,
|
|
2128
|
+
// 否则旧版遗留的宽松权限会被静默放过。
|
|
2129
|
+
if (err?.code !== 'ENOENT') {
|
|
2130
|
+
logger.warn('dsh-bridge: 无法检查 config.json 权限状态,跳过收紧:%s', err?.message ?? err);
|
|
2131
|
+
}
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
try {
|
|
2135
|
+
const current = await stat(configFile);
|
|
2136
|
+
const tightened = current.mode & 0o777;
|
|
2137
|
+
if (tightened === CONFIG_FILE_MODE) return;
|
|
2138
|
+
await chmod(configFile, CONFIG_FILE_MODE);
|
|
2139
|
+
logger.info('dsh-bridge: 已将 config.json 权限从 %s 收敛为 0600', tightened.toString(8).padStart(4, '0'));
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
logger.warn('dsh-bridge: 收紧 config.json 权限失败(不影响运行):%s', err?.message ?? err);
|
|
2142
|
+
}
|
|
1968
2143
|
}
|
|
1969
2144
|
|
|
1970
2145
|
// 整对象写入(同样入队,避免与进行中的事务交错)
|
|
@@ -2022,7 +2197,7 @@ function apply(ctx, config = {}) {
|
|
|
2022
2197
|
}
|
|
2023
2198
|
|
|
2024
2199
|
// 启动时读取已保存的 auth 配置并执行保命标记检查
|
|
2025
|
-
checkEmergencyReset().then(() => loadConfig()).then((stored) => {
|
|
2200
|
+
checkEmergencyReset().then(() => ensureConfigFileMode()).then(() => loadConfig()).then((stored) => {
|
|
2026
2201
|
if (stored?.auth) {
|
|
2027
2202
|
if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
|
|
2028
2203
|
if (stored.auth.mode) authManager.mode = stored.auth.mode;
|
|
@@ -2053,6 +2228,10 @@ function apply(ctx, config = {}) {
|
|
|
2053
2228
|
|
|
2054
2229
|
// 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
|
|
2055
2230
|
loadConfig().then(async (stored) => {
|
|
2231
|
+
// 首次启用引导判据(保守):仅当「访问认证未开启」且「从未展示过引导」时才提示。
|
|
2232
|
+
// 已开启认证的用户、以及已经看过引导的用户一律不再打扰。
|
|
2233
|
+
// 目的:默认监听 0.0.0.0 且认证默认关闭时,让新用户在开箱状态被明确提醒去开门禁。
|
|
2234
|
+
service.firstRunGuidePending = authManager.enabled !== true && stored?.wizard?.guideShown !== true;
|
|
2056
2235
|
if (stored?.externalTunnel) {
|
|
2057
2236
|
service.externalTunnelConfig = stored.externalTunnel;
|
|
2058
2237
|
}
|
|
@@ -2201,6 +2380,8 @@ function apply(ctx, config = {}) {
|
|
|
2201
2380
|
telegram,
|
|
2202
2381
|
platformManager,
|
|
2203
2382
|
logger,
|
|
2383
|
+
// 运行中任务探测所需的引用:探测失败一律降级为「无任务」,不阻断重启
|
|
2384
|
+
runtime: { ctx, platformManager },
|
|
2204
2385
|
saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
|
|
2205
2386
|
const stored = await updateConfig((current) => {
|
|
2206
2387
|
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 列表 */
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.
|
|
5
|
+
"releaseNotes": "【v2.12.0】安全加固 + 首次启用引导 + 修复 IM 标题 + 重启打断保护 + 发布包瘦身 88%\n• 修复:IM /sessions 会话标题全为「新会话」——DSH 0.1.7 把 session_projcache 改为 per-record 目录,插件仍读旧单文件路径;现兼容两种布局\n• 安全:config.json 与微信 context token 落盘收紧为 0600(此前 0644 同机可读,含明文隧道凭据),存量文件启动时自动收敛\n• 安全:WebSocket 升级补齐 Origin 校验(防恶意页面借浏览器 Cookie 建连);Origin 缺失一律放行,不影响 IM 机器人等非浏览器客户端\n• 修复:局域网直连原生 3080 端口时 WebSocket 被误拒、实时通道静默失效\n• 新功能:首次启用引导——检测到「监听 0.0.0.0 但未开启访问认证」时提示开门禁,已配置用户不打扰\n• 新功能:重启打断保护——有任务在跑(含子代理、待审批)时弹二次确认并列出具体现场,避免静默打断\n• 修复:一键升级 DSH 显示成功但实际仍是旧版本——prefix 错位(装到 lib/lib/node_modules)改用 npm prefix -g;升级后核验 dsh --version,不匹配则报错而非报喜\n• 优化:文档配图转 WebP 并移出发布包,tarball 7.63MB → 0.87MB\n• 说明:默认监听地址保持 0.0.0.0 不变,不破坏现有部署;权限与校验改动重启 dsh 后生效",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
"README.md",
|
|
22
22
|
"README.en.md",
|
|
23
23
|
"CHANGELOG.md",
|
|
24
|
-
"LICENSE"
|
|
25
|
-
"docs"
|
|
24
|
+
"LICENSE"
|
|
26
25
|
],
|
|
27
26
|
"scripts": {
|
|
28
27
|
"build:client": "node client/build.mjs",
|
|
@@ -106,4 +105,4 @@
|
|
|
106
105
|
"access": "public",
|
|
107
106
|
"registry": "https://registry.npmjs.org/"
|
|
108
107
|
}
|
|
109
|
-
}
|
|
108
|
+
}
|