@mrrisega/dsh-remote 0.7.7 → 0.7.9-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/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/clients/dsh-remote/test/wechat-channel.test.mjs +452 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +568 -29
- package/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/dsh-setup.mjs +200 -48
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -2
- package/packages/dsh-remote-web/lib/index.js +347 -53
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +200 -48
- package/packages/dsh-remote-web/test/setup-win-scan.test.mjs +455 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +50 -0
- package/packages/dsh-remote-web/test/windows-process-discovery.test.mjs +427 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-setup.mjs` 的 **Windows 进程发现 / 自检扫描** —— 行为级回归(2026-10-29 反馈取证)。
|
|
4
|
+
*
|
|
5
|
+
* 【为什么单开一个文件】事故(见 dsh-relay-internal/2026-10-29-反馈bridge_wedged-取证/
|
|
6
|
+
* code-analysis.md §二 Q4/Q5、§四 F2/F3/F5/F7/F8):Windows/Electron 宿主下,插件把
|
|
7
|
+
* **活着的孤儿 bridge** 判成「未运行」→ 反复 taskkill 重拉 → 3 次后 `bridge_wedged` 永久红字,
|
|
8
|
+
* 而中继数据面全程在线。运行期/安装器侧的三个结构性缺陷都在 Windows 分支里,而 Windows
|
|
9
|
+
* 分支在开发机上**根本跑不到** —— 本文件用「`DSH_SETUP_PLATFORM=win32` + 假 powershell.exe +
|
|
10
|
+
* 假 PATH + 行为级调用」在 macOS/Linux 上把这些分支真的跑一遍:
|
|
11
|
+
*
|
|
12
|
+
* ① `listRuntimeBridgePids()` 不得再认死镜像名 `Name='node.exe'`:
|
|
13
|
+
* Electron 宿主拉起的 bridge 可能叫 `DeepSeek Harness.exe`,旧过滤必然全漏 ⇒
|
|
14
|
+
* `sweepStrayBridges()` 清不掉孤儿 ⇒ code=4 之后 5 分钟退避永远清不掉;
|
|
15
|
+
* 同时**必须保持 `isThisRuntimeBridgeCmd` 的严格性**(另一个运行时目录的进程不许碰);
|
|
16
|
+
* ② `doctor` 的进程扫描旧实现是 `tasklist /FI "IMAGENAME eq node.exe" /FO CSV`,而 tasklist
|
|
17
|
+
* **没有命令行列**,再用 `dsh-setup.mjs`/`dsh-bridge.mjs` 正则过滤 ⇒ Windows 上恒为 0/0
|
|
18
|
+
* —— 这条"假证据"把本次排查带偏。现在改为 CIM 全量枚举(带 CommandLine),
|
|
19
|
+
* 计数必须与假数据一致,并打出 pid + 解释器名 + 命令行摘要;扫描不可用要**显式说**
|
|
20
|
+
* 「扫描不可用」,绝不能输出一个会被读成"没有进程"的 0;
|
|
21
|
+
* ③ `looksLikeWatcher()/looksLikeBridge()` 旧实现用 `ps` ⇒ Windows 恒 false ⇒
|
|
22
|
+
* 接管/回收上一任进程这两条安全回收路径在 Windows 上从不生效。补 Windows 分支后,
|
|
23
|
+
* 按 pid 查到的命令行必须能认出 watcher / bridge,且**认不出就绝不动**(PID 复用安全);
|
|
24
|
+
* ④ pid 文件必须"谁持有谁负责":`removePidFile(name, expectPid)` 只在内容等于自己的 pid 时删
|
|
25
|
+
* (旧实现无条件删 = 事故根因链第 2 步:新 bridge 秒退把孤儿活着的证据删掉);
|
|
26
|
+
* 写/删失败必须往 `.dsh-setup-install.log` 留一行(不再静默 catch);
|
|
27
|
+
* ⑤ bridge 自证:状态文件里写 `pid` 并节流刷新 `heartbeat_at`(插件侧另一条线读取)。
|
|
28
|
+
*
|
|
29
|
+
* 【怎么测 dsh-setup.mjs 里的函数】它是 **CLI 单文件**(`import` 就会执行 main、没有 export),
|
|
30
|
+
* 所以只能沿用仓库既有手法(见 windows-compat.test.mjs 的 new Function 用例):按锚点切出
|
|
31
|
+
* 函数源码,用 `new Function` 注入模块级依赖(IS_WIN / THIS_DIR / spawnSync / path / fs / sh)
|
|
32
|
+
* 后调用。假 `powershell.exe` 放在 PATH 最前面(macOS/Linux 能按 PATH 解析带扩展名的可执行脚本)。
|
|
33
|
+
*
|
|
34
|
+
* 【隔离纪律】不碰真实 Windows / 真实 `~/.dsh-remote`:HOME 与 relayDir 全部 mkdtemp,
|
|
35
|
+
* 平台分支用 `DSH_SETUP_PLATFORM=win32` 强制;「子进程 env」只传给 spawnSync,不改本进程环境
|
|
36
|
+
* (只有切函数用的 PATH/假 JSON 会临时置位并在 finally 还原)。
|
|
37
|
+
*/
|
|
38
|
+
import assert from "node:assert/strict";
|
|
39
|
+
import { spawnSync } from "node:child_process";
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import {
|
|
42
|
+
chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
|
|
43
|
+
} from "node:fs";
|
|
44
|
+
import os from "node:os";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import test from "node:test";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
|
|
49
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const ROOT = path.join(HERE, "..", "..", "..");
|
|
51
|
+
// DSH_TEST_SETUP_PATH:只给"红/绿对照"用 —— 指向改动前的 dsh-setup.mjs,验证本文件的新用例
|
|
52
|
+
// 在旧实现上真的会红(而不是"假脚本不认 Name 过滤所以蒙对")。
|
|
53
|
+
const SETUP = process.env.DSH_TEST_SETUP_PATH || path.join(ROOT, "dsh-setup.mjs");
|
|
54
|
+
const BRIDGE = path.join(ROOT, "clients", "dsh-remote", "dsh-bridge.mjs");
|
|
55
|
+
// 本文件就是 Windows 专项:CRLF 检出时源码锚点必须仍然匹配
|
|
56
|
+
const SETUP_SRC = readFileSync(SETUP, "utf8").replace(/\r\n/g, "\n");
|
|
57
|
+
const BRIDGE_SRC = readFileSync(BRIDGE, "utf8").replace(/\r\n/g, "\n");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 假 `powershell.exe`(node 实现,便于解码 -EncodedCommand 做"服务端过滤"模拟)。
|
|
61
|
+
*
|
|
62
|
+
* 关键:它会**解码 -EncodedCommand 并检查脚本里有没有 `Name='node.exe'`**,有则模拟 CIM 的
|
|
63
|
+
* 服务端过滤(只留 `Name === "node.exe"` 的条目)—— 于是旧实现(带 Name 过滤)在
|
|
64
|
+
* 「Electron 命名的 bridge」用例里必然红,新实现(无 Name 过滤)绿。这不是蒙对,是照旧实现的
|
|
65
|
+
* 查询语义真的过滤了一遍。
|
|
66
|
+
*
|
|
67
|
+
* 另外两种用法:
|
|
68
|
+
* · 脚本含 `ProcessId=`(`windowsProcessCommandLine` 点查)→ 原样输出 `$DSH_TEST_CMDLINE`;
|
|
69
|
+
* · `$DSH_TEST_CIM_FAIL=1` → 非 0 退出,模拟「没装 PowerShell / 调用失败 / 超时」。
|
|
70
|
+
* 每次调用往 `$DSH_TEST_CIM_LOG` 追加一行(证明扫描执行过 + 用的是哪种查询)。
|
|
71
|
+
*/
|
|
72
|
+
const FAKE_POWERSHELL = `#!/usr/bin/env node
|
|
73
|
+
const fs = require("node:fs");
|
|
74
|
+
const args = process.argv.slice(2);
|
|
75
|
+
const at = args.indexOf("-EncodedCommand");
|
|
76
|
+
let script = "";
|
|
77
|
+
try { if (at >= 0 && args[at + 1]) script = Buffer.from(args[at + 1], "base64").toString("utf16le"); } catch {}
|
|
78
|
+
const hasNameFilter = /Name\\s*=\\s*'node\\.exe'/.test(script);
|
|
79
|
+
const log = process.env.DSH_TEST_CIM_LOG;
|
|
80
|
+
if (log) {
|
|
81
|
+
try {
|
|
82
|
+
fs.appendFileSync(log, JSON.stringify({ hasNameFilter, pointQuery: /ProcessId\\s*=/.test(script), script: script.slice(0, 200) }) + "\\n");
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
if (process.env.DSH_TEST_CIM_FAIL === "1") {
|
|
86
|
+
try { process.stderr.write("fake powershell: 不可用\\n"); } catch {}
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
if (/ProcessId\\s*=/.test(script)) {
|
|
90
|
+
process.stdout.write(String(process.env.DSH_TEST_CMDLINE || ""));
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
let procs = [];
|
|
94
|
+
try { procs = JSON.parse(process.env.DSH_TEST_CIM_JSON || "[]"); } catch { procs = []; }
|
|
95
|
+
if (!Array.isArray(procs)) procs = procs ? [procs] : [];
|
|
96
|
+
if (hasNameFilter) procs = procs.filter((p) => String((p && p.Name) || "").toLowerCase() === "node.exe");
|
|
97
|
+
if (!procs.length) process.exit(0); // ConvertTo-Json 对空结果不输出任何东西
|
|
98
|
+
process.stdout.write(JSON.stringify(procs.length === 1 ? procs[0] : procs));
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
/** 造隔离环境:临时 root / 假 bin(含假 powershell.exe)/ 假 HOME / 临时 relayDir。 */
|
|
102
|
+
function makeEnv() {
|
|
103
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "dsh-setup-winscan-"));
|
|
104
|
+
const fakeBin = path.join(root, "bin");
|
|
105
|
+
const home = path.join(root, "home");
|
|
106
|
+
const relayDir = path.join(root, "relay");
|
|
107
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
108
|
+
mkdirSync(home, { recursive: true });
|
|
109
|
+
mkdirSync(relayDir, { recursive: true });
|
|
110
|
+
const ps = path.join(fakeBin, "powershell.exe");
|
|
111
|
+
writeFileSync(ps, FAKE_POWERSHELL);
|
|
112
|
+
chmodSync(ps, 0o755);
|
|
113
|
+
return {
|
|
114
|
+
root, fakeBin, home, relayDir,
|
|
115
|
+
cimLog: path.join(root, "cim.log"),
|
|
116
|
+
cleanup() { try { rmSync(root, { recursive: true, force: true }); } catch { /* 已清理 */ } },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 临时置位本进程环境(给"切函数直接调用"用),返回后原样还原。 */
|
|
121
|
+
function withEnv(vars, fn) {
|
|
122
|
+
const saved = {};
|
|
123
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
124
|
+
saved[k] = process.env[k];
|
|
125
|
+
if (v === undefined) delete process.env[k];
|
|
126
|
+
else process.env[k] = v;
|
|
127
|
+
}
|
|
128
|
+
try { return fn(); } finally {
|
|
129
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
130
|
+
if (v === undefined) delete process.env[k];
|
|
131
|
+
else process.env[k] = v;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 按锚点切出 dsh-setup.mjs 里的函数源码,用 `new Function` 注入模块级依赖后执行。
|
|
138
|
+
* @param {{ start: string, end: string, params: Record<string, unknown>, exports: string[] }} spec
|
|
139
|
+
*/
|
|
140
|
+
function loadSetupFns({ start, end, params = {}, exports }) {
|
|
141
|
+
const a = SETUP_SRC.indexOf(start);
|
|
142
|
+
const b = SETUP_SRC.indexOf(end);
|
|
143
|
+
assert.ok(a >= 0, `切片起点锚点失效(代码可能改过):${start.slice(0, 60)}`);
|
|
144
|
+
assert.ok(b > a, `切片终点锚点失效:${end.slice(0, 60)}`);
|
|
145
|
+
const src = SETUP_SRC.slice(a, b);
|
|
146
|
+
const keys = Object.keys(params);
|
|
147
|
+
const make = new Function(...keys, `${src}\nreturn { ${exports.join(", ")} };`);
|
|
148
|
+
return make(...keys.map((k) => params[k]));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** POSIX 分支 stub:win32 用例里若走到它,说明平台分支接错了 —— 直接炸出来。 */
|
|
152
|
+
const posixBranchSh = () => { throw new Error("win32 用例不该走 POSIX 分支(sh/ps)"); };
|
|
153
|
+
|
|
154
|
+
/** 「按本运行时 CLI 的 Windows 分支」加载 isThisRuntimeBridgeCmd + windowsCimProcesses + listRuntimeBridgePids。 */
|
|
155
|
+
function loadPidDiscovery(thisDir) {
|
|
156
|
+
return loadSetupFns({
|
|
157
|
+
start: "function isThisRuntimeBridgeCmd(cmd) {",
|
|
158
|
+
end: "function sweepStrayBridges(",
|
|
159
|
+
params: { IS_WIN: true, THIS_DIR: thisDir, path, spawnSync, sh: posixBranchSh },
|
|
160
|
+
exports: ["isThisRuntimeBridgeCmd", "windowsCimProcesses", "listRuntimeBridgePids"],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 加载 processCommandLine / looksLikeWatcher / looksLikeBridge + Windows 的 windowsProcessCommandLine。 */
|
|
165
|
+
function loadLookalike() {
|
|
166
|
+
const first = SETUP_SRC.slice(
|
|
167
|
+
SETUP_SRC.indexOf("function processCommandLine(pid) {"),
|
|
168
|
+
SETUP_SRC.indexOf("function shouldReapLeftoverBridge("),
|
|
169
|
+
);
|
|
170
|
+
const second = SETUP_SRC.slice(
|
|
171
|
+
SETUP_SRC.indexOf("/** 读某进程的完整命令行"),
|
|
172
|
+
SETUP_SRC.indexOf("/**\n * 把 Windows 命令行拆成 argv"),
|
|
173
|
+
);
|
|
174
|
+
assert.ok(first.includes("function looksLikeWatcher") && first.includes("function looksLikeBridge"),
|
|
175
|
+
"应能切出 processCommandLine / looksLikeWatcher / looksLikeBridge");
|
|
176
|
+
assert.ok(second.includes("function windowsProcessCommandLine"),
|
|
177
|
+
"应能切出 windowsProcessCommandLine(Windows 按 pid 查命令行)");
|
|
178
|
+
const make = new Function("IS_WIN", "sh", "spawnSync",
|
|
179
|
+
`${first}\n${second}\nreturn { processCommandLine, looksLikeWatcher, looksLikeBridge, windowsProcessCommandLine };`);
|
|
180
|
+
return make(true, posixBranchSh, spawnSync);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 加载 pid 文件三件套(写在 run() 里、被「只删自己的」契约约束)。 */
|
|
184
|
+
function loadPidFileHelpers(configDir) {
|
|
185
|
+
return loadSetupFns({
|
|
186
|
+
start: "function logSetupIssue(line) {",
|
|
187
|
+
end: "function processCommandLine(pid) {",
|
|
188
|
+
params: { CONFIG_DIR: configDir, fs, path },
|
|
189
|
+
exports: ["logSetupIssue", "writePidFile", "removePidFile", "readPidFile"],
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 跑 `dsh-setup.mjs doctor`(子进程;env 全部显式给,不改本进程环境)。 */
|
|
194
|
+
function runDoctor(env, extra = {}) {
|
|
195
|
+
const r = spawnSync(process.execPath, [SETUP, "doctor"], {
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
timeout: 90000,
|
|
198
|
+
env: {
|
|
199
|
+
...process.env,
|
|
200
|
+
HOME: env.home,
|
|
201
|
+
PATH: `${env.fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
|
202
|
+
DSH_RELAY_DIR: env.relayDir,
|
|
203
|
+
DSH_SETUP_PLATFORM: "win32", // 平台覆盖:让 macOS/Linux 也真的走 win32 的扫描分支
|
|
204
|
+
DSH_REMOTE_TELEMETRY: "0",
|
|
205
|
+
DSH_RELAY_DEFAULT_API: "http://127.0.0.1:1",
|
|
206
|
+
DSH_RELAY_SKIP_SERVICE: "1",
|
|
207
|
+
DSH_TEST_CIM_LOG: env.cimLog,
|
|
208
|
+
...extra,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
return { out: String(r.stdout || "") + String(r.stderr || ""), status: r.status };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CIM_WATCHER = {
|
|
215
|
+
ProcessId: 1111,
|
|
216
|
+
Name: "DeepSeek Harness.exe",
|
|
217
|
+
CommandLine: '"C:\\Program Files\\DeepSeek Harness\\DeepSeek Harness.exe" "C:\\Users\\u\\.dsh-remote\\dsh-setup.mjs" run',
|
|
218
|
+
};
|
|
219
|
+
const CIM_BRIDGE = {
|
|
220
|
+
ProcessId: 2222,
|
|
221
|
+
Name: "DeepSeek Harness.exe", // ← 关键:不是 node.exe(Electron 宿主回落解释器)
|
|
222
|
+
CommandLine: '"C:\\Program Files\\DeepSeek Harness\\DeepSeek Harness.exe" "C:\\Users\\u\\.dsh-remote\\clients\\dsh-remote\\dsh-bridge.mjs"',
|
|
223
|
+
};
|
|
224
|
+
const CIM_UNRELATED = {
|
|
225
|
+
ProcessId: 3333,
|
|
226
|
+
Name: "node.exe",
|
|
227
|
+
CommandLine: '"C:\\Program Files\\nodejs\\node.exe" -e "setTimeout(() => {}, 120000)"',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ─────────────────── ① listRuntimeBridgePids:非 node.exe 的 bridge 必须被认出 ───────────────────
|
|
231
|
+
|
|
232
|
+
test("★ listRuntimeBridgePids:非 node.exe 命名的 bridge 也要能识别(旧实现 Name='node.exe' 过滤必漏)", () => {
|
|
233
|
+
const env = makeEnv();
|
|
234
|
+
try {
|
|
235
|
+
const thisDir = path.join(env.root, "runtime-dir", ".dsh-remote");
|
|
236
|
+
const thisBridge = path.join(thisDir, "clients", "dsh-remote", "dsh-bridge.mjs");
|
|
237
|
+
const otherBridge = path.join(env.root, "other-runtime", "clients", "dsh-remote", "dsh-bridge.mjs");
|
|
238
|
+
const cim = [
|
|
239
|
+
// Electron 宿主回落的解释器:镜像名 = 桌面端 exe(旧实现 100% 漏)
|
|
240
|
+
{ ProcessId: 4101, Name: "DeepSeek Harness.exe", CommandLine: `"C:\\Apps\\DeepSeek Harness.exe" "${thisBridge}"` },
|
|
241
|
+
// 真 node 当解释器(旧实现能扫到)
|
|
242
|
+
{ ProcessId: 4102, Name: "node.exe", CommandLine: `"C:\\Program Files\\nodejs\\node.exe" "${thisBridge}"` },
|
|
243
|
+
// 另一个运行时目录的 bridge:**不许**算进本运行时(严格性不能丢,否则跨配置目录误杀)
|
|
244
|
+
{ ProcessId: 4103, Name: "node.exe", CommandLine: `"node.exe" "${otherBridge}"` },
|
|
245
|
+
// 无关进程(PID 复用场景):命令行不含 dsh-bridge.mjs
|
|
246
|
+
{ ProcessId: 4104, Name: "node.exe", CommandLine: '"node.exe" -e "setTimeout(() => {}, 120000)"' },
|
|
247
|
+
// watcher 不是 bridge:不能混进来
|
|
248
|
+
{ ProcessId: 4105, Name: "DeepSeek Harness.exe", CommandLine: `"DeepSeek Harness.exe" "${path.join(thisDir, "dsh-setup.mjs")}" run` },
|
|
249
|
+
];
|
|
250
|
+
const fns = loadPidDiscovery(thisDir);
|
|
251
|
+
const pids = withEnv({
|
|
252
|
+
PATH: `${env.fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
|
253
|
+
DSH_TEST_CIM_JSON: JSON.stringify(cim),
|
|
254
|
+
DSH_TEST_CIM_LOG: env.cimLog,
|
|
255
|
+
DSH_TEST_CIM_FAIL: undefined,
|
|
256
|
+
}, () => fns.listRuntimeBridgePids());
|
|
257
|
+
|
|
258
|
+
assert.deepEqual(pids, [4101, 4102],
|
|
259
|
+
`必须认出「Electron 命名的桥 + node.exe 的桥」,且排除别的运行时/无关进程/watcher(实际 ${JSON.stringify(pids)})`);
|
|
260
|
+
|
|
261
|
+
// 反向证据:新实现的 CIM 脚本**不得**再带 Name 过滤(假 powershell 会如实记录)
|
|
262
|
+
const log = readFileSync(env.cimLog, "utf8");
|
|
263
|
+
assert.ok(log.trim().length > 0, "扫描应该真的执行过(假 powershell 没被调用?)");
|
|
264
|
+
assert.ok(!/"hasNameFilter":true/.test(log),
|
|
265
|
+
"win32 的 CIM 枚举不得再带 Name='node.exe' 过滤(Electron 宿主下必然全漏)");
|
|
266
|
+
} finally { env.cleanup(); }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("★ listRuntimeBridgePids:扫描不可用 → 空数组(宁漏杀不误杀,绝不因扫不到就当没有)", () => {
|
|
270
|
+
const env = makeEnv();
|
|
271
|
+
try {
|
|
272
|
+
const fns = loadPidDiscovery(path.join(env.root, "runtime-dir"));
|
|
273
|
+
const pids = withEnv({
|
|
274
|
+
PATH: `${env.fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
|
275
|
+
DSH_TEST_CIM_JSON: JSON.stringify([CIM_BRIDGE]),
|
|
276
|
+
DSH_TEST_CIM_LOG: env.cimLog,
|
|
277
|
+
DSH_TEST_CIM_FAIL: "1", // 模拟没 PowerShell / 被策略禁用 / 超时
|
|
278
|
+
}, () => fns.listRuntimeBridgePids());
|
|
279
|
+
assert.deepEqual(pids, [], "扫描不可用时必须返回空(失败方向是漏杀,不能拿空结果当'确认没有'去杀别的东西)");
|
|
280
|
+
assert.equal(fns.windowsCimProcesses(), null, "windowsCimProcesses 必须用 null 表示「扫描不可用」,与空数组 [] 区分");
|
|
281
|
+
} finally { env.cleanup(); }
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ─────────────────── ② doctor:不再输出恒 0/0 的假证据 ───────────────────
|
|
285
|
+
|
|
286
|
+
test("★ doctor:win32 进程计数与 fake CIM 一致,并带 pid/解释器/命令行摘要(旧实现 tasklist 无命令行 ⇒ 恒 0/0)", () => {
|
|
287
|
+
const env = makeEnv();
|
|
288
|
+
try {
|
|
289
|
+
const { out } = runDoctor(env, { DSH_TEST_CIM_JSON: JSON.stringify([CIM_WATCHER, CIM_BRIDGE, CIM_UNRELATED]) });
|
|
290
|
+
assert.match(out, /③ 进程与自启动/, "doctor 的进程段必须存在");
|
|
291
|
+
assert.match(out, /实测进程\s*: watcher 1 个 \/ bridge 1 个/,
|
|
292
|
+
`计数必须与 fake CIM 一致(旧实现恒 0/0):\n${out}`);
|
|
293
|
+
assert.match(out, /· watcher pid=1111 解释器=DeepSeek Harness\.exe 命令行=/,
|
|
294
|
+
"必须打出 watcher 的 pid + 解释器名(下次取证一眼看出谁在跑)");
|
|
295
|
+
assert.match(out, /· bridge pid=2222 解释器=DeepSeek Harness\.exe 命令行=/,
|
|
296
|
+
"必须打出 bridge 的 pid + 解释器名(Electron 宿主下不是 node.exe)");
|
|
297
|
+
assert.match(out, /dsh-bridge\.mjs/, "命令行摘要要能看出是哪个脚本");
|
|
298
|
+
assert.ok(!/pid=3333/.test(out), "无关进程(node -e)不得被算成 watcher/bridge");
|
|
299
|
+
// 平台覆盖要如实标注:否则"平台 darwin 却走 win32 扫描"根本没法解释
|
|
300
|
+
assert.match(out, /平台\s*:.*win32.*DSH_SETUP_PLATFORM 覆盖/,
|
|
301
|
+
"DSH_SETUP_PLATFORM 覆盖时 doctor 必须把这件事写在报告里");
|
|
302
|
+
} finally { env.cleanup(); }
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("★ doctor:扫描不可用时显式打印「扫描不可用」,绝不输出会被读成「没有进程」的 0", () => {
|
|
306
|
+
const env = makeEnv();
|
|
307
|
+
try {
|
|
308
|
+
const { out } = runDoctor(env, { DSH_TEST_CIM_FAIL: "1", DSH_TEST_CIM_JSON: "[]" });
|
|
309
|
+
assert.match(out, /实测进程\s*: 扫描不可用/, `扫描失败必须如实说不可用:\n${out}`);
|
|
310
|
+
assert.match(out, /不能\*\*当作|不能当作/, "要明确提示这条结论不能当「进程没在跑」的证据");
|
|
311
|
+
assert.ok(!/实测进程\s*: watcher 0 个 \/ bridge 0 个/.test(out),
|
|
312
|
+
"🔴 旧实现的核心假证据:扫描失败时输出 0/0 —— 这条绝不能再出现");
|
|
313
|
+
} finally { env.cleanup(); }
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─────────────────── ③ looksLikeWatcher / looksLikeBridge:补 Windows 分支 ───────────────────
|
|
317
|
+
|
|
318
|
+
test("★ looksLikeWatcher/looksLikeBridge:Windows 按 pid 查命令行(旧实现只用 ps ⇒ Windows 恒 false)", () => {
|
|
319
|
+
const env = makeEnv();
|
|
320
|
+
const fns = loadLookalike();
|
|
321
|
+
// 环境(PATH 上的假 powershell + DSH_TEST_CMDLINE)必须在调用**执行期间**有效
|
|
322
|
+
const withCmdline = (cmdline, fn) => withEnv({
|
|
323
|
+
PATH: `${env.fakeBin}${path.delimiter}${process.env.PATH || ""}`,
|
|
324
|
+
DSH_TEST_CMDLINE: cmdline,
|
|
325
|
+
DSH_TEST_CIM_FAIL: undefined,
|
|
326
|
+
}, fn);
|
|
327
|
+
try {
|
|
328
|
+
// watcher:Electron 宿主(process.execPath)+ dsh-setup.mjs run
|
|
329
|
+
withCmdline('"C:\\Program Files\\DeepSeek Harness\\DeepSeek Harness.exe" "C:\\Users\\u\\.dsh-remote\\dsh-setup.mjs" run', () => {
|
|
330
|
+
assert.equal(fns.looksLikeWatcher(5001), true, "Windows 上必须能认出 watcher(旧实现 ps 不存在 ⇒ 恒 false)");
|
|
331
|
+
assert.equal(fns.looksLikeBridge(5001), false, "watcher 不能被当成 bridge");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// bridge:命令行走 Windows 反斜杠路径(Electron 宿主命名,非 node.exe)
|
|
335
|
+
withCmdline('"C:\\Program Files\\DeepSeek Harness\\DeepSeek Harness.exe" "C:\\Users\\u\\.dsh-remote\\clients\\dsh-remote\\dsh-bridge.mjs"', () => {
|
|
336
|
+
assert.equal(fns.looksLikeBridge(5002), true, "Windows 上必须能认出 bridge(clients\\dsh-remote + dsh-bridge.mjs)");
|
|
337
|
+
assert.equal(fns.looksLikeWatcher(5002), false, "bridge 不能被当成 watcher(接管时会对它 SIGTERM)");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// PID 复用:pid 活着但命令行是无关进程 → 两个都必须是 false(否则会误杀一棵无关进程树)
|
|
341
|
+
withCmdline('"C:\\Program Files\\nodejs\\node.exe" -e "setTimeout(() => {}, 120000)"', () => {
|
|
342
|
+
assert.equal(fns.looksLikeWatcher(5003), false, "★认不出就绝不动(PID 复用安全)");
|
|
343
|
+
assert.equal(fns.looksLikeBridge(5003), false, "★认不出就绝不动(PID 复用安全)");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// 取不到命令行(PowerShell 不可用)→ false(失败方向必须是"漏杀")
|
|
347
|
+
withEnv({ PATH: `${env.fakeBin}${path.delimiter}${process.env.PATH || ""}`, DSH_TEST_CIM_FAIL: "1", DSH_TEST_CMDLINE: undefined },
|
|
348
|
+
() => {
|
|
349
|
+
assert.equal(fns.looksLikeWatcher(5004), false, "取不到命令行 → 不认");
|
|
350
|
+
assert.equal(fns.looksLikeBridge(5004), false, "取不到命令行 → 不认");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// 源码级:Windows 分支必须真的存在(这是本条修复的"为什么")
|
|
354
|
+
assert.match(SETUP_SRC, /if \(IS_WIN\) return windowsProcessCommandLine\(pid\);/,
|
|
355
|
+
"processCommandLine 必须有 Windows 分支(Windows 没有 ps)");
|
|
356
|
+
} finally { env.cleanup(); }
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ─────────────────── ④ pid 文件:只删自己的 + 失败留痕 ───────────────────
|
|
360
|
+
|
|
361
|
+
test("★ pid 文件归属:removePidFile 只在内容等于自己的 pid 时才删(旧实现无条件删 = 事故根因链第 2 步)", () => {
|
|
362
|
+
const env = makeEnv();
|
|
363
|
+
try {
|
|
364
|
+
const fns = loadPidFileHelpers(env.relayDir);
|
|
365
|
+
const file = path.join(env.relayDir, ".dsh-bridge.pid");
|
|
366
|
+
|
|
367
|
+
fns.writePidFile(".dsh-bridge.pid", 4321);
|
|
368
|
+
assert.equal(readFileSync(file, "utf8"), "4321", "writePidFile 应写入纯数字 pid");
|
|
369
|
+
|
|
370
|
+
fns.removePidFile(".dsh-bridge.pid", 9999); // 别的实例的 pid
|
|
371
|
+
assert.ok(existsSync(file), "★内容不等于自己的 pid → 一个字节都不许动(旧实现无条件删把孤儿活着的证据删掉)");
|
|
372
|
+
assert.equal(readFileSync(file, "utf8"), "4321");
|
|
373
|
+
|
|
374
|
+
fns.removePidFile(".dsh-bridge.pid", 4321); // 自己的
|
|
375
|
+
assert.ok(!existsSync(file), "内容等于自己的 pid → 必须删掉");
|
|
376
|
+
|
|
377
|
+
// 极端情况:文件已被别人改写/删除 → 不报错、不误删
|
|
378
|
+
fns.writePidFile(".dsh-watcher.pid", 5555);
|
|
379
|
+
fns.writePidFile(".dsh-watcher.pid", 6666); // 被改写
|
|
380
|
+
fns.removePidFile(".dsh-watcher.pid", 5555);
|
|
381
|
+
assert.equal(readFileSync(path.join(env.relayDir, ".dsh-watcher.pid"), "utf8"), "6666",
|
|
382
|
+
"文件被改写后,旧 pid 的持有者不得删掉它");
|
|
383
|
+
} finally { env.cleanup(); }
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("★ pid 文件写/删失败必须留痕(旧实现静默 catch ⇒ 文件'莫名消失'永远查不到原因)", () => {
|
|
387
|
+
const env = makeEnv();
|
|
388
|
+
try {
|
|
389
|
+
const fns = loadPidFileHelpers(env.relayDir);
|
|
390
|
+
const logFile = path.join(env.relayDir, ".dsh-setup-install.log");
|
|
391
|
+
|
|
392
|
+
// 写失败:父目录不存在 → ENOENT
|
|
393
|
+
fns.writePidFile(path.join("不存在目录", "x.pid"), 7);
|
|
394
|
+
let log = readFileSync(logFile, "utf8");
|
|
395
|
+
assert.match(log, /写 pid 文件失败/, "写失败必须留一行日志");
|
|
396
|
+
assert.match(log, /ENOENT/, "日志要带错误码(否则还是查不到原因)");
|
|
397
|
+
assert.match(log, /不存在目录/, "日志要带文件名");
|
|
398
|
+
|
|
399
|
+
// 删失败:目标是个目录 → EISDIR
|
|
400
|
+
mkdirSync(path.join(env.relayDir, "dir-as-pid.pid"));
|
|
401
|
+
fns.removePidFile("dir-as-pid.pid");
|
|
402
|
+
log = readFileSync(logFile, "utf8");
|
|
403
|
+
assert.match(log, /删除 pid 文件失败/, "删失败也必须留一行日志");
|
|
404
|
+
assert.match(log, /EISDIR|EPERM|EACCES/, "删失败日志要带错误码");
|
|
405
|
+
} finally { env.cleanup(); }
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("★ run() 的 pid 接线:bridge 退出/man 退出清理都必须带 expectPid(源码契约)", () => {
|
|
409
|
+
// 行为级覆盖不到 run()(要起真 watcher/bridge),按仓库既有手法锁源码接线。
|
|
410
|
+
assert.ok(!/removePidFile\(BRIDGE_PID_FILE\);/.test(SETUP_SRC),
|
|
411
|
+
"🔴 不得再无条件删 bridge pid 文件(事故根因链第 2 步)");
|
|
412
|
+
assert.ok(!/removePidFile\(WATCHER_PID_FILE\);/.test(SETUP_SRC),
|
|
413
|
+
"watcher pid 文件同样只删自己的");
|
|
414
|
+
|
|
415
|
+
const exitAt = SETUP_SRC.indexOf('bridgeProc.on("exit"');
|
|
416
|
+
assert.ok(exitAt > 0, "应能找到 bridge 子进程的 exit 接线");
|
|
417
|
+
const exitBlock = SETUP_SRC.slice(exitAt, exitAt + 1200);
|
|
418
|
+
assert.match(exitBlock, /removePidFile\(BRIDGE_PID_FILE, spawnedBridgePid\)/,
|
|
419
|
+
"bridge 退出时只删**这个子进程**的 pid 文件(内容 == spawnedBridgePid)");
|
|
420
|
+
|
|
421
|
+
const cleanupAt = SETUP_SRC.indexOf("const cleanup = () => {");
|
|
422
|
+
assert.ok(cleanupAt > 0, "应能找到 watcher 的退出清理");
|
|
423
|
+
const cleanupBlock = SETUP_SRC.slice(cleanupAt, cleanupAt + 1200);
|
|
424
|
+
assert.match(cleanupBlock, /removePidFile\(WATCHER_PID_FILE, process\.pid\)/,
|
|
425
|
+
"watcher 退出只删自己的 pid 文件");
|
|
426
|
+
assert.match(cleanupBlock, /removePidFile\(BRIDGE_PID_FILE, childPid\)/,
|
|
427
|
+
"watcher 退出只删自己子进程的 bridge pid 文件");
|
|
428
|
+
|
|
429
|
+
const reapAt = SETUP_SRC.indexOf("removePidFile(BRIDGE_PID_FILE, leftover)");
|
|
430
|
+
assert.ok(reapAt > 0, "回收上一任 bridge 后删 pid 文件时也要带 leftover(已经过 looksLikeBridge 校验)");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ─────────────────── ⑤ bridge 自证:pid + 心跳新鲜度 ───────────────────
|
|
434
|
+
|
|
435
|
+
test("★ bridge 自证:启动写 pid、心跳拍节流刷新 heartbeat_at(供插件做第二存活判据)", () => {
|
|
436
|
+
assert.match(BRIDGE_SRC, /persistBridgeState\(\{ pid: process\.pid, heartbeat_at: Date\.now\(\), device_id: DEVICE_ID, started_at: Date\.now\(\)/,
|
|
437
|
+
"启动 reset 处必须写入 pid(插件据此判断 state.pid 是否活着)");
|
|
438
|
+
assert.match(BRIDGE_SRC, /STATE_HEARTBEAT_MIN_MS = 10_000/, "心跳刷新要节流(≥10s,避免写盘过频)");
|
|
439
|
+
assert.match(BRIDGE_SRC, /function stampBridgeHeartbeat\(\)/, "应有心跳刷新函数");
|
|
440
|
+
const fn = BRIDGE_SRC.slice(
|
|
441
|
+
BRIDGE_SRC.indexOf("function stampBridgeHeartbeat()"),
|
|
442
|
+
BRIDGE_SRC.indexOf("// ---------- 安装来源 / 版本"),
|
|
443
|
+
);
|
|
444
|
+
assert.match(fn, /if \(now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS\) return;/,
|
|
445
|
+
"心跳刷新必须真的节流");
|
|
446
|
+
assert.match(fn, /heartbeat_at: now/, "心跳刷新必须更新 heartbeat_at");
|
|
447
|
+
|
|
448
|
+
const beatAt = BRIDGE_SRC.indexOf("const beat = () => {");
|
|
449
|
+
assert.ok(beatAt > 0, "应能找到隧道心跳拍");
|
|
450
|
+
const beatBlock = BRIDGE_SRC.slice(beatAt, beatAt + 700);
|
|
451
|
+
assert.match(beatBlock, /stampBridgeHeartbeat\(\)/,
|
|
452
|
+
"★心跳拍里必须调用自证刷新(否则状态文件永远停在启动那一刻)");
|
|
453
|
+
// 2xx/4xx 语义不变:状态文件只是附加判据,写失败静默(persistBridgeState 内部已兜底)
|
|
454
|
+
assert.match(BRIDGE_SRC, /\.dsh-bridge-state\.json/, "状态文件名必须保持不变(插件侧按这个名字读)");
|
|
455
|
+
});
|
|
@@ -1005,3 +1005,53 @@ test("★ 通道下发 caps: [] → 面板不得回退本地镜像(否则会展
|
|
|
1005
1005
|
"★空 caps 时一个能力都不能显示为可用(回退镜像就会显示一排 ✅)");
|
|
1006
1006
|
assert.ok(texts.some((t) => t.indexOf("🔒") === 0), "应显示为不可用");
|
|
1007
1007
|
});
|
|
1008
|
+
|
|
1009
|
+
// ---------------------------------------------------------------------------
|
|
1010
|
+
// 推送静默(2026-10-29 契约 §4):context_token 过期后平台不给续期,bridge 会暂停主动推送。
|
|
1011
|
+
// 面板要做的是「如实说明 + 给一条恢复动作」,且**不**改变两态绑定判定;
|
|
1012
|
+
// 旧 bridge 没有这些新字段时一个字都不能多显示(字段缺失 ≠ 已静默)。
|
|
1013
|
+
// ---------------------------------------------------------------------------
|
|
1014
|
+
const PAUSED_TEXT = "微信推送已静默";
|
|
1015
|
+
|
|
1016
|
+
test("推送静默:push_paused=true 时在已绑定卡上给出恢复提示(仍是已绑定、绑定/解绑按钮都在)", async () => {
|
|
1017
|
+
const plugin = loadPlugin({
|
|
1018
|
+
status: {
|
|
1019
|
+
...BOUND, push_paused: true, push_paused_at: 1_700_000_800_000,
|
|
1020
|
+
push_paused_reason: "sendMessage: ret=-2 prepare failed"
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
const tree = await plugin.settle();
|
|
1024
|
+
assert.ok(textHas(tree, PAUSED_TEXT), "必须说明推送已静默(否则用户以为机器人坏了/要重新绑定)");
|
|
1025
|
+
assert.ok(textHas(tree, "回任意一条消息即可恢复"), "要给出唯一可执行的动作");
|
|
1026
|
+
assert.ok(textHas(tree, "未送达的通知会补发"), "要说明未送达的通知不会静默丢失");
|
|
1027
|
+
assert.ok(textHas(tree, "已绑定"), "仍是已绑定态:静默只是健康提示,不是第三种绑定状态");
|
|
1028
|
+
assert.ok(!textHas(tree, "未绑定"), "★静默绝不能把面板判成未绑定(那会诱导用户重新扫码)");
|
|
1029
|
+
assert.ok(buttonWithText(tree, "解绑"), "解绑按钮不得被隐藏");
|
|
1030
|
+
assert.equal(buttonWithText(tree, "连接微信机器人"), undefined, "已绑定时仍不应出现「连接微信机器人」");
|
|
1031
|
+
// 复用既有 warn 样式 + 可播报:不引入任何新配色,也不需要读屏用户盯着屏幕才能知道
|
|
1032
|
+
const warn = find(tree, (n) => hasClass(n, "dru-msg-warn") && n.props?.role === "status"
|
|
1033
|
+
&& (n.children || []).some((c) => typeof c === "string" && c.includes(PAUSED_TEXT)));
|
|
1034
|
+
assert.ok(warn, "静默提示必须是 dru-msg dru-msg-warn + role=status 的节点");
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("推送静默:pending_outbox_count>0 给一行「待补发 N 条」,且不与旧的长文案重复", async () => {
|
|
1038
|
+
const plugin = loadPlugin({ status: { ...BOUND, push_paused: true, pending_outbox_count: 3 } });
|
|
1039
|
+
const tree = await plugin.settle();
|
|
1040
|
+
assert.ok(textHas(tree, "待补发 3 条"), "静默期间要能看到待补发条数");
|
|
1041
|
+
assert.ok(!textHas(tree, "条通知刚才没能发出去"),
|
|
1042
|
+
"★静默态已由「待补发 N 条」说明,不再重复整段长文案(同一件事提示两遍 = 噪音)");
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("推送静默:旧 bridge 没有 push_paused(字段缺失/为 null)→ 不渲染提示,且待补发旧字段展示保持", async () => {
|
|
1046
|
+
const plugin = loadPlugin({ status: { ...BOUND, pending_outbox: 2 } });
|
|
1047
|
+
const tree = await plugin.settle();
|
|
1048
|
+
assert.ok(!textHas(tree, PAUSED_TEXT), "★字段缺失就是旧 bridge,不能当成「已静默」渲染");
|
|
1049
|
+
assert.ok(textHas(tree, "有 2 条通知刚才没能发出去"), "旧字段 pending_outbox 的既有展示必须保持(回归)");
|
|
1050
|
+
assert.ok(textHas(tree, "已绑定"));
|
|
1051
|
+
|
|
1052
|
+
// null 也不得被当成「已静默」或「0 条」:null 只是"没给",回落 pending_outbox 的 2
|
|
1053
|
+
const nul = loadPlugin({ status: { ...BOUND, push_paused: null, pending_outbox_count: null, pending_outbox: 2 } });
|
|
1054
|
+
const tree2 = await nul.settle();
|
|
1055
|
+
assert.ok(!textHas(tree2, PAUSED_TEXT), "push_paused=null 不是 true,不能渲染静默提示");
|
|
1056
|
+
assert.ok(textHas(tree2, "有 2 条通知刚才没能发出去"), "★pending_outbox_count=null 不能覆盖掉旧字段的 2 条");
|
|
1057
|
+
});
|