@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.2

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.
Files changed (29) hide show
  1. package/README.md +2 -1
  2. package/clients/dsh-remote/dsh-bridge.mjs +148 -9
  3. package/clients/dsh-remote/dsh-events.mjs +1981 -0
  4. package/clients/dsh-remote/src/lifecycle.mjs +42 -0
  5. package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
  6. package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
  7. package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
  8. package/clients/dsh-remote/test/wechat-channel.test.mjs +2033 -0
  9. package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
  10. package/clients/dsh-remote/test/wechat-runtime.test.mjs +626 -0
  11. package/clients/dsh-remote/wechat-channel.mjs +2705 -0
  12. package/clients/dsh-remote/wechat-runtime.mjs +1260 -0
  13. package/docs/telemetry.md +31 -2
  14. package/dsh-setup.mjs +134 -40
  15. package/package.json +6 -5
  16. package/packages/dsh-remote-web/lib/client.js +799 -65
  17. package/packages/dsh-remote-web/lib/index.js +460 -10
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
  20. package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
  21. package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
  22. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
  23. package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
  24. package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
  25. package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
  26. package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +866 -0
  28. package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
  29. package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * 微信通道**端到端**联调测试。
3
+ *
4
+ * 为什么必须单独有这一个文件:它覆盖的是**集成缝**,而这条缝恰好最容易出问题 ——
5
+ * 上线途中真的在缝里抓到过 bug(两个并行模块的 kind 词表不一致,各自单测全绿、
6
+ * 拼起来用户收到「未知节点」;以及编排层的订阅器事件处理器压根没挂上)。
7
+ *
8
+ * wechat-channel.test.mjs 用**假 client** 测协议与状态机
9
+ * dsh-events.test.mjs 用**假 mux** 测事件订阅
10
+ * wechat-runtime.test.mjs 用**假订阅器** 测编排
11
+ * ← 本文件:三者**全是真的**,只把腾讯 ilink 与 DSH mux 换成假上游
12
+ *
13
+ * 走的正是产品的第一条完整链路:
14
+ * DSH 发出审批 → 通道推送到微信 → 用户回数字 → 回执真的 POST 回 DSH
15
+ */
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import http from "node:http";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { WebSocketServer } from "ws";
24
+
25
+ import { createWeChatRuntime } from "../wechat-runtime.mjs";
26
+ import { saveAccount } from "../wechat-channel.mjs";
27
+
28
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
+
30
+ /** 轮询等待条件成立(避免用固定 sleep 造成 flake)。 */
31
+ async function waitFor(fn, { timeoutMs = 5000, stepMs = 25, label = "条件" } = {}) {
32
+ const deadline = Date.now() + timeoutMs;
33
+ for (;;) {
34
+ const v = await fn();
35
+ if (v) return v;
36
+ if (Date.now() > deadline) throw new Error(`waitFor 超时:${label}`);
37
+ await sleep(stepMs);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 假 DSH:一个 HTTP server 同时承载 `/api/$events/result`(回执 POST)
43
+ * 与 WebSocket 升级(`/api/remote.mux`)。记录收到的回执与开过的流。
44
+ */
45
+ async function fakeDsh() {
46
+ const state = { results: [], streams: [], sockets: [], eventsStreamId: "" };
47
+ const server = http.createServer((req, res) => {
48
+ let body = "";
49
+ req.on("data", (c) => (body += c));
50
+ req.on("end", () => {
51
+ if (req.url && req.url.startsWith("/api/$events/result")) {
52
+ let parsed = null;
53
+ try { parsed = JSON.parse(body); } catch { /* 记录原文 */ }
54
+ state.results.push(parsed || { raw: body });
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(JSON.stringify({ type: "server-response", rpcId: (parsed && parsed.rpcId) || "r", result: { ok: true } }));
57
+ return;
58
+ }
59
+ res.writeHead(404, { "content-type": "application/json" });
60
+ res.end(JSON.stringify({ error: "not found" }));
61
+ });
62
+ });
63
+ const wss = new WebSocketServer({ server, path: "/api/remote.mux" });
64
+ wss.on("connection", (ws) => {
65
+ state.sockets.push(ws);
66
+ ws.on("message", (raw) => {
67
+ let frame = null;
68
+ try { frame = JSON.parse(raw.toString()); } catch { return; }
69
+ if (frame.type !== "open") return;
70
+ state.streams.push({ endpoint: frame.endpoint, streamId: frame.streamId });
71
+ if (frame.endpoint === "$events") {
72
+ // ⚠️ 必须记下订阅器**自己生成的** streamId:回推帧只有带上它才会被
73
+ // `#onTextFrame` 认领(`if (stream === undefined) return;` 会丢掉未知流的帧)。
74
+ // 早先我写死 "ev" 导致帧被丢弃,还误判成"顶层 waterfall 是协议形状" —— 两个错叠在一起。
75
+ state.eventsStreamId = frame.streamId;
76
+ // 真实 DSH 连接后的首帧就是这个形状(含 clientId —— 回执必须带它)
77
+ ws.send(JSON.stringify({
78
+ type: "item",
79
+ streamId: frame.streamId,
80
+ value: { type: "ready", clientId: "cli-e2e-1", host: { home: "/tmp" } }
81
+ }));
82
+ } else if (frame.endpoint === "session/control") {
83
+ ws.send(JSON.stringify({
84
+ type: "item", streamId: frame.streamId,
85
+ value: { type: "baseline", value: { queues: {}, jobs: {}, projections: {} } }
86
+ }));
87
+ } else if (frame.endpoint === "session/follow") {
88
+ ws.send(JSON.stringify({
89
+ type: "item", streamId: frame.streamId,
90
+ value: { type: "snapshot", header: {}, cursor: 0, records: [], projections: {} }
91
+ }));
92
+ }
93
+ });
94
+ });
95
+
96
+ return new Promise((resolve) => {
97
+ server.listen(0, "127.0.0.1", () => {
98
+ const port = server.address().port;
99
+ resolve({
100
+ state,
101
+ baseUrl: `http://127.0.0.1:${port}`,
102
+ /**
103
+ * 推一条审批请求,**按真实 DSH 的线上形状**。
104
+ *
105
+ * ⚠️ 形状经过对 DSH 源码的核对,不是猜的 —— 这里曾是本文件最大的错误来源:
106
+ * 早先我按"顶层 waterfall 帧"构造,于是 e2e 只验证了我自己的假上游,
107
+ * 对真实链路毫无背书。真实形状是**包在 item 里**的:
108
+ * · `dsh-api-gateway/lib/index.js` 的 `pump()`:
109
+ * `for await (const value of source) await this.send({type:"item",streamId,value})`
110
+ * —— **每一个**生成器产出都被这层包住(waterfall 也不例外)。
111
+ * · 生产者在同一文件把 `{type:"waterfall",event,eventId,agentId,request}` 推进
112
+ * per-client queue,queue 的产出就是上面那个 `value`。
113
+ * · DSH **自己的浏览器客户端** `dsh-api-gateway/lib/client.js:729` 同样在
114
+ * `value.type === "waterfall"` 上解析 —— 若真机发顶层帧,DSH 自己的 UI 会把
115
+ * 每一次审批都丢掉,而审批恰恰是那个 UI 在答的。这是最硬的一条反证。
116
+ * · `dsh-client-connection/lib/client.js:5599` 那个顶层字面量是**测试 fixture**
117
+ * (`approvalInvocation`,reason 写着「fixture 常驻审批」),不是协议。
118
+ *
119
+ * 另外 request 里**不能带 `agent` / `signal`**:客户端校验器
120
+ * `hasExactRemoteEventKeys` + `!Object.hasOwn(request,"agent")` 会直接判非法。
121
+ */
122
+ pushApproval({ eventId = "evt-1", agentId = "agent-1", toolName = "Bash", reason = "安装依赖" } = {}) {
123
+ const ws = state.sockets[0];
124
+ assert.ok(ws, "还没有 $events 连接");
125
+ ws.send(JSON.stringify({
126
+ type: "item",
127
+ streamId: state.eventsStreamId,
128
+ value: {
129
+ type: "waterfall",
130
+ event: "approval/request",
131
+ eventId,
132
+ agentId,
133
+ request: { toolName, reason }
134
+ }
135
+ }));
136
+ },
137
+ close: () => new Promise((r) => {
138
+ for (const ws of state.sockets) { try { ws.close(); } catch { /* 忽略 */ } }
139
+ try { wss.close(); } catch { /* 忽略 */ }
140
+ try { server.closeAllConnections?.(); } catch { /* 忽略 */ }
141
+ server.close(() => r());
142
+ })
143
+ });
144
+ });
145
+ });
146
+ }
147
+
148
+ /** 假腾讯 ilink:收 sendmessage、发 getupdates。 */
149
+ async function fakeIlink() {
150
+ const state = { sends: [], queue: [], notifyStart: 0, notifyStop: 0, getUpdates: 0, statusQueue: [] };
151
+ const server = http.createServer((req, res) => {
152
+ let body = "";
153
+ req.on("data", (c) => (body += c));
154
+ req.on("end", () => {
155
+ const url = new URL(req.url, "http://127.0.0.1");
156
+ const reply = (obj) => {
157
+ res.writeHead(200, { "content-type": "application/octet-stream" });
158
+ res.end(JSON.stringify(obj));
159
+ };
160
+ if (url.pathname.endsWith("/getupdates")) {
161
+ state.getUpdates += 1;
162
+ return reply({ ret: 0, msgs: state.queue.splice(0), get_updates_buf: "b" + state.getUpdates });
163
+ }
164
+ if (url.pathname.endsWith("/sendmessage")) {
165
+ try { state.sends.push(JSON.parse(body)); } catch { state.sends.push({ raw: body }); }
166
+ return reply({ ret: 0, message_id: "m" + state.sends.length });
167
+ }
168
+ if (url.pathname.endsWith("/notifystart")) { state.notifyStart += 1; return reply({ ret: 0 }); }
169
+ if (url.pathname.endsWith("/notifystop")) { state.notifyStop += 1; return reply({ ret: 0 }); }
170
+ if (url.pathname.endsWith("/get_bot_qrcode")) {
171
+ return reply({ ret: 0, qrcode: "Q-e2e", qrcode_img_content: "https://liteapp.weixin.qq.com/q/x?qrcode=Q-e2e&bot_type=3" });
172
+ }
173
+ if (url.pathname.endsWith("/get_qrcode_status")) {
174
+ // 脚本化扫码状态机:绑定流程测试靠它推进(qrcode 状态是长轮询,一次一步)
175
+ return reply(state.statusQueue.length ? state.statusQueue.shift() : { status: "wait" });
176
+ }
177
+ return reply({ ret: 0 });
178
+ });
179
+ });
180
+ return new Promise((resolve) => {
181
+ server.listen(0, "127.0.0.1", () => resolve({
182
+ state,
183
+ baseUrl: `http://127.0.0.1:${server.address().port}`,
184
+ /** 模拟用户在微信里发一句话。 */
185
+ userSays(text) {
186
+ state.queue.push({ from_user_id: "user-e2e", client_id: "c1", item_list: [{ type: 1, text_item: { text } }] });
187
+ },
188
+ lastText() {
189
+ const last = state.sends[state.sends.length - 1];
190
+ return last && last.msg && last.msg.item_list && last.msg.item_list[0] && last.msg.item_list[0].text_item
191
+ ? last.msg.item_list[0].text_item.text : "";
192
+ },
193
+ close: () => new Promise((r) => {
194
+ try { server.closeAllConnections?.(); } catch { /* 忽略 */ }
195
+ server.close(() => r());
196
+ })
197
+ }));
198
+ });
199
+ }
200
+
201
+ async function bootE2e() {
202
+ const relayDir = fs.mkdtempSync(path.join(os.tmpdir(), "wc-e2e-"));
203
+ const ilink = await fakeIlink();
204
+ const dsh = await fakeDsh();
205
+ saveAccount(relayDir, { token: "tok-e2e", accountId: "bot-e2e", baseUrl: ilink.baseUrl, userId: "user-e2e", boundAt: 1 });
206
+ // ⚠️ v2 起 beginBind 有**登录门槛**(必须已登录才给二维码,不让用户白扫一次)。
207
+ // 判据是 .dsh-config.json 里有 phone —— 这里必须种一个,否则绑定流程测试会被门槛拦下。
208
+ fs.writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({ phone: "13800000000" }));
209
+ const rt = createWeChatRuntime({
210
+ relayDir,
211
+ upstream: dsh.baseUrl,
212
+ cookieOf: () => "dsh-auth-e2e=1",
213
+ secret: "sec-e2e",
214
+ logger: { info() {}, warn() {}, error() {}, debug() {}, addSecret() {} }
215
+ });
216
+ await rt.start();
217
+ // 等真实的 dsh-events 订阅器连上并拿到 ready
218
+ await waitFor(() => dsh.state.streams.some((s) => s.endpoint === "$events"), { label: "$events 打开" });
219
+ await waitFor(() => dsh.state.sockets.length > 0, { label: "mux 连接" });
220
+ return { rt, ilink, dsh, relayDir, close: async () => { await rt.stop(); await ilink.close(); await dsh.close(); } };
221
+ }
222
+
223
+ test("★ 端到端:DSH 审批 → 微信推送 → 用户回 1 → 回执真的回到 DSH", async () => {
224
+ const { ilink, dsh, close } = await bootE2e();
225
+ try {
226
+ // 1) DSH 侧发出审批请求
227
+ dsh.pushApproval({ eventId: "evt-e2e-1", toolName: "Bash", reason: "安装依赖" });
228
+
229
+ // 2) 通道应把它推成微信消息,且带可回执的编号选项
230
+ await waitFor(() => ilink.state.sends.length > 0, { label: "推送通知" });
231
+ const text = ilink.lastText();
232
+ assert.match(text, /需要你拍板/, `推送文案不对:${text}`);
233
+ assert.match(text, /Bash/, "必须带工具名(否则用户不知道在批准什么)");
234
+ assert.match(text, /安装依赖/, "必须带原因");
235
+ assert.match(text, /回复 1/, "必须给出编号选项(可回执)");
236
+ assert.match(text, /回复 2/);
237
+
238
+ // 3) 用户在微信里回 "1"
239
+ ilink.userSays("1");
240
+ await waitFor(() => dsh.state.results.length > 0, { label: "回执到达 DSH" });
241
+
242
+ // 4) 回执必须是**合法的 DSH 信封**,且 outcome 是 allowed-once
243
+ const body = dsh.state.results[0];
244
+ assert.equal(body.method, "$events/result", `method 不对:${JSON.stringify(body)}`);
245
+ const args = body.payload && body.payload.args;
246
+ assert.ok(args, "缺少 payload.args");
247
+ assert.equal(args.clientId, "cli-e2e-1", "clientId 必须取自 ready 帧,否则服务端认不出是哪条流");
248
+ assert.equal(args.eventId, "evt-e2e-1", "eventId 必须是那条审批的 id");
249
+ assert.deepEqual(args.outcome, { kind: "result", value: "allowed-once" }, "回 1 必须解码成 allowed-once");
250
+
251
+ // 5) 用户应收到确认
252
+ await waitFor(() => ilink.state.sends.length >= 2, { label: "确认回复" });
253
+ assert.match(ilink.lastText(), /已按你的选择/, `确认文案不对:${ilink.lastText()}`);
254
+ } finally {
255
+ await close();
256
+ }
257
+ });
258
+
259
+ test("防御:顶层 waterfall 帧也要能收(未观测形态的兜底,非主路径)", async () => {
260
+ // 主路径是 item 内嵌(真实线上形状,见本文件 pushApproval 的注释)。
261
+ // 这条只保证:万一服务端将来改成顶层下发,我们不会**静默丢掉审批**。
262
+ const { ilink, dsh, close } = await bootE2e();
263
+ try {
264
+ const ws = dsh.state.sockets[0];
265
+ assert.ok(ws, "还没有 mux 连接");
266
+ ws.send(JSON.stringify({
267
+ type: "waterfall",
268
+ event: "approval/request",
269
+ eventId: "evt-top-1",
270
+ agentId: "agent-top",
271
+ request: { toolName: "Bash", reason: "顶层形态兜底" }
272
+ }));
273
+ await waitFor(() => ilink.state.sends.length > 0, { label: "顶层帧也应被识别" });
274
+ assert.match(ilink.lastText(), /需要你拍板/);
275
+ ilink.userSays("1");
276
+ await waitFor(() => dsh.state.results.length > 0, { label: "顶层帧的回执" });
277
+ assert.deepEqual(dsh.state.results[0].payload.args.outcome, { kind: "result", value: "allowed-once" });
278
+ assert.equal(dsh.state.results[0].payload.args.eventId, "evt-top-1");
279
+ } finally {
280
+ await close();
281
+ }
282
+ });
283
+
284
+ test("★ 端到端:回 2 必须解码成 rejected(而不是「也是同意」)", async () => {
285
+ const { ilink, dsh, close } = await bootE2e();
286
+ try {
287
+ // ⚠️ 刻意用**非破坏性**理由:破坏性操作现在会被降级为"回电脑确认"、不给编号选项,
288
+ // 那条路径由下面单独一条用例覆盖。这里要验的是正常的 1/2 映射。
289
+ dsh.pushApproval({ eventId: "evt-e2e-2", toolName: "Bash", reason: "跑一下单元测试" });
290
+ await waitFor(() => ilink.state.sends.length > 0, { label: "推送" });
291
+ ilink.userSays("2");
292
+ await waitFor(() => dsh.state.results.length > 0, { label: "回执" });
293
+ const args = dsh.state.results[0].payload.args;
294
+ assert.deepEqual(args.outcome, { kind: "result", value: "rejected" });
295
+ assert.equal(args.eventId, "evt-e2e-2");
296
+ } finally {
297
+ await close();
298
+ }
299
+ });
300
+
301
+ test("★ 端到端:没有待办时回数字,不得凭空发出任何回执(过期≠同意)", async () => {
302
+ const { ilink, dsh, close } = await bootE2e();
303
+ try {
304
+ ilink.userSays("1");
305
+ await waitFor(() => ilink.state.sends.length > 0, { label: "回复" });
306
+ assert.match(ilink.lastText(), /没有代你做出任何选择/, `应明确拒绝,实际:${ilink.lastText()}`);
307
+ assert.equal(dsh.state.results.length, 0, "绝不能在没有待办时发出回执");
308
+ } finally {
309
+ await close();
310
+ }
311
+ });
312
+
313
+ test("★ 端到端:同一条审批回两次,只回执一次", async () => {
314
+ const { ilink, dsh, close } = await bootE2e();
315
+ try {
316
+ dsh.pushApproval({ eventId: "evt-e2e-3" });
317
+ await waitFor(() => ilink.state.sends.length > 0, { label: "推送" });
318
+ ilink.userSays("1");
319
+ await waitFor(() => dsh.state.results.length === 1, { label: "第一次回执" });
320
+ ilink.userSays("1");
321
+ await sleep(400);
322
+ assert.equal(dsh.state.results.length, 1, "第二次回复不得再产生回执");
323
+ } finally {
324
+ await close();
325
+ }
326
+ });
327
+
328
+ test("★ 端到端:通道上线要通知腾讯侧(notifystart),退出要 notifystop", async () => {
329
+ const { ilink, close } = await bootE2e();
330
+ try {
331
+ await waitFor(() => ilink.state.notifyStart > 0, { label: "notifystart" });
332
+ assert.ok(ilink.state.notifyStart > 0, "上线必须 notifystart —— 否则腾讯侧不知道通道在跑");
333
+ } finally {
334
+ await close();
335
+ }
336
+ assert.ok(ilink.state.notifyStop > 0, "优雅退出必须 notifystop —— 否则腾讯侧会一直以为通道在线");
337
+ });
338
+
339
+ test("端到端:面板控制面在真实运行时下可用,且不回显 token", async () => {
340
+ const { rt, dsh, close } = await bootE2e();
341
+ try {
342
+ const port = rt.boundPort;
343
+ assert.ok(port > 0, "控制面应已监听");
344
+ const r = await fetch(`http://127.0.0.1:${port}/wechat/status`, { headers: { "x-dsh-bridge-secret": "sec-e2e" } });
345
+ assert.equal(r.status, 200);
346
+ const s = await r.json();
347
+ assert.equal(s.bound, true);
348
+ assert.equal(s.channel_running, true);
349
+ assert.ok(!JSON.stringify(s).includes("tok-e2e"), `状态泄漏 token:${JSON.stringify(s)}`);
350
+ // 未带密钥必须 401
351
+ assert.equal((await fetch(`http://127.0.0.1:${port}/wechat/status`)).status, 401);
352
+ void dsh;
353
+ } finally {
354
+ await close();
355
+ }
356
+ });
357
+
358
+ test("★ 绑定流程走**真实控制面 API**(面板就是这么调的):取二维码 → 轮询 → 配对码 → 已绑定", async () => {
359
+ // 这条补的是最后一个没人测过的缝:面板宿主半边代理 ↔ bridge 真实控制面。
360
+ // 两边各自都有测试,但一边用假 bridge、另一边直接调对象 —— 没有一条串起来过。
361
+ const relayDir = fs.mkdtempSync(path.join(os.tmpdir(), "wc-bind-"));
362
+ const ilink = await fakeIlink();
363
+ const dsh = await fakeDsh();
364
+ // v2 登录门槛:这条路要验的是**绑定流程本身**,所以先满足"已登录"前提
365
+ fs.writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({ phone: "13800000000" }));
366
+ // 脚本化:wait → need_verifycode → scaned → confirmed
367
+ ilink.state.statusQueue.push(
368
+ { status: "wait" },
369
+ { status: "need_verifycode" },
370
+ { status: "scaned" },
371
+ { status: "confirmed", bot_token: "tok-bind", ilink_bot_id: "bot-bind", baseurl: ilink.baseUrl, ilink_user_id: "u-bind" }
372
+ );
373
+ const rt = createWeChatRuntime({
374
+ relayDir,
375
+ upstream: dsh.baseUrl,
376
+ cookieOf: () => "x=1",
377
+ secret: "sec-bind",
378
+ baseUrl: ilink.baseUrl,
379
+ logger: { info() {}, warn() {}, error() {}, debug() {}, addSecret() {} }
380
+ });
381
+ const H = { "x-dsh-bridge-secret": "sec-bind", "content-type": "application/json" };
382
+ const call = async (pathname, init = {}) => {
383
+ const r = await fetch(`http://127.0.0.1:${rt.boundPort}${pathname}`, { headers: H, ...init });
384
+ return { status: r.status, body: await r.json() };
385
+ };
386
+ try {
387
+ await rt.startControl();
388
+ const st0 = await call("/wechat/status");
389
+ assert.equal(st0.body.bound, false, "初始应是未绑定");
390
+
391
+ const started = await call("/wechat/bind/start", { method: "POST" });
392
+ assert.equal(started.body.ok, true, `取二维码失败:${JSON.stringify(started.body)}`);
393
+ assert.match(started.body.qrcode_svg, /^data:image\/svg\+xml;base64,/, "面板要的是一张能直接 <img src> 的 data URL");
394
+
395
+ // 推进到 need_verifycode —— 这一步是真实的:手机扫码后微信会让用户输数字配对码
396
+ const states = [];
397
+ let needCode = false;
398
+ for (let i = 0; i < 6; i++) {
399
+ const r = await call("/wechat/bind/poll");
400
+ states.push(r.body.state);
401
+ if (r.body.need_verify_code) { needCode = true; break; }
402
+ }
403
+ assert.ok(needCode, `应派出 need_verifycode(否则面板不会弹配对码输入框),实际:${JSON.stringify(states)}`);
404
+
405
+ const verified = await call("/wechat/bind/verify", { method: "POST", body: JSON.stringify({ code: "4321" }) });
406
+ assert.equal(verified.body.ok, true, `提交配对码失败:${JSON.stringify(verified.body)}`);
407
+
408
+ let bound = false;
409
+ for (let i = 0; i < 8; i++) {
410
+ const r = await call("/wechat/bind/poll");
411
+ if (r.body.state === "confirmed" || r.body.bound) { bound = true; break; }
412
+ }
413
+ assert.ok(bound, "绑定应最终走到 confirmed");
414
+
415
+ const st = await call("/wechat/status");
416
+ assert.equal(st.body.bound, true, "绑定后状态必须是已绑定");
417
+ assert.equal(st.body.bot_id, "bot-bind");
418
+ assert.ok(!JSON.stringify(st.body).includes("tok-bind"), `状态泄漏 token:${JSON.stringify(st.body)}`);
419
+ // 凭据确实落盘了(后续长轮询要用)
420
+ assert.ok(fs.existsSync(path.join(relayDir, ".wechat-account.json")), "凭据应已落盘");
421
+ const mode = fs.statSync(path.join(relayDir, ".wechat-account.json")).mode & 0o777;
422
+ assert.equal(mode, 0o600, `凭据文件权限必须是 0600,实际 ${mode.toString(8)}`);
423
+
424
+ const un = await call("/wechat/unbind", { method: "POST" });
425
+ assert.equal(un.body.ok, true);
426
+ assert.equal((await call("/wechat/status")).body.bound, false, "解绑后必须回到未绑定");
427
+ } finally {
428
+ await rt.stop();
429
+ await ilink.close();
430
+ await dsh.close();
431
+ }
432
+ });
433
+
434
+ test("★ 端到端:破坏性操作不给一步回执,必须回电脑确认(安全边界)", async () => {
435
+ const { ilink, dsh, close } = await bootE2e();
436
+ try {
437
+ dsh.pushApproval({ eventId: "evt-destr-1", toolName: "Bash", reason: "rm -rf /important" });
438
+ await waitFor(() => ilink.state.sends.length > 0, { label: "破坏性审批推送" });
439
+ const text = ilink.lastText();
440
+ assert.match(text, /到电脑上确认/, `破坏性操作必须要求回电脑,实际:${text}`);
441
+ assert.ok(!/回复 1/.test(text), "破坏性操作**不得**给出可一步回执的编号选项");
442
+
443
+ // 关键:此时回数字**绝不能**产生回执 —— 一键放行破坏性操作正是要防的事
444
+ ilink.userSays("1");
445
+ await sleep(500);
446
+ assert.equal(dsh.state.results.length, 0, "破坏性操作绝不能被微信一键放行");
447
+ } finally {
448
+ await close();
449
+ }
450
+ });
451
+
452
+ test("★ 端到端:未登录时连二维码都不给(不让用户白扫一次)", async () => {
453
+ const relayDir = fs.mkdtempSync(path.join(os.tmpdir(), "wc-nologin-"));
454
+ const ilink = await fakeIlink();
455
+ const dsh = await fakeDsh();
456
+ // 刻意**不写** .dsh-config.json → 未登录
457
+ const rt = createWeChatRuntime({
458
+ relayDir,
459
+ upstream: dsh.baseUrl,
460
+ cookieOf: () => "x=1",
461
+ secret: "sec-nologin",
462
+ baseUrl: ilink.baseUrl,
463
+ logger: { info() {}, warn() {}, error() {}, debug() {}, addSecret() {} }
464
+ });
465
+ try {
466
+ await rt.startControl();
467
+ const r = await fetch(`http://127.0.0.1:${rt.boundPort}/wechat/bind/start`, {
468
+ method: "POST",
469
+ headers: { "x-dsh-bridge-secret": "sec-nologin", "content-type": "application/json" }
470
+ });
471
+ const body = await r.json();
472
+ assert.equal(body.ok, false, "未登录必须拒绝绑定");
473
+ assert.equal(body.code, "login_required");
474
+ assert.match(body.error, /登录/, `错误信息要能照做,实际:${body.error}`);
475
+ assert.ok(!body.qrcode_svg, "不得下发二维码");
476
+ } finally {
477
+ await rt.stop();
478
+ await ilink.close();
479
+ await dsh.close();
480
+ }
481
+ });