@wenbin_wb/dsh-bridge 2.11.2 → 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 +76 -0
- package/README.en.md +25 -25
- package/README.md +25 -25
- package/client/client.js +235 -10
- package/client/index.js +124 -12
- package/client/locales.mjs +174 -0
- 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/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +6 -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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// dsh-bridge 客户端国际化:跟随 DSH 主体语言(LocaleFace)。
|
|
2
|
+
//
|
|
3
|
+
// DSH 0.1.7 的 client 提供 locale 服务(`dsh-client-locale` 的 LocaleFace):
|
|
4
|
+
// - ctx.locale.register(namespace, { zh: {...}, en: {...} }) 注册词典;
|
|
5
|
+
// - ctx.locale.translate(map) 按当前语言取译文(纯字符串原样返回、map 走词典,
|
|
6
|
+
// 未声明语言回退到 fallback 链,最终到 en);
|
|
7
|
+
// - ctx.locale.subscribe(fn) 在语言切换时通知。
|
|
8
|
+
// 插件把 "locale" 加进 inject 后即可拿到 ctx.locale,从而自动跟随 DSH 主体语言
|
|
9
|
+
// (含用户手动切换、浏览器语言回退、<html lang> 同步),无需自建语言开关。
|
|
10
|
+
//
|
|
11
|
+
// 本文件只收「移动端自绘 UI 的用户可见文案」(A 类):顶栏按钮、工作区选择器、
|
|
12
|
+
// 远程访问面板、浏览器输入区提示等。内部字符串(DOM/CSS 类名匹配、注释、日志)
|
|
13
|
+
// 保持中文原样——它们不是 UI 文案,翻译会破坏匹配(见 readHostSessionTitle 教训)。
|
|
14
|
+
|
|
15
|
+
export const LOCALE_NAMESPACE = '@wenbin_wb/dsh-bridge';
|
|
16
|
+
|
|
17
|
+
/** 词典:每个命名空间至少要有 en(最终 fallback);zh 为默认语言。 */
|
|
18
|
+
export const zh = {
|
|
19
|
+
// 移动端顶栏
|
|
20
|
+
'menu.open': '打开菜单',
|
|
21
|
+
'menu.newSession': '新建会话',
|
|
22
|
+
'title.fallback': '新会话',
|
|
23
|
+
// 移动端输入区
|
|
24
|
+
'composer.placeholder': '✍️ 点击输入消息…',
|
|
25
|
+
'composer.expand': '展开输入框',
|
|
26
|
+
'composer.collapse': '收起输入框,最大化对话阅读区',
|
|
27
|
+
'panel.backToChat': '返回对话',
|
|
28
|
+
// 移动端设置面板 / 抽屉(dsh-bridge 自绘部分)
|
|
29
|
+
'workspace.choose': '选择电脑工作区',
|
|
30
|
+
'workspace.reading': '正在读取目录内容…',
|
|
31
|
+
'workspace.currentDir': '当前目录:',
|
|
32
|
+
'workspace.registeredHint': '已注册工作区 (${count} 个,点击直接切换):',
|
|
33
|
+
'workspace.enterHint': '点击进入文件夹,或点击「+ 选为工作区」直接添加并切换',
|
|
34
|
+
'workspace.unlockHint': '远程访问时浏览/添加工作区需输入后台管理密码解锁(与访问密码不同)。',
|
|
35
|
+
'workspace.chooseFolder': '选择工作区',
|
|
36
|
+
'workspace.addFolder': '添加',
|
|
37
|
+
'workspace.refresh': '刷新目录',
|
|
38
|
+
'workspace.back': '返回上一级',
|
|
39
|
+
'workspace.filter': '过滤子文件夹…',
|
|
40
|
+
'workspace.more': '还有更多',
|
|
41
|
+
'workspace.addSubfolder': '直接添加此子文件夹为工作区并进入',
|
|
42
|
+
'workspace.onlyAdmin': '仅限本机管理',
|
|
43
|
+
'workspace.isCurrent': '当前主入口',
|
|
44
|
+
'workspace.isFallback': '主入口',
|
|
45
|
+
// 远程访问面板
|
|
46
|
+
'access.copyLink': '复制链接',
|
|
47
|
+
'access.copied': '✓ 已复制',
|
|
48
|
+
'access.hideQr': '隐藏二维码',
|
|
49
|
+
'access.showQr': '显示二维码',
|
|
50
|
+
'access.privateEnv': '请在私密环境下使用',
|
|
51
|
+
'access.switching': '切换中…',
|
|
52
|
+
'access.publicEntry': '公网访问入口',
|
|
53
|
+
'access.copy': '复制',
|
|
54
|
+
'access.stopReconnect': '停止重连',
|
|
55
|
+
'access.close': '关闭',
|
|
56
|
+
'access.connecting': '连接中…',
|
|
57
|
+
'access.downloading': '下载中…',
|
|
58
|
+
'access.enable': '开启公网隧道',
|
|
59
|
+
'access.scanHint': '请在私密环境下扫码使用',
|
|
60
|
+
'access.autoStart': '随 DSH 启动自动开启',
|
|
61
|
+
'access.tunnelConfig': '隧道配置',
|
|
62
|
+
'access.expand': '展开 ▾',
|
|
63
|
+
'access.collapse': '收起 ▴',
|
|
64
|
+
'access.saveError': '保存配置失败',
|
|
65
|
+
'access.serverConfig': '隧道服务器配置',
|
|
66
|
+
'access.saving': '保存中…',
|
|
67
|
+
'access.saved': '✓ 已保存',
|
|
68
|
+
'access.saveConfig': '保存配置',
|
|
69
|
+
'access.clear': '清除',
|
|
70
|
+
'access.externalTunnel': '外部已部署隧道',
|
|
71
|
+
'access.saveFixed': '保存固定域名配置',
|
|
72
|
+
'access.updateFailed': '更新失败',
|
|
73
|
+
'access.securityHint': '设置外部访客访问密码',
|
|
74
|
+
'access.goSetup': '去开启 ➔',
|
|
75
|
+
'access.gotoSettings': '设置 ➔',
|
|
76
|
+
'access.tutorial': '查看自建隧道服务器搭建教程',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const en = {
|
|
80
|
+
'menu.open': 'Open menu',
|
|
81
|
+
'menu.newSession': 'New session',
|
|
82
|
+
'title.fallback': 'New Session',
|
|
83
|
+
'composer.placeholder': '✍️ Tap to type a message…',
|
|
84
|
+
'composer.expand': 'Expand input',
|
|
85
|
+
'composer.collapse': 'Collapse input to maximize chat view',
|
|
86
|
+
'panel.backToChat': 'Back to chat',
|
|
87
|
+
'workspace.choose': 'Choose a workspace',
|
|
88
|
+
'workspace.reading': 'Reading directory…',
|
|
89
|
+
'workspace.currentDir': 'Current directory:',
|
|
90
|
+
'workspace.registeredHint': 'Registered workspaces (${count}, click to switch):',
|
|
91
|
+
'workspace.enterHint': 'Enter a folder, or tap “+ Select as workspace” to add & switch',
|
|
92
|
+
'workspace.unlockHint': 'Browsing/adding workspaces remotely requires the admin password to unlock (different from the access password).',
|
|
93
|
+
'workspace.chooseFolder': 'Choose workspace',
|
|
94
|
+
'workspace.addFolder': 'Add',
|
|
95
|
+
'workspace.refresh': 'Refresh',
|
|
96
|
+
'workspace.back': 'Back',
|
|
97
|
+
'workspace.filter': 'Filter subfolders…',
|
|
98
|
+
'workspace.more': 'More',
|
|
99
|
+
'workspace.addSubfolder': 'Add this subfolder as a workspace and enter',
|
|
100
|
+
'workspace.onlyAdmin': 'Local admin only',
|
|
101
|
+
'workspace.isCurrent': 'Current entry',
|
|
102
|
+
'workspace.isFallback': 'Main entry',
|
|
103
|
+
'access.copyLink': 'Copy link',
|
|
104
|
+
'access.copied': '✓ Copied',
|
|
105
|
+
'access.hideQr': 'Hide QR',
|
|
106
|
+
'access.showQr': 'Show QR',
|
|
107
|
+
'access.privateEnv': 'Use in a private environment',
|
|
108
|
+
'access.switching': 'Switching…',
|
|
109
|
+
'access.publicEntry': 'Public access entry',
|
|
110
|
+
'access.copy': 'Copy',
|
|
111
|
+
'access.stopReconnect': 'Stop reconnecting',
|
|
112
|
+
'access.close': 'Close',
|
|
113
|
+
'access.connecting': 'Connecting…',
|
|
114
|
+
'access.downloading': 'Downloading…',
|
|
115
|
+
'access.enable': 'Enable public tunnel',
|
|
116
|
+
'access.scanHint': 'Scan in a private environment',
|
|
117
|
+
'access.autoStart': 'Start automatically with DSH',
|
|
118
|
+
'access.tunnelConfig': 'Tunnel configuration',
|
|
119
|
+
'access.expand': 'Expand ▾',
|
|
120
|
+
'access.collapse': 'Collapse ▴',
|
|
121
|
+
'access.saveError': 'Failed to save configuration',
|
|
122
|
+
'access.serverConfig': 'Tunnel server configuration',
|
|
123
|
+
'access.saving': 'Saving…',
|
|
124
|
+
'access.saved': '✓ Saved',
|
|
125
|
+
'access.saveConfig': 'Save configuration',
|
|
126
|
+
'access.clear': 'Clear',
|
|
127
|
+
'access.externalTunnel': 'External tunnel',
|
|
128
|
+
'access.saveFixed': 'Save fixed-domain configuration',
|
|
129
|
+
'access.updateFailed': 'Update failed',
|
|
130
|
+
'access.securityHint': 'Set an external visitor access password',
|
|
131
|
+
'access.goSetup': 'Set up ➔',
|
|
132
|
+
'access.gotoSettings': 'Settings ➔',
|
|
133
|
+
'access.tutorial': 'View the self-hosted tunnel server tutorial',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 构造一个翻译函数:优先用宿主的 ctx.locale.translate(跟随主体语言),
|
|
138
|
+
* 宿主不支持时退化为「取当前语言词典(en/zh)+ 互为兜底」。
|
|
139
|
+
* @param {object} ctx - Cordis 客户端上下文(含可选 locale 服务)
|
|
140
|
+
* @returns {{ t: Function, active: () => string, subscribe: Function }}
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* 构造翻译器:读 DSH 主体语言(`<html lang>`,宿主 LocaleFace 实时同步),
|
|
144
|
+
* 中英词典按当前语言选词。`ctx.locale` 只作可选的词典注册(拿到则注册,
|
|
145
|
+
* 拿不到或未声明 inject 也不抛错、不影响翻译——动态插件无法把 locale 加进
|
|
146
|
+
* inject,宿主会对未声明服务抛 denyRead,详见 index.js 注释)。
|
|
147
|
+
* @param {object} ctx - Cordis 客户端上下文(可选)
|
|
148
|
+
* @returns {{ t: Function, active: () => string }}
|
|
149
|
+
*/
|
|
150
|
+
export function createTranslator(ctx) {
|
|
151
|
+
// 当前语言:宿主把 active 语言同步到 <html lang>(zh→zh-CN,其余原样)。
|
|
152
|
+
// 读它即可跟随 DSH 主体语言且无需声明 locale 服务。
|
|
153
|
+
const active = () => {
|
|
154
|
+
const lang = (typeof document !== 'undefined' ? document.documentElement.getAttribute('lang') : '') || '';
|
|
155
|
+
return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
|
156
|
+
};
|
|
157
|
+
const entryOf = (key) => ({ zh: zh[key], en: en[key] });
|
|
158
|
+
const t = (key, vars) => {
|
|
159
|
+
const e = entryOf(key);
|
|
160
|
+
let text = e[active()] ?? e.en ?? e.zh ?? '';
|
|
161
|
+
if (vars && text) {
|
|
162
|
+
for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`\${${k}}`, String(v));
|
|
163
|
+
}
|
|
164
|
+
return text;
|
|
165
|
+
};
|
|
166
|
+
// 可选:向宿主注册词典(仅当其有 register 且可访问时;失败无妨)
|
|
167
|
+
try {
|
|
168
|
+
const locale = ctx && (typeof ctx.get === 'function' ? ctx.get('locale') : ctx.locale);
|
|
169
|
+
if (locale && typeof locale.register === 'function') {
|
|
170
|
+
locale.register(LOCALE_NAMESPACE, { zh, en });
|
|
171
|
+
}
|
|
172
|
+
} catch { /* 动态插件的 locale 服务被宿主门控时忽略 */ }
|
|
173
|
+
return { t, active };
|
|
174
|
+
}
|
|
@@ -32,6 +32,8 @@ export const BRIDGE_ENDPOINTS = {
|
|
|
32
32
|
authRegenerateToken: 'authRegenerateToken',
|
|
33
33
|
authAdminUnlock: 'authAdminUnlock',
|
|
34
34
|
authAdminLock: 'authAdminLock',
|
|
35
|
+
// 首次启用引导:用户确认已了解后置位,避免重复打扰
|
|
36
|
+
dismissFirstRunGuide: 'dismissFirstRunGuide',
|
|
35
37
|
// 平台管理器(多 IM 平台统一接口)
|
|
36
38
|
listPlatforms: 'listPlatforms',
|
|
37
39
|
platformLogin: 'platformLogin',
|
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) {
|