dsh-remote-web 0.7.10-beta.3 → 0.7.12-beta.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/lib/client.js +1377 -292
- package/lib/index.js +419 -12
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +220 -1
- package/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/runtime/clients/dsh-remote/push-reporter.mjs +54 -0
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/runtime/clients/dsh-remote/wecom-bot.mjs +2670 -0
- package/runtime/dsh-setup.mjs +95 -9
package/lib/index.js
CHANGED
|
@@ -210,6 +210,38 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
|
|
|
210
210
|
return upstreamDiscoveryInFlight;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 上游地址是否已经"够用"(不需要在**响应路径里现扫**)。
|
|
215
|
+
* 【B1 · 2026-11-07】登录/首屏慢的主因之一就是在响应路径里 await 端口发现。
|
|
216
|
+
* 只要下面任一成立就够用:显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离 / 平台模拟。
|
|
217
|
+
*/
|
|
218
|
+
function upstreamDiscoverySettled() {
|
|
219
|
+
// 测试钩子**最优先**(true=强制"未 settled",false=强制"已 settled";null=不干预):
|
|
220
|
+
// DSH 宿主里跑测试时环境常带 DSH_WEB_URL(会被 boundWebPort 认成"已知端口"),
|
|
221
|
+
// 不放在最前面就没法模拟"端口未知、需要发现"的现场。
|
|
222
|
+
if (upstreamForceUnsettled === true) return false;
|
|
223
|
+
if (upstreamForceUnsettled === false) return true;
|
|
224
|
+
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return true;
|
|
225
|
+
if (boundWebPort()) return true;
|
|
226
|
+
if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return true;
|
|
227
|
+
if (skipsSystemOps()) return true;
|
|
228
|
+
if (osPlatform() !== process.platform) return true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 测试钩子:替换发现实现 / 强制"未 settled"(只给用例用,生产永远走真实实现)。 */
|
|
233
|
+
let discoverImpl = discoverUpstreamSelf;
|
|
234
|
+
let upstreamForceUnsettled = null;
|
|
235
|
+
function setUpstreamTestState(opts = {}) {
|
|
236
|
+
if (typeof opts.discover === "function") discoverImpl = opts.discover;
|
|
237
|
+
if (typeof opts.unsettled === "boolean") upstreamForceUnsettled = opts.unsettled;
|
|
238
|
+
return { discover: discoverImpl, unsettled: upstreamForceUnsettled };
|
|
239
|
+
}
|
|
240
|
+
function resetUpstreamTestState() {
|
|
241
|
+
discoverImpl = discoverUpstreamSelf;
|
|
242
|
+
upstreamForceUnsettled = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
/**
|
|
214
246
|
* 把上游地址落盘(`<relayDir>/.dsh-upstream`),供**没有环境变量**的那些守护读取:
|
|
215
247
|
* 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
|
|
@@ -1461,7 +1493,7 @@ function writeLinuxUnit(relayDir) {
|
|
|
1461
1493
|
if (!unitPath) return null;
|
|
1462
1494
|
const setup = join(relayDir, "dsh-setup.mjs");
|
|
1463
1495
|
const unit = `[Unit]
|
|
1464
|
-
Description=dsh-remote bridge (auto-starts with dsh web)
|
|
1496
|
+
Description=dsh-remote-web bridge (auto-starts with dsh web)
|
|
1465
1497
|
|
|
1466
1498
|
[Service]
|
|
1467
1499
|
ExecStart=${NODE_BIN} ${setup} run
|
|
@@ -1648,6 +1680,31 @@ function launchTarget() {
|
|
|
1648
1680
|
*/
|
|
1649
1681
|
const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
1650
1682
|
|
|
1683
|
+
// ── B1:shell 探测短 TTL 缓存(2026-11-07 登录慢优化)─────────────────────────────
|
|
1684
|
+
// launchctl / ps / systemctl 每次调用都是一次进程 spawn(实测 30~120ms,慢机器更久),
|
|
1685
|
+
// 而状态轮询 2.5s 一次会反复重跑同一份"没变"的事实。这里给 10s TTL:
|
|
1686
|
+
// · 结论按 key 缓存;**变更动作(启动/停止/卸载/账号切换)会主动清缓存**,不会让面板显示过期状态。
|
|
1687
|
+
const STATUS_PROBE_TTL_MS = 10 * 1000;
|
|
1688
|
+
const statusProbeCache = new Map();
|
|
1689
|
+
function memoStatusProbe(key, fn) {
|
|
1690
|
+
// 测试/诊断隔离下**不缓存**:这类环境里 shell 探测是假命令/假结果(skipsSystemOps 或 DSH_TEST_*
|
|
1691
|
+
// 注入的 fake PATH),缓存只会让用例互相污染(一个用例伪造的 pid/服务状态被下一个用例读到 → 假红)。
|
|
1692
|
+
// 生产路径(真实 launchctl/ps)才走 10s TTL。
|
|
1693
|
+
if (skipsSystemOps()) return fn();
|
|
1694
|
+
try {
|
|
1695
|
+
if (Object.keys(process.env).some((k) => k.startsWith("DSH_TEST_"))) return fn();
|
|
1696
|
+
} catch { /* 忽略 */ }
|
|
1697
|
+
const now = Date.now();
|
|
1698
|
+
const hit = statusProbeCache.get(key);
|
|
1699
|
+
if (hit && now - hit.at < STATUS_PROBE_TTL_MS) return hit.value;
|
|
1700
|
+
const value = fn();
|
|
1701
|
+
statusProbeCache.set(key, { at: now, value });
|
|
1702
|
+
return value;
|
|
1703
|
+
}
|
|
1704
|
+
function clearStatusProbeCache() {
|
|
1705
|
+
try { statusProbeCache.clear(); } catch { /* 忽略 */ }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1651
1708
|
function launchdStatus() {
|
|
1652
1709
|
// 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
|
|
1653
1710
|
// 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
|
|
@@ -2655,6 +2712,7 @@ function stopBridgeDetached(relayDir, opts = {}) {
|
|
|
2655
2712
|
|
|
2656
2713
|
/** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
2657
2714
|
function startBridge(relayDir) {
|
|
2715
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2658
2716
|
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
2659
2717
|
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
2660
2718
|
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
@@ -2872,6 +2930,7 @@ const lastSelfhealReadyLine = new Map();
|
|
|
2872
2930
|
|
|
2873
2931
|
/** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
|
|
2874
2932
|
function stopBridge(relayDir) {
|
|
2933
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2875
2934
|
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
2876
2935
|
// 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
|
|
2877
2936
|
// 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
|
|
@@ -4008,8 +4067,9 @@ function maskPhone(p) {
|
|
|
4008
4067
|
|
|
4009
4068
|
async function composeStatus(relayDir) {
|
|
4010
4069
|
const cfg = loadConfig(relayDir);
|
|
4011
|
-
|
|
4012
|
-
const
|
|
4070
|
+
// B1:同样的 10s 缓存(状态轮询时不重复 spawn launchctl/ps)。
|
|
4071
|
+
const launchd = memoStatusProbe("launchd", launchdStatus);
|
|
4072
|
+
const manual = memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
4013
4073
|
// 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
|
|
4014
4074
|
let bindError = null;
|
|
4015
4075
|
try {
|
|
@@ -5704,12 +5764,18 @@ async function accountDeviceBound(relayDir, cfg) {
|
|
|
5704
5764
|
*/
|
|
5705
5765
|
async function composeConnect(relayDir, opts = {}) {
|
|
5706
5766
|
const cfg = opts.cfg || loadConfig(relayDir);
|
|
5707
|
-
// 【
|
|
5708
|
-
//
|
|
5709
|
-
//
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
const
|
|
5767
|
+
// 【B1 · 2026-11-07 登录慢优化】端口发现**绝不再阻塞状态/登录响应**:
|
|
5768
|
+
// · 已有可用结论(显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离)→ 直接用;
|
|
5769
|
+
// · 否则**放后台**跑(void),本次先用 hint/回退地址把状态返回 —— 首屏不再等信息;
|
|
5770
|
+
// 发现完成后的结果会在下一次状态轮询自然带上(面板每 2.5~30s 轮询,无需推送)。
|
|
5771
|
+
// 旧实现的 await 会把 lsof/netstat + 数百端口探测整个塞进首屏路径(业主实测 7~8 秒)。
|
|
5772
|
+
const discoverySettled = upstreamDiscoverySettled();
|
|
5773
|
+
if (!discoverySettled) {
|
|
5774
|
+
void discoverImpl(relayDir).catch(() => FALLBACK_UPSTREAM);
|
|
5775
|
+
}
|
|
5776
|
+
// B1:这两次 shell 探测走 10s TTL 缓存(见 memoStatusProbe);变更动作会清缓存。
|
|
5777
|
+
const launchd = opts.launchd || memoStatusProbe("launchd", launchdStatus);
|
|
5778
|
+
const manual = opts.manual || memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
5713
5779
|
const book = connectBook(relayDir);
|
|
5714
5780
|
const now = Date.now();
|
|
5715
5781
|
const hasAcct = hasAccountCreds(cfg);
|
|
@@ -5958,6 +6024,8 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5958
6024
|
selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
|
|
5959
6025
|
upstreamUrl: upstreamUrl(),
|
|
5960
6026
|
upstreamSource: upstreamSourceLabel(),
|
|
6027
|
+
// B1:这次响应是否"端口发现还没完成、已放后台跑"(只作诊断/文案;下一次轮询会自然收敛)
|
|
6028
|
+
discoveryPending: Boolean(!discoverySettled),
|
|
5961
6029
|
// 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
|
|
5962
6030
|
// 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
|
|
5963
6031
|
// 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
|
|
@@ -7542,7 +7610,275 @@ export function localRequestBlocked(req) {
|
|
|
7542
7610
|
return null;
|
|
7543
7611
|
}
|
|
7544
7612
|
|
|
7545
|
-
|
|
7613
|
+
// ═══════════════════════ 🤖 IM 机器人通道(2026-11-07,企业微信智能机器人)═══════════════════════
|
|
7614
|
+
//
|
|
7615
|
+
// 面板「通知与消息通道」弹层背后的三条本地 API。它**不是**原来那条「微信机器人扫码绑定」通道
|
|
7616
|
+
// (那条跑在 bridge 的 wechat-runtime 里,见 /dsh-remote/wechat/*),而是「用企业微信/飞书机器人的
|
|
7617
|
+
// 凭据让桥主动推送消息」的第二种通道,配置面就两个文件(契约与桥半约定,字段名**不要改**):
|
|
7618
|
+
//
|
|
7619
|
+
// <relayDir>/im-bots.json 插件写、桥读
|
|
7620
|
+
// { version:1, wecom:{ enabled, botId, secret, url, targetChatId, sessionId, sessionTitle, previewCardAt } }
|
|
7621
|
+
// previewCardAt:**发送预览卡片**的触发时间戳(ms;插件写、桥读,值变大桥就发一张预览审批卡)
|
|
7622
|
+
// <relayDir>/im-bot-status.json 桥写、插件**只读**
|
|
7623
|
+
// { updatedAt, wecom:{ enabled, connected, authenticated, lastError,
|
|
7624
|
+
// lastConnectedAt, lastMessageAt, boundChatId } }
|
|
7625
|
+
//
|
|
7626
|
+
// 安全边界(本文件里凡是下发给浏览器的字段都要过这里):
|
|
7627
|
+
// · **secret 永不返回浏览器**(GET/PUT/测试结果里只给 hasSecret:boolean);
|
|
7628
|
+
// · 配置文件按 0600 写 + 临时文件原子替换(半写文件会让桥读到坏 JSON → 通道静默失效);
|
|
7629
|
+
// · 状态文件是桥写的,插件只挑白名单字段转发,绝不整份透传(防止未来桥塞进敏感字段被带出去)。
|
|
7630
|
+
//
|
|
7631
|
+
// 三条 API:
|
|
7632
|
+
// GET /dsh-remote/im-bots → {ok, wecom:{enabled,botId,hasSecret,url,targetChatId,sessionId,sessionTitle}, status}
|
|
7633
|
+
// PUT /dsh-remote/im-bots (body {...}) → 同上(保存后立刻回读;空 secret = 不修改)
|
|
7634
|
+
// POST /dsh-remote/im-bots/test → 写配置后轮询状态 ≤10s,回 {ok, connected, authenticated, lastError}
|
|
7635
|
+
// 失败一律人话 + 稳定错误码:invalid_bot_id / missing_secret / connect_failed / timeout(外加 invalid_url / bad_request)。
|
|
7636
|
+
const IM_BOTS_FILE = "im-bots.json";
|
|
7637
|
+
const IM_BOT_STATUS_FILE = "im-bot-status.json";
|
|
7638
|
+
/** POST test 最多等多久(桥真正连上企业微信是异步的;10s 是 task 契约)。可用 config.imBotsWaitMs 覆盖(测试用)。 */
|
|
7639
|
+
const IM_BOT_STATUS_WAIT_MS = 10_000;
|
|
7640
|
+
const IM_BOT_STATUS_POLL_MS = 300;
|
|
7641
|
+
/** 企业微信智能机器人的 Bot ID 形如 `aib...`(用户抄错时当场给可理解的错误,而不是存下去然后一直连不上)。 */
|
|
7642
|
+
const WECOM_BOT_ID_RE = /^aib[A-Za-z0-9_-]{3,}$/;
|
|
7643
|
+
const IM_BOT_URL_RE = /^https?:\/\/[^\s]+$/i;
|
|
7644
|
+
|
|
7645
|
+
function imBotsPathOf(relayDir) { return join(relayDir, IM_BOTS_FILE); }
|
|
7646
|
+
function imBotStatusPathOf(relayDir) { return join(relayDir, IM_BOT_STATUS_FILE); }
|
|
7647
|
+
|
|
7648
|
+
/** 读一个 JSON 文件;不存在/半写/坏 JSON 一律当空对象(通道没配 ≠ 面板报错)。 */
|
|
7649
|
+
function readJsonFileSoft(file) {
|
|
7650
|
+
try {
|
|
7651
|
+
const raw = readFileSync(file, "utf8");
|
|
7652
|
+
const v = JSON.parse(raw);
|
|
7653
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
7654
|
+
} catch { return {}; }
|
|
7655
|
+
}
|
|
7656
|
+
|
|
7657
|
+
function readImBots(relayDir) { return readJsonFileSoft(imBotsPathOf(relayDir)); }
|
|
7658
|
+
function readImBotStatus(relayDir) { return readJsonFileSoft(imBotStatusPathOf(relayDir)); }
|
|
7659
|
+
|
|
7660
|
+
/** 归一化 wecom 配置(读的时候也归一,桥手工编辑过/旧版本文件都不会让面板崩)。 */
|
|
7661
|
+
function wecomCfgOf(bots) {
|
|
7662
|
+
const w = bots && bots.wecom && typeof bots.wecom === "object" ? bots.wecom : {};
|
|
7663
|
+
return {
|
|
7664
|
+
enabled: !!w.enabled,
|
|
7665
|
+
botId: typeof w.botId === "string" ? w.botId : "",
|
|
7666
|
+
secret: typeof w.secret === "string" ? w.secret : "",
|
|
7667
|
+
url: typeof w.url === "string" ? w.url : "",
|
|
7668
|
+
targetChatId: typeof w.targetChatId === "string" ? w.targetChatId : "",
|
|
7669
|
+
// 「转接到哪个会话」:空串 = 跟随当前活跃会话(桥侧默认)。
|
|
7670
|
+
sessionId: typeof w.sessionId === "string" ? w.sessionId : "",
|
|
7671
|
+
sessionTitle: typeof w.sessionTitle === "string" ? w.sessionTitle : "",
|
|
7672
|
+
// 「发送预览卡片」触发时间戳:插件写、桥读;0/缺失 = 从未触发过。
|
|
7673
|
+
previewCardAt: Number.isFinite(Number(w.previewCardAt)) && Number(w.previewCardAt) > 0 ? Number(w.previewCardAt) : 0,
|
|
7674
|
+
};
|
|
7675
|
+
}
|
|
7676
|
+
|
|
7677
|
+
/** 状态文件只转发白名单字段(桥以后往里塞什么都带不出去)。 */
|
|
7678
|
+
function wecomStatusOf(status) {
|
|
7679
|
+
const w = status && status.wecom && typeof status.wecom === "object" ? status.wecom : {};
|
|
7680
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
7681
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
7682
|
+
return {
|
|
7683
|
+
enabled: !!w.enabled,
|
|
7684
|
+
connected: !!w.connected,
|
|
7685
|
+
authenticated: !!w.authenticated,
|
|
7686
|
+
lastError: str(w.lastError),
|
|
7687
|
+
lastConnectedAt: num(w.lastConnectedAt),
|
|
7688
|
+
lastMessageAt: num(w.lastMessageAt),
|
|
7689
|
+
boundChatId: str(w.boundChatId),
|
|
7690
|
+
// ── 会话可选绑定(2026-11-07,字段名与桥约定死,不要改)──
|
|
7691
|
+
// 只挑 id/title/updatedAt 三个字段、最多 50 条、title 截到 200 字:
|
|
7692
|
+
// 状态文件是桥写的,插件没权利把它整份原样吐给浏览器(可能越来越大/带内部字段)。
|
|
7693
|
+
activeSessionId: str(w.activeSessionId),
|
|
7694
|
+
activeSessionTitle: str(w.activeSessionTitle),
|
|
7695
|
+
boundSessionId: str(w.boundSessionId),
|
|
7696
|
+
// 「上次预览卡片」已处理到的时间戳(桥写;面板只用来显示「上次预览:HH:MM」)
|
|
7697
|
+
lastPreviewCardAt: num(w.lastPreviewCardAt),
|
|
7698
|
+
// ── 会员档位 / 额度(2026-11-07 桥侧新增,字段名锁死;只白名单,不整包吐)──
|
|
7699
|
+
// tier 只认三个枚举值;拿不到就是空串(面板据此**不渲染**档位条,绝不猜)。
|
|
7700
|
+
tier: (() => {
|
|
7701
|
+
const t = str(w.tier);
|
|
7702
|
+
return (t === "free" || t === "pro" || t === "pro_max") ? t : "";
|
|
7703
|
+
})(),
|
|
7704
|
+
// caps 是字符串数组:逐个转字符串 + 截断 + 上限 50 条(桥以后往里塞对象也别透出去)
|
|
7705
|
+
caps: (Array.isArray(w.caps) ? w.caps : []).slice(0, 50).map((c) => str(c).slice(0, 80)).filter(Boolean),
|
|
7706
|
+
capsCount: num(w.capsCount),
|
|
7707
|
+
bindingGated: !!w.bindingGated,
|
|
7708
|
+
messagesPerMonth: num(w.messagesPerMonth),
|
|
7709
|
+
// null = 不限或未知(**必须和 0 区分**:0 条剩余 ≠ 不限量)
|
|
7710
|
+
remainingMessages: typeof w.remainingMessages === "number" && Number.isFinite(w.remainingMessages) ? w.remainingMessages : null,
|
|
7711
|
+
usage: (() => {
|
|
7712
|
+
const u = w.usage && typeof w.usage === "object" ? w.usage : null;
|
|
7713
|
+
if (!u) return null;
|
|
7714
|
+
return { month: str(u.month), messages: num(u.messages) };
|
|
7715
|
+
})(),
|
|
7716
|
+
sessions: (Array.isArray(w.sessions) ? w.sessions : []).slice(0, 50).map(function (it) {
|
|
7717
|
+
const o = it && typeof it === "object" ? it : {};
|
|
7718
|
+
return {
|
|
7719
|
+
id: str(o.id),
|
|
7720
|
+
title: str(o.title).slice(0, 200),
|
|
7721
|
+
updatedAt: num(o.updatedAt),
|
|
7722
|
+
};
|
|
7723
|
+
}).filter(function (it) { return !!it.id; }),
|
|
7724
|
+
};
|
|
7725
|
+
}
|
|
7726
|
+
|
|
7727
|
+
/**
|
|
7728
|
+
* 原子写 im-bots.json(0600)。
|
|
7729
|
+
* 为什么 tmp + rename:桥是**另一个进程**,写一半被读到就是坏 JSON;rename 在同一目录内是原子的。
|
|
7730
|
+
*/
|
|
7731
|
+
function writeImBots(relayDir, data) {
|
|
7732
|
+
mkdirSync(relayDir, { recursive: true });
|
|
7733
|
+
const file = imBotsPathOf(relayDir);
|
|
7734
|
+
const tmp = `${file}.dsh-remote.tmp`;
|
|
7735
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
7736
|
+
try { hardenFile(tmp); } catch { /* Windows ACL 失败不致命 */ }
|
|
7737
|
+
try { chmodSync(tmp, 0o600); } catch { /* 非 POSIX 忽略 */ }
|
|
7738
|
+
renameSync(tmp, file);
|
|
7739
|
+
try { chmodSync(file, 0o600); } catch { /* 忽略 */ }
|
|
7740
|
+
}
|
|
7741
|
+
|
|
7742
|
+
/** 面板可见的通道状态(GET/PUT 共用同一个形状;**绝不含 secret**)。 */
|
|
7743
|
+
function imBotsView(relayDir) {
|
|
7744
|
+
const bots = readImBots(relayDir);
|
|
7745
|
+
const w = wecomCfgOf(bots);
|
|
7746
|
+
const stFile = readImBotStatus(relayDir);
|
|
7747
|
+
return {
|
|
7748
|
+
ok: true,
|
|
7749
|
+
wecom: {
|
|
7750
|
+
enabled: w.enabled,
|
|
7751
|
+
botId: w.botId,
|
|
7752
|
+
hasSecret: w.secret.length > 0,
|
|
7753
|
+
url: w.url,
|
|
7754
|
+
targetChatId: w.targetChatId,
|
|
7755
|
+
sessionId: w.sessionId,
|
|
7756
|
+
sessionTitle: w.sessionTitle,
|
|
7757
|
+
},
|
|
7758
|
+
status: wecomStatusOf(stFile),
|
|
7759
|
+
updatedAt: typeof stFile.updatedAt === "number" ? stFile.updatedAt : 0,
|
|
7760
|
+
};
|
|
7761
|
+
}
|
|
7762
|
+
|
|
7763
|
+
/**
|
|
7764
|
+
* 保存 wecom 配置(PUT 与 POST test 共用;纯校验 + 写盘,不碰网络)。
|
|
7765
|
+
* @returns {{ok:true}|{ok:false, code:string, message:string}}
|
|
7766
|
+
*/
|
|
7767
|
+
function saveWecomConfig(relayDir, input) {
|
|
7768
|
+
const src = input && typeof input === "object" ? input : {};
|
|
7769
|
+
const bots = readImBots(relayDir);
|
|
7770
|
+
const cur = wecomCfgOf(bots);
|
|
7771
|
+
|
|
7772
|
+
// 只认契约字段;其余平台(feishu 等)与未知字段原样保留 —— 插件不认识它们,但没有权利删掉。
|
|
7773
|
+
const next = {
|
|
7774
|
+
enabled: cur.enabled,
|
|
7775
|
+
botId: cur.botId,
|
|
7776
|
+
secret: cur.secret,
|
|
7777
|
+
url: cur.url,
|
|
7778
|
+
targetChatId: cur.targetChatId,
|
|
7779
|
+
sessionId: cur.sessionId,
|
|
7780
|
+
sessionTitle: cur.sessionTitle,
|
|
7781
|
+
previewCardAt: cur.previewCardAt,
|
|
7782
|
+
};
|
|
7783
|
+
|
|
7784
|
+
if (Object.prototype.hasOwnProperty.call(src, "botId")) {
|
|
7785
|
+
const botId = String(src.botId ?? "").trim();
|
|
7786
|
+
if (botId && !WECOM_BOT_ID_RE.test(botId)) {
|
|
7787
|
+
return { ok: false, code: "invalid_bot_id", message: "Bot ID 格式不对:应形如 aibxxxxxxxx(在企业微信「智能机器人」页面复制)" };
|
|
7788
|
+
}
|
|
7789
|
+
next.botId = botId;
|
|
7790
|
+
}
|
|
7791
|
+
if (Object.prototype.hasOwnProperty.call(src, "secret")) {
|
|
7792
|
+
const secret = typeof src.secret === "string" ? src.secret.trim() : "";
|
|
7793
|
+
// 空字符串 = **不修改**(面板不回显 secret,用户只改 Bot ID 时不该被迫重填)
|
|
7794
|
+
if (secret) next.secret = secret;
|
|
7795
|
+
}
|
|
7796
|
+
if (Object.prototype.hasOwnProperty.call(src, "url")) {
|
|
7797
|
+
const url = String(src.url ?? "").trim();
|
|
7798
|
+
if (url && !IM_BOT_URL_RE.test(url)) {
|
|
7799
|
+
return { ok: false, code: "invalid_url", message: "服务地址必须以 http:// 或 https:// 开头" };
|
|
7800
|
+
}
|
|
7801
|
+
next.url = url;
|
|
7802
|
+
}
|
|
7803
|
+
if (Object.prototype.hasOwnProperty.call(src, "targetChatId")) {
|
|
7804
|
+
const cid = String(src.targetChatId ?? "").trim();
|
|
7805
|
+
if (cid.length > 200) return { ok: false, code: "invalid_target_chat_id", message: "接收会话 ID 过长(最多 200 字)" };
|
|
7806
|
+
next.targetChatId = cid;
|
|
7807
|
+
}
|
|
7808
|
+
if (Object.prototype.hasOwnProperty.call(src, "sessionId")) {
|
|
7809
|
+
const sid = String(src.sessionId ?? "").trim();
|
|
7810
|
+
if (sid.length > 200) return { ok: false, code: "invalid_session_id", message: "会话 ID 过长(最多 200 字)" };
|
|
7811
|
+
// 空串 = **跟随当前活跃会话** → 同时清掉标题(保留一个"孤零零的标题"只会让 UI 说错话)
|
|
7812
|
+
next.sessionId = sid;
|
|
7813
|
+
next.sessionTitle = sid ? String(src.sessionTitle ?? "").trim().slice(0, 200) : "";
|
|
7814
|
+
}
|
|
7815
|
+
if (Object.prototype.hasOwnProperty.call(src, "previewCardAt")) {
|
|
7816
|
+
// 触发信号:必须是数字/可转数字(前端传 Date.now())。非法值直接拒绝,绝不写进文件 ——
|
|
7817
|
+
// 桥只认"数字变大",写个 NaN 进去会让这条信号永久失效且很难排查。
|
|
7818
|
+
const n = Number(src.previewCardAt);
|
|
7819
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
7820
|
+
return { ok: false, code: "invalid_preview_card_at", message: "预览卡片触发时间戳必须是正整数(毫秒)" };
|
|
7821
|
+
}
|
|
7822
|
+
next.previewCardAt = Math.floor(n);
|
|
7823
|
+
}
|
|
7824
|
+
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
7825
|
+
else if (!next.enabled && next.botId && next.secret) next.enabled = true; // 首次保存凭据 = 想让它生效
|
|
7826
|
+
|
|
7827
|
+
// 凭据完整性:Bot ID 与 Secret 缺一不可(secret 允许"沿用已保存的",但最终必须存在)。
|
|
7828
|
+
// 不做"只存 Bot ID、enabled=false"的半截配置 —— 那种文件桥读起来只会静默失效,用户以为配好了。
|
|
7829
|
+
if (!next.botId) {
|
|
7830
|
+
return { ok: false, code: "invalid_bot_id", message: "请先填写 Bot ID(在企业微信「智能机器人」页面复制,形如 aibxxxxxxxx)" };
|
|
7831
|
+
}
|
|
7832
|
+
if (!next.secret) {
|
|
7833
|
+
return { ok: false, code: "missing_secret", message: "请先填写 Secret(企业微信智能机器人页面生成的密钥,只保存在本机)" };
|
|
7834
|
+
}
|
|
7835
|
+
|
|
7836
|
+
const out = { ...bots, version: 1, wecom: next };
|
|
7837
|
+
try {
|
|
7838
|
+
writeImBots(relayDir, out);
|
|
7839
|
+
return { ok: true };
|
|
7840
|
+
} catch (e) {
|
|
7841
|
+
return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
|
|
7842
|
+
}
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
/**
|
|
7846
|
+
* POST /dsh-remote/im-bots/test:保存(若带凭据)→ 轮询桥写的状态文件 ≤ waitMs。
|
|
7847
|
+
* 返回面板直接可渲染的结论:连接中 / 已连接 / 失败原因(桥的 lastError 原文照登)。
|
|
7848
|
+
*/
|
|
7849
|
+
async function imBotsTest(relayDir, waitMs, input) {
|
|
7850
|
+
const hasInput = input && typeof input === "object" && Object.keys(input).length > 0;
|
|
7851
|
+
if (hasInput) {
|
|
7852
|
+
const saved = saveWecomConfig(relayDir, input);
|
|
7853
|
+
if (!saved.ok) return { httpCode: 400, body: { ok: false, error: saved.code, message: saved.message } };
|
|
7854
|
+
}
|
|
7855
|
+
const w = wecomCfgOf(readImBots(relayDir));
|
|
7856
|
+
if (!w.secret) {
|
|
7857
|
+
return { httpCode: 400, body: { ok: false, error: "missing_secret", message: "还没有保存 Secret:请先填写并保存(Secret 只保存在本机)" } };
|
|
7858
|
+
}
|
|
7859
|
+
const startedAt = Date.now();
|
|
7860
|
+
const total = Math.max(200, Number(waitMs) || IM_BOT_STATUS_WAIT_MS);
|
|
7861
|
+
let last = wecomStatusOf(readImBotStatus(relayDir));
|
|
7862
|
+
while (Date.now() - startedAt < total) {
|
|
7863
|
+
if (last.connected) {
|
|
7864
|
+
return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
|
|
7865
|
+
}
|
|
7866
|
+
await new Promise((r) => { const t = setTimeout(r, IM_BOT_STATUS_POLL_MS); try { t.unref?.(); } catch { /* 忽略 */ } });
|
|
7867
|
+
last = wecomStatusOf(readImBotStatus(relayDir));
|
|
7868
|
+
if (last.lastError && !last.connected) break; // 桥已经明确报错:不用傻等到超时
|
|
7869
|
+
}
|
|
7870
|
+
if (last.connected) {
|
|
7871
|
+
return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
|
|
7872
|
+
}
|
|
7873
|
+
if (last.lastError) {
|
|
7874
|
+
return { httpCode: 502, body: { ok: false, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
|
|
7875
|
+
}
|
|
7876
|
+
return { httpCode: 504, body: { ok: false, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
|
|
7877
|
+
}
|
|
7878
|
+
|
|
7879
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7880
|
+
|
|
7881
|
+
function registerRoutes(ctx, relayDir, opts = {}) {
|
|
7546
7882
|
// 本插件所在 profile:与配对备份的持久数据目录共用同一份推导(pluginProfileDir),
|
|
7547
7883
|
// 避免"卸载删的目录"和"备份写的目录"各算各的(2026-10-17 双份备份加固时抽出)。
|
|
7548
7884
|
const profileDir = pluginProfileDir();
|
|
@@ -8040,7 +8376,26 @@ function registerRoutes(ctx, relayDir) {
|
|
|
8040
8376
|
const bridgeRestart = startBridge(relayDir);
|
|
8041
8377
|
// 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」约定);失败静默。
|
|
8042
8378
|
maybeReportInstall(relayDir, { force: true });
|
|
8043
|
-
|
|
8379
|
+
// 【B2 · 2026-11-07】登录响应**立即返回**:旧实现 `...(await composeStatus())` 会把
|
|
8380
|
+
// 端口发现 / launchctl+ps / 中继探测全塞进登录路径(业主实测 7~8 秒)。
|
|
8381
|
+
// 这里只回"保存成功 + bridge 重启结果 + 最小配置回显",衍生信息由面板登录后异步拉
|
|
8382
|
+
// (GET /dsh-remote/status & /bridge-status),UI 已经先切到已登录态。
|
|
8383
|
+
const cfgEcho = loadConfig(relayDir);
|
|
8384
|
+
sendJson(res, 200, {
|
|
8385
|
+
ok: true,
|
|
8386
|
+
bridgeRestart,
|
|
8387
|
+
statusPending: true,
|
|
8388
|
+
config: {
|
|
8389
|
+
phone: maskPhone(cfgEcho.phone || ""),
|
|
8390
|
+
hasPhone: Boolean(cfgEcho.phone),
|
|
8391
|
+
hasPassword: Boolean(cfgEcho.password),
|
|
8392
|
+
deviceId: cfgEcho.device_id || "",
|
|
8393
|
+
apiUrl: cfgEcho.api_url || DEFAULT_API,
|
|
8394
|
+
mode: cfgEcho.local_key ? "local" : "saas",
|
|
8395
|
+
selfHostUrl: cfgEcho.tunnel_url ? String(cfgEcho.tunnel_url).replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
|
|
8396
|
+
hasLocalKey: Boolean(cfgEcho.local_key)
|
|
8397
|
+
}
|
|
8398
|
+
});
|
|
8044
8399
|
},
|
|
8045
8400
|
},
|
|
8046
8401
|
{
|
|
@@ -8337,6 +8692,50 @@ function registerRoutes(ctx, relayDir) {
|
|
|
8337
8692
|
await proxyCommunityInvite(relayDir, req, res, url.pathname);
|
|
8338
8693
|
},
|
|
8339
8694
|
},
|
|
8695
|
+
// 🤖 IM 机器人通道(2026-11-07):面板「通知与消息通道」弹层的数据面。
|
|
8696
|
+
// 与其它 /dsh-remote/* 一样只在本机 webserver 上注册(跨站请求已被 localRequestBlocked 挡掉)。
|
|
8697
|
+
// ⚠️ secret 永不返回浏览器 —— 三个响应里都只有 hasSecret / lastError 这类非敏感字段。
|
|
8698
|
+
// ⚠️ 用**一条** ALL 路由在 handler 内按 req.method 分发:webserver 的注册是按 path 的,
|
|
8699
|
+
// 同一个 path 注册两条(GET/PUT)只会有一条生效 —— 那是 2026-11-07 本地实测踩到的坑
|
|
8700
|
+
// (面板保存打到了 GET 上,看着"没报错但没保存")。feedback/announcements 用 prefix 同理。
|
|
8701
|
+
{
|
|
8702
|
+
method: "ALL",
|
|
8703
|
+
path: "/dsh-remote/im-bots",
|
|
8704
|
+
handler: async (req, res) => {
|
|
8705
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
8706
|
+
if (method === "GET") {
|
|
8707
|
+
sendJson(res, 200, imBotsView(relayDir));
|
|
8708
|
+
return;
|
|
8709
|
+
}
|
|
8710
|
+
if (method !== "PUT") {
|
|
8711
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed", message: "只支持 GET / PUT" });
|
|
8712
|
+
return;
|
|
8713
|
+
}
|
|
8714
|
+
const body = await readJsonBody(req);
|
|
8715
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "bad_request", message: "请求体不是合法 JSON" });
|
|
8716
|
+
const wecom = body && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
|
|
8717
|
+
if (!wecom) return sendJson(res, 400, { ok: false, error: "bad_request", message: "缺少 wecom 配置" });
|
|
8718
|
+
const saved = saveWecomConfig(relayDir, wecom);
|
|
8719
|
+
if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
|
|
8720
|
+
sendJson(res, 200, imBotsView(relayDir));
|
|
8721
|
+
},
|
|
8722
|
+
},
|
|
8723
|
+
{
|
|
8724
|
+
method: "ALL",
|
|
8725
|
+
path: "/dsh-remote/im-bots/test",
|
|
8726
|
+
handler: async (req, res) => {
|
|
8727
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
8728
|
+
if (method !== "POST") {
|
|
8729
|
+
sendJson(res, 405, { ok: false, error: "method_not_allowed", message: "只支持 POST" });
|
|
8730
|
+
return;
|
|
8731
|
+
}
|
|
8732
|
+
const body = await readJsonBody(req);
|
|
8733
|
+
// body 可选:不传 = 只测**已保存**的配置;传 = 先保存再测(与面板「保存并连接」一次点完一致)
|
|
8734
|
+
const wecom = body && !body.__parseError && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
|
|
8735
|
+
const r = await imBotsTest(relayDir, opts.imBotsWaitMs, wecom);
|
|
8736
|
+
sendJson(res, r.httpCode, r.body);
|
|
8737
|
+
},
|
|
8738
|
+
},
|
|
8340
8739
|
];
|
|
8341
8740
|
|
|
8342
8741
|
const disposers = [];
|
|
@@ -8680,7 +9079,10 @@ export function apply(ctx, config = {}) {
|
|
|
8680
9079
|
markRestartPending(relayDir, "refresh", "插件已更新,刷新页面即可生效");
|
|
8681
9080
|
ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 提示刷新页面");
|
|
8682
9081
|
}
|
|
8683
|
-
ctx.effect(() => registerRoutes(ctx, relayDir
|
|
9082
|
+
ctx.effect(() => registerRoutes(ctx, relayDir, {
|
|
9083
|
+
// 测试钩子:POST /dsh-remote/im-bots/test 的等待上限(默认 10s,见 imBotsTest 注释)。
|
|
9084
|
+
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9085
|
+
}), "dsh-remote-web: /dsh-remote routes");
|
|
8684
9086
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
8685
9087
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
8686
9088
|
// 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
|
|
@@ -8728,6 +9130,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
|
|
|
8728
9130
|
setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
|
|
8729
9131
|
// 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
|
|
8730
9132
|
composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
|
|
9133
|
+
// B1(2026-11-07 登录慢):端口发现是否阻塞、shell 探测 TTL 缓存、状态组装都要能被单测钉住
|
|
9134
|
+
composeStatus as __composeStatus, launchdStatus as __launchdStatus,
|
|
9135
|
+
clearStatusProbeCache as __clearStatusProbeCache, upstreamDiscoverySettled as __upstreamDiscoverySettled,
|
|
9136
|
+
setUpstreamTestState as __setUpstreamTestState, resetUpstreamTestState as __resetUpstreamTestState,
|
|
9137
|
+
STATUS_PROBE_TTL_MS as __STATUS_PROBE_TTL_MS,
|
|
8731
9138
|
bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
|
|
8732
9139
|
bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
|
|
8733
9140
|
DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12-beta.1",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|