@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,626 @@
1
+ /**
2
+ * 微信通道**编排层**测试(wechat-runtime.mjs)。
3
+ *
4
+ * 这一层的存在理由就是"把两个并行开发的模块拼起来",所以测试的重点也在这里:
5
+ *
6
+ * ① **词表翻译**:事件侧(`dsh-events.mjs`)与文案侧(`wechat-channel.mjs`)的 kind 名不同
7
+ * (`approval-request` vs `approval`),两边各自单测全绿、拼起来却互不认识。
8
+ * 这里锁死每一个 kind 都有归宿,并且上游**新增** kind 时会当场失败。
9
+ * ② **-14 冷却真的生效**:腾讯侧 token 失效时若不退避会猛打接口。
10
+ * ⚠️ 这里守的是一个**真实踩过的坑**:SessionCooldown 没有 isActive(),
11
+ * 写成 `cooldown.isActive && cooldown.isActive()` 会静默短路成 false,冷却形同虚设。
12
+ * ③ **回执编码分派**:审批用 answerApproval(值),提问用 answerQuestion(答案数组),
13
+ * 用错会把审批值塞进提问信封。且选项字段是 `value` 不是 outcome。
14
+ * ④ **控制面安全**:仅回环 + 必须带密钥;**永不回显 bot_token**。
15
+ * ⑤ §6 的两条产品硬约束:过期**绝不**当同意;断线窗口要如实告知。
16
+ */
17
+
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import http from "node:http";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { EventEmitter } from "node:events";
25
+
26
+ import * as rtmod from "../wechat-runtime.mjs";
27
+ import {
28
+ createWeChatRuntime,
29
+ readControlFile,
30
+ assertKindCoverage,
31
+ toFormatterNode,
32
+ CONTROL_HEADER
33
+ } from "../wechat-runtime.mjs";
34
+ import { saveAccount, loadState, formatNotification, SessionCooldown } from "../wechat-channel.mjs";
35
+ import { NODE_KINDS } from "../dsh-events.mjs";
36
+
37
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "wc-rt-"));
38
+ const SECRET = "s3cret-bridge";
39
+
40
+ /** 静默 logger。⚠️ 必须带 addSecret():WeChatChannel 构造时会调用它给 token 脱敏。 */
41
+ const quietLogger = () => ({ info() {}, warn() {}, error() {}, debug() {}, addSecret() {} });
42
+
43
+
44
+ /** 本地假 ilink 服务 —— 测试期网络护栏只允许回环,所以必须自带假上游。 */
45
+ function fakeIlink(handler) {
46
+ const state = { sends: [], notifyStart: 0, notifyStop: 0, getUpdates: 0, queue: [] };
47
+ const server = http.createServer((req, res) => {
48
+ const url = new URL(req.url, "http://127.0.0.1");
49
+ let body = "";
50
+ req.on("data", (c) => (body += c));
51
+ req.on("end", () => {
52
+ const reply = (obj) => {
53
+ res.writeHead(200, { "content-type": "application/octet-stream" });
54
+ res.end(JSON.stringify(obj));
55
+ };
56
+ if (url.pathname.endsWith("/getupdates")) {
57
+ state.getUpdates += 1;
58
+ if (handler && handler.onGetUpdates) return handler.onGetUpdates(state, reply);
59
+ const msgs = state.queue.splice(0);
60
+ return reply({ ret: 0, msgs, get_updates_buf: "buf-" + state.getUpdates, longpolling_timeout_ms: 1 });
61
+ }
62
+ if (url.pathname.endsWith("/sendmessage")) {
63
+ try { state.sends.push(JSON.parse(body)); } catch { state.sends.push({ raw: body }); }
64
+ return reply({ ret: 0, message_id: "m" + state.sends.length });
65
+ }
66
+ if (url.pathname.endsWith("/notifystart")) { state.notifyStart += 1; return reply({ ret: 0 }); }
67
+ if (url.pathname.endsWith("/notifystop")) { state.notifyStop += 1; return reply({ ret: 0 }); }
68
+ if (url.pathname.endsWith("/get_bot_qrcode")) return reply({ ret: 0, qrcode: "Q1", qrcode_img_content: "https://liteapp.weixin.qq.com/q/abc?qrcode=Q1&bot_type=3" });
69
+ if (url.pathname.endsWith("/get_qrcode_status")) return reply({ status: "wait" });
70
+ return reply({ ret: 0 });
71
+ });
72
+ });
73
+ return new Promise((resolve) => {
74
+ server.listen(0, "127.0.0.1", () =>
75
+ resolve({
76
+ server,
77
+ state,
78
+ baseUrl: `http://127.0.0.1:${server.address().port}`,
79
+ // ⚠️ 只 close() 不够:Node 的 fetch(undici)默认 keep-alive,连接不释放会让
80
+ // server.close() 一直等下去 → 整个测试挂死(本文件真的踩过一次,排查成本很高)。
81
+ close: () =>
82
+ new Promise((r) => {
83
+ try { server.closeAllConnections?.(); } catch { /* 老版本无此 API */ }
84
+ server.close(() => r());
85
+ })
86
+ })
87
+ );
88
+ });
89
+ }
90
+
91
+ /** 直接构造一个"已绑定"的运行时(绕过扫码),便于测通道与回执。 */
92
+ async function boundRuntime(ilink, opts = {}) {
93
+ const relayDir = tmp();
94
+ saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "user-1", boundAt: 1 });
95
+ const rt = createWeChatRuntime({
96
+ relayDir,
97
+ upstream: "http://127.0.0.1:1",
98
+ secret: SECRET,
99
+ cooldownMs: opts.cooldownMs,
100
+ logger: quietLogger()
101
+ });
102
+ // 替换订阅器:本文件测编排,不测 DSH 协议(那是 dsh-events.test.mjs 的职责)
103
+ rt.subscriber = opts.subscriber || makeFakeSubscriber();
104
+ return { rt, relayDir };
105
+ }
106
+
107
+ /** 假的 DSH 订阅器:记录回执调用,便于断言分派正确。 */
108
+ function makeFakeSubscriber() {
109
+ const s = new EventEmitter();
110
+ s.calls = [];
111
+ s.answer = async (id, outcome) => { s.calls.push({ m: "answer", id, outcome }); return { ok: true }; };
112
+ s.answerApproval = async (id, value) => { s.calls.push({ m: "answerApproval", id, value }); return { ok: true }; };
113
+ s.answerQuestion = async (id, answers) => { s.calls.push({ m: "answerQuestion", id, answers }); return { ok: true }; };
114
+ s.digestDue = (d = {}) => ({ kind: NODE_KINDS.DIGEST_DUE, notified: d.notified, answered: d.answered, at: 1 });
115
+ s.quotaLow = (d = {}) => ({ kind: NODE_KINDS.QUOTA_LOW, message: d.message, at: 1 });
116
+ s.membershipExpiring = (d = {}) => ({ kind: NODE_KINDS.MEMBERSHIP_EXPIRING, message: d.message, at: 1 });
117
+ s.start = () => {};
118
+ s.close = () => {};
119
+ // ── v2:会话遥控。记录调用,让编排层的分派可被断言 ──
120
+ s.sessionsFixture = [];
121
+ s.summaryFixture = "";
122
+ s.listSessions = async () => { s.calls.push({ m: "listSessions" }); return { ok: true, sessions: s.sessionsFixture }; };
123
+ s.createSession = async (o = {}) => { s.calls.push({ m: "createSession", opts: o }); return { ok: true, sessionId: "session-new-1" }; };
124
+ s.promptSession = async (o = {}) => { s.calls.push({ m: "promptSession", sessionId: o.sessionId, text: o.text }); return { ok: true, accepted: true }; };
125
+ s.cancelSession = async (o = {}) => { s.calls.push({ m: "cancelSession", sessionId: o.sessionId }); return { ok: true }; };
126
+ s.lastAssistantText = async () => ({ ok: true, text: s.summaryFixture });
127
+ return s;
128
+ }
129
+
130
+ const lastText = (state) => {
131
+ const last = state.sends[state.sends.length - 1];
132
+ return last && last.msg && last.msg.item_list && last.msg.item_list[0] && last.msg.item_list[0].text_item
133
+ ? last.msg.item_list[0].text_item.text
134
+ : "";
135
+ };
136
+
137
+ // ── ① 词表翻译 ────────────────────────────────────────────────────────────
138
+
139
+ test("★ 词表覆盖:事件侧每个 kind 都必须有归宿(上游新增节点会当场暴露)", () => {
140
+ assert.equal(assertKindCoverage(), true);
141
+ });
142
+
143
+ test("★ 翻译:事件 kind → 文案 kind 全部命中,不再渲染出「未知节点」", async () => {
144
+ const expect = {
145
+ [NODE_KINDS.APPROVAL_REQUEST]: "approval",
146
+ [NODE_KINDS.USER_QUESTION]: "question",
147
+ [NODE_KINDS.PLAN_REVIEW]: "plan",
148
+ [NODE_KINDS.SESSION_ERROR]: "error",
149
+ [NODE_KINDS.TURN_END]: "stopped",
150
+ [NODE_KINDS.DIGEST_DUE]: "daily",
151
+ [NODE_KINDS.QUOTA_LOW]: "quota",
152
+ [NODE_KINDS.MEMBERSHIP_EXPIRING]: "membership"
153
+ };
154
+ for (const [eventKind, formatterKind] of Object.entries(expect)) {
155
+ const { node } = toFormatterNode({ kind: eventKind });
156
+ assert.ok(node, `${eventKind} 没有翻译出文案节点`);
157
+ assert.equal(node.kind, formatterKind);
158
+ const text = formatNotification(node, {}).text;
159
+ assert.ok(!text.includes("未知节点"), `${eventKind} 渲染成了「未知节点」:${text}`);
160
+ }
161
+ });
162
+
163
+ test("★ 翻译:字段名对齐(toolName→tool / questions→prompt+options / 简报 lines / 停止原因)", () => {
164
+ const a = toFormatterNode({ kind: NODE_KINDS.APPROVAL_REQUEST, toolName: "Bash", reason: "rm -rf" }).node;
165
+ assert.equal(a.tool, "Bash", "toolName 必须映射到 tool,否则文案显示「(未提供)」");
166
+ assert.match(formatNotification(a, {}).text, /工具: Bash/);
167
+
168
+ const q = toFormatterNode({
169
+ kind: NODE_KINDS.USER_QUESTION,
170
+ questions: [{ id: "q1", question: "选哪个?", options: ["A", "B"] }]
171
+ }).node;
172
+ assert.equal(q.prompt, "选哪个?");
173
+ assert.deepEqual(q.options, ["A", "B"], "提问的选项必须被抬到 options,否则用户看不到可回什么");
174
+ const qt = formatNotification(q, {});
175
+ assert.equal(qt.replyable, true);
176
+ assert.equal(qt.options.length, 2);
177
+
178
+ const d = toFormatterNode({ kind: NODE_KINDS.DIGEST_DUE, notified: 3, answered: 1 }).node;
179
+ assert.ok(Array.isArray(d.lines) && d.lines.length, "简报必须有 lines,否则文案是空的");
180
+
181
+ const t = toFormatterNode({ kind: NODE_KINDS.TURN_END, reason: "aborted" }).node;
182
+ assert.match(formatNotification(t, {}).text, /被中断|中止|停止原因/, "停止原因要翻译给用户看");
183
+ });
184
+
185
+ test("特殊节点不该被当成「未接线」:event-expired / gap / fault 走各自的路径", () => {
186
+ for (const k of [NODE_KINDS.EVENT_EXPIRED, NODE_KINDS.GAP, NODE_KINDS.FAULT]) {
187
+ assert.equal(toFormatterNode({ kind: k }).node, null, `${k} 不应有文案模板`);
188
+ assert.equal(assertKindCoverage(), true, `${k} 必须被 SPECIAL 列表认领,否则覆盖自检会误报`);
189
+ }
190
+ });
191
+
192
+ // ── ② -14 冷却 ────────────────────────────────────────────────────────────
193
+
194
+ test("★ -14 冷却真的生效:命中 session timeout 后不得猛打接口", async () => {
195
+ const ilink = await fakeIlink({ onGetUpdates: (_s, reply) => reply({ ret: 0, errcode: -14, errmsg: "session timeout" }) });
196
+ try {
197
+ const { rt } = await boundRuntime(ilink, { cooldownMs: 300 });
198
+ await rt.startChannel();
199
+ await new Promise((r) => setTimeout(r, 1400));
200
+ // 冷却 300ms + 循环下限 1s → 1.4s 内应该只打了极少数几次;
201
+ // 若 isActive 短路 bug 回归,这里会变成几十次。
202
+ assert.ok(ilink.state.getUpdates <= 3, `冷却未生效:1.4s 内打了 ${ilink.state.getUpdates} 次 getupdates`);
203
+ // 断言「冷却被触发并记了账」而不是「此刻冷却未结束」——用 300ms 短冷却时,
204
+ // 1.4s 后冷却本来就该结束了;原先断言 remainingMs>0 是测试自己写错了。
205
+ assert.match(
206
+ rt.status().last_error,
207
+ /冷却|token 失效|会话过期/,
208
+ `-14 应被登记为冷却状态,实际:${JSON.stringify(rt.status().last_error)}`
209
+ );
210
+ await rt.stop();
211
+ } finally {
212
+ await ilink.close();
213
+ }
214
+ });
215
+
216
+ test("SessionCooldown 没有 isActive():编排层不得依赖它(防静默短路回归)", () => {
217
+ const sc = new SessionCooldown({ cooldownMs: 1000 });
218
+ assert.equal(typeof sc.isActive, "undefined", "上游确实没有 isActive —— 若将来加了,请同步改运行时并用本测试提醒");
219
+ assert.equal(sc.remainingMs(), 0, "未 arm 时 remainingMs 必须是 0(不能是 undefined,否则比较会出错)");
220
+ sc.arm("test");
221
+ assert.ok(sc.remainingMs() > 0);
222
+ });
223
+
224
+ // ── ③ 回执分派 ────────────────────────────────────────────────────────────
225
+
226
+ test("★ 审批回执走 answerApproval,提问回执走 answerQuestion(不能混)", async () => {
227
+ const ilink = await fakeIlink();
228
+ try {
229
+ const sub = makeFakeSubscriber();
230
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
231
+ rt.channel.account = { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "user-1" };
232
+
233
+ // 1) 推一条审批
234
+ const sent = await rt.notify({
235
+ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-1", toolName: "Bash", reason: "rm", answerShape: "approval", at: 1
236
+ });
237
+ assert.equal(sent.ok, true);
238
+ assert.match(lastText(ilink.state), /需要你拍板/);
239
+
240
+ // 用户回 1 → 应走 answerApproval 且值为 allowed-once
241
+ ilink.state.queue.push({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
242
+ await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
243
+ const call = sub.calls[sub.calls.length - 1];
244
+ assert.equal(call.m, "answerApproval", "审批必须走 answerApproval,否则编码不对");
245
+ assert.equal(call.value, "allowed-once", "选项字段是 value,不是 outcome");
246
+
247
+ // 2) 推一条提问
248
+ await rt.notify({
249
+ kind: NODE_KINDS.USER_QUESTION, eventId: "ev-2", answerShape: "question", at: 1,
250
+ questions: [{ id: "q1", question: "选哪个?", options: ["甲", "乙"] }]
251
+ });
252
+ await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "2" } }] });
253
+ const call2 = sub.calls[sub.calls.length - 1];
254
+ assert.equal(call2.m, "answerQuestion", "提问必须走 answerQuestion");
255
+ assert.deepEqual(call2.answers, [{ id: "q1", selected: ["乙"] }]);
256
+ } finally {
257
+ await ilink.close();
258
+ }
259
+ });
260
+
261
+ test("★ 过期绝不等于同意:没有待答通知时回数字,必须明说「没有代你做决定」", async () => {
262
+ const ilink = await fakeIlink();
263
+ try {
264
+ const sub = makeFakeSubscriber();
265
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
266
+ await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "1" } }] });
267
+ assert.equal(sub.calls.length, 0, "不得在没有待答通知时发出任何回执");
268
+ assert.match(lastText(ilink.state), /没有代你做出任何选择/);
269
+ } finally {
270
+ await ilink.close();
271
+ }
272
+ });
273
+
274
+ test("同一条通知不得被回复两次(consume 后再次回复应被拒)", async () => {
275
+ const ilink = await fakeIlink();
276
+ try {
277
+ const sub = makeFakeSubscriber();
278
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
279
+ await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "ev-x", toolName: "B", answerShape: "approval", at: 1 });
280
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
281
+ const before = sub.calls.length;
282
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
283
+ assert.equal(sub.calls.length, before, "第二次回复不得再产生一次回执");
284
+ } finally {
285
+ await ilink.close();
286
+ }
287
+ });
288
+
289
+ // ── ④ 指令 / 帮助文案 ─────────────────────────────────────────────────────
290
+
291
+ test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮助(防静默发空)", async () => {
292
+ const ilink = await fakeIlink();
293
+ try {
294
+ const { rt } = await boundRuntime(ilink);
295
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/help" } }] });
296
+ const text = lastText(ilink.state);
297
+ assert.ok(text.includes("/status") && text.includes("/unbind"), `帮助文案没发出来,实际:${JSON.stringify(text)}`);
298
+ } finally {
299
+ await ilink.close();
300
+ }
301
+ });
302
+
303
+ test("★ 运营钩子:能力表是活代码 —— 每档能力可查,未知档位按最低档", () => {
304
+ const { capabilitiesFor, WECHAT_CAPABILITY_TABLE } = rtmod;
305
+ // 免费档今天必须**包含全部**能力(业主:当前免费开放给所有人)
306
+ const free = capabilitiesFor("free");
307
+ for (const cap of ["notify", "approve", "assign", "sessions", "summary"]) {
308
+ assert.ok(free.includes(cap), `免费档今天必须有 ${cap}(当前全免费)`);
309
+ }
310
+ // 付费档是预留的:必须存在且是免费档的超集(加付费档 = 只改表,不动业务逻辑)
311
+ const pro = capabilitiesFor("pro");
312
+ for (const cap of free) assert.ok(pro.includes(cap), `付费档必须是免费档超集,缺 ${cap}`);
313
+ assert.ok(pro.length > free.length, "付费档应比免费档多能力(否则钩子没意义)");
314
+ // 未知/空档位按最低档处理 —— 宁可少给,不可误放
315
+ assert.deepEqual(capabilitiesFor("nonexistent"), free);
316
+ assert.deepEqual(capabilitiesFor(""), free);
317
+ assert.deepEqual(capabilitiesFor(undefined), free);
318
+ // 表是冻结的(防止运行期被改)
319
+ assert.ok(Object.isFrozen(WECHAT_CAPABILITY_TABLE));
320
+ });
321
+
322
+ test("★ 运营钩子:能力表在派发路径上真的生效 —— 拿掉 assign 后交代任务被拒", async () => {
323
+ // 这条守的是"钩子是活代码,不是文档":今天免费档含 assign 所以行为不变,
324
+ // 但把 assign 从表里拿掉后,**派发必须真的拒绝**。
325
+ const ilink = await fakeIlink();
326
+ try {
327
+ const sub = makeFakeSubscriber();
328
+ const relayDir = tmp();
329
+ saveAccount(relayDir, { token: "tok", accountId: "b", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
330
+ const rt = createWeChatRuntime({
331
+ relayDir, upstream: "http://127.0.0.1:1", secret: SECRET, logger: quietLogger(),
332
+ // 注入一张"免费档不含 assign"的表,模拟将来把该能力收进付费档
333
+ capabilityTable: { free: ["notify"], pro: ["notify", "approve", "assign", "sessions", "summary"] }
334
+ });
335
+ rt.subscriber = sub;
336
+
337
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
338
+ assert.equal(sub.calls.filter((c) => c.m === "createSession").length, 0, "不含 assign 时不得真的建会话");
339
+ assert.match(lastText(ilink.state), /当前档位/, `必须给出可照做的拒绝理由,实际:${lastText(ilink.state)}`);
340
+
341
+ // 默认档位(不注入)必须仍然可用 —— 业主:当前免费开放给所有人
342
+ const rt2 = createWeChatRuntime({ relayDir, upstream: "http://127.0.0.1:1", secret: SECRET, logger: quietLogger() });
343
+ rt2.subscriber = makeFakeSubscriber();
344
+ await rt2.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 做点事" } }] });
345
+ assert.equal(rt2.subscriber.calls.filter((c) => c.m === "createSession").length, 1, "默认(免费档)今天必须能交代任务");
346
+ } finally {
347
+ await ilink.close();
348
+ }
349
+ });
350
+
351
+ test("★ v2:纯文本 = 交代任务 —— 没有当前会话时自动开新会话并下发", async () => {
352
+ const ilink = await fakeIlink();
353
+ try {
354
+ const sub = makeFakeSubscriber();
355
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
356
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "帮我修个 bug" } }] });
357
+ const created = sub.calls.find((c) => c.m === "createSession");
358
+ const prompted = sub.calls.find((c) => c.m === "promptSession");
359
+ assert.ok(created, "没有当前会话时,第一条普通消息应开新会话");
360
+ assert.equal(prompted && prompted.text, "帮我修个 bug", "任务文本必须原样下发");
361
+ assert.match(lastText(ilink.state), /已开新任务/);
362
+ } finally {
363
+ await ilink.close();
364
+ }
365
+ });
366
+
367
+ test("★ v2:有当前会话时纯文本进当前会话(不是每次开新的)", async () => {
368
+ const ilink = await fakeIlink();
369
+ try {
370
+ const sub = makeFakeSubscriber();
371
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
372
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/new 第一件事" } }] });
373
+ sub.calls.length = 0;
374
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "再补充一句" } }] });
375
+ const created = sub.calls.find((c) => c.m === "createSession");
376
+ const prompted = sub.calls.find((c) => c.m === "promptSession");
377
+ assert.ok(!created, "已有当前会话时**不得**再开新会话");
378
+ assert.equal(prompted && prompted.sessionId, "session-new-1");
379
+ assert.equal(prompted && prompted.text, "再补充一句");
380
+ } finally {
381
+ await ilink.close();
382
+ }
383
+ });
384
+
385
+ test("★ v2 安全边界(业主拍板放宽):看不到内容照给一键回执,只看得出破坏性才降级", async () => {
386
+ // 背景:DSH 的审批节点常常只下发 toolName、没有命令原文。原先「看不到内容也算破坏性」
387
+ // 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" —— 过严。业主明确:DSH 自身有
388
+ // 权限控制,安全边界可以松一点。所以现在**只看内容**。
389
+ const ilink = await fakeIlink();
390
+ try {
391
+ const sub = makeFakeSubscriber();
392
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
393
+
394
+ // (a) 没有内容 → 按普通审批,保留一步回执
395
+ await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "d2", toolName: "Bash", answerShape: "approval", at: 1 });
396
+ const a = lastText(ilink.state);
397
+ assert.match(a, /需要你拍板/, `看不到内容应是普通审批,实际:${a}`);
398
+ assert.match(a, /回复 1/, "看不到内容必须仍给编号选项(放宽后的行为)");
399
+ assert.equal(rt.pendingReplies.length, 1, "应登记为待答,用户能一键处理");
400
+
401
+ // 回 1 必须真的能放行 —— 这是放宽后要保证的核心路径
402
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
403
+ const answered = sub.calls.filter((c) => c.m === "answerApproval");
404
+ assert.equal(answered.length, 1, "普通审批必须能被微信一键放行");
405
+ assert.equal(answered[0].value, "allowed-once");
406
+
407
+ // (b) 内容里看得出破坏性 → 仍然降级,不给一键回执
408
+ sub.calls.length = 0;
409
+ await rt.notify({ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "d1", toolName: "Bash", reason: "rm -rf /x", answerShape: "approval", at: 1 });
410
+ const b = lastText(ilink.state);
411
+ assert.match(b, /到电脑上确认/, `破坏性内容必须要求回电脑,实际:${b}`);
412
+ assert.match(b, /内容.*破坏性/, "有内容时应说是**内容**被判定破坏性");
413
+ assert.ok(!/回复 1/.test(b), "破坏性操作不得给编号选项");
414
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
415
+ assert.equal(sub.calls.filter((c) => c.m === "answerApproval").length, 0, "破坏性操作绝不能被一键放行");
416
+ } finally {
417
+ await ilink.close();
418
+ }
419
+ });
420
+
421
+ test("★ v2:/ls 列出会话(带名称),/use N 切换并落盘", async () => {
422
+ const ilink = await fakeIlink();
423
+ try {
424
+ const sub = makeFakeSubscriber();
425
+ sub.sessionsFixture = [
426
+ { sessionId: "session-aaa", title: "修复 Windows 兼容性", running: true, cwd: "/p" },
427
+ { sessionId: "session-bbb", title: "写周报", running: false, cwd: "/p" }
428
+ ];
429
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
430
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
431
+ const listed = lastText(ilink.state);
432
+ assert.match(listed, /修复 Windows 兼容性/, `列表必须带会话名称,实际:${listed}`);
433
+ assert.match(listed, /写周报/);
434
+
435
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
436
+ assert.equal(rt.currentSessionId, "session-bbb");
437
+ // 必须落盘 —— 否则 bridge 一重启,用户选好的任务就丢了,下一条消息会静默开成新任务
438
+ assert.equal(loadState(relayDir).current_session_id, "session-bbb", "当前会话指针必须落盘");
439
+ } finally {
440
+ await ilink.close();
441
+ }
442
+ });
443
+
444
+ test("★ v2:当前会话指针在重启后恢复(否则用户下一条消息会静默开成新任务)", async () => {
445
+ const ilink = await fakeIlink();
446
+ try {
447
+ const sub = makeFakeSubscriber();
448
+ sub.sessionsFixture = [{ sessionId: "session-zzz", title: "长任务", running: false, cwd: "/p" }];
449
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
450
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/ls" } }] });
451
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 1" } }] });
452
+
453
+ // 模拟 bridge 重启:用同一个 relayDir 新建一个运行时
454
+ const sub2 = makeFakeSubscriber();
455
+ const rt2 = createWeChatRuntime({
456
+ relayDir, upstream: "http://127.0.0.1:1", secret: SECRET,
457
+ logger: quietLogger()
458
+ });
459
+ rt2.subscriber = sub2;
460
+ assert.equal(rt2.currentSessionId, "session-zzz", "重启后必须还记得当前会话");
461
+ await rt2.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续" } }] });
462
+ const prompted = sub2.calls.find((c) => c.m === "promptSession");
463
+ assert.equal(prompted && prompted.sessionId, "session-zzz", "重启后必须接着原会话,不能开新的");
464
+ } finally {
465
+ await ilink.close();
466
+ }
467
+ });
468
+
469
+ test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止」", async () => {
470
+ const ilink = await fakeIlink();
471
+ try {
472
+ const sub = makeFakeSubscriber();
473
+ sub.sessionsFixture = [{ sessionId: "session-s1", title: "修复 Windows 兼容性", running: false, cwd: "/p" }];
474
+ sub.summaryFixture = "定位到 PATH 分隔符问题,已修复并验证。";
475
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
476
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 1" } }] });
477
+ await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-s1", reason: "completed", at: 1 });
478
+ const text = lastText(ilink.state);
479
+ assert.match(text, /修复 Windows 兼容性/, `必须带会话名称,实际:${text}`);
480
+ assert.match(text, /PATH 分隔符/, `必须带结论,实际:${text}`);
481
+ assert.match(text, /完成/, "正常完成不能读起来像失败");
482
+ } finally {
483
+ await ilink.close();
484
+ }
485
+ });
486
+
487
+ test("★ v2 安全:破坏性审批不给回执选项,回数字也不产生任何回执", async () => {
488
+ const ilink = await fakeIlink();
489
+ try {
490
+ const sub = makeFakeSubscriber();
491
+ const { rt } = await boundRuntime(ilink, { subscriber: sub });
492
+ await rt.notify({
493
+ kind: NODE_KINDS.APPROVAL_REQUEST, eventId: "evt-d", toolName: "Bash",
494
+ reason: "rm -rf /important", answerShape: "approval", at: 1
495
+ });
496
+ const text = lastText(ilink.state);
497
+ assert.match(text, /到电脑上确认/);
498
+ assert.ok(!/回复 1/.test(text), "破坏性操作不得给编号选项");
499
+ assert.equal(rt.pendingReplies.length, 0, "不得登记为待答");
500
+
501
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "1" } }] });
502
+ const answers = sub.calls.filter((c) => String(c.m).startsWith("answer"));
503
+ assert.equal(answers.length, 0, "破坏性操作绝不能被微信一键放行");
504
+ } finally {
505
+ await ilink.close();
506
+ }
507
+ });
508
+
509
+ test("/quiet 后只推「要你决定」与报错,日常节点被静音", async () => {
510
+ const ilink = await fakeIlink();
511
+ try {
512
+ const { rt, relayDir } = await boundRuntime(ilink);
513
+ await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/quiet" } }] });
514
+ assert.equal(loadState(relayDir).quiet, true);
515
+
516
+ const before = ilink.state.sends.length;
517
+ const quieted = await rt.notify({ kind: NODE_KINDS.DIGEST_DUE, notified: 1, answered: 0, at: 1 });
518
+ assert.equal(quieted.ok, false);
519
+ assert.equal(quieted.reason, "quiet");
520
+ assert.equal(ilink.state.sends.length, before, "免打扰期间日常节点不得发出");
521
+
522
+ const urgent = await rt.notify({ kind: NODE_KINDS.SESSION_ERROR, detail: "boom", at: 1 });
523
+ assert.equal(urgent.ok, true, "报错属于「必须放行」");
524
+ } finally {
525
+ await ilink.close();
526
+ }
527
+ });
528
+
529
+ // ── ⑤ 控制面安全 ──────────────────────────────────────────────────────────
530
+
531
+ test("★ 控制面:无密钥一律 401,且只 bind 回环", async () => {
532
+ const ilink = await fakeIlink();
533
+ try {
534
+ const { rt, relayDir } = await boundRuntime(ilink);
535
+ const port = await rt.startControl();
536
+ const base = `http://127.0.0.1:${port}`;
537
+
538
+ const noAuth = await fetch(`${base}/wechat/status`);
539
+ assert.equal(noAuth.status, 401, "无密钥必须 401");
540
+ const badAuth = await fetch(`${base}/wechat/status`, { headers: { [CONTROL_HEADER]: "wrong" } });
541
+ assert.equal(badAuth.status, 401);
542
+
543
+ const ok = await fetch(`${base}/wechat/status`, { headers: { [CONTROL_HEADER]: SECRET } });
544
+ assert.equal(ok.status, 200);
545
+
546
+ // 发现文件里只有端口,没有密钥
547
+ const disc = readControlFile(relayDir);
548
+ assert.equal(disc.port, port);
549
+ assert.equal(disc.header, CONTROL_HEADER);
550
+ assert.ok(!JSON.stringify(disc).includes(SECRET), "发现文件绝不能包含密钥");
551
+
552
+ // 服务确实只监听回环
553
+ const addr = rt.controlServer.address();
554
+ assert.equal(addr.address, "127.0.0.1", "控制面必须只 bind 127.0.0.1");
555
+ await rt.stopControl();
556
+ } finally {
557
+ await ilink.close();
558
+ }
559
+ });
560
+
561
+ test("★ 面板状态永不回显 bot_token", async () => {
562
+ const ilink = await fakeIlink();
563
+ try {
564
+ const { rt } = await boundRuntime(ilink);
565
+ const s = rt.status();
566
+ const json = JSON.stringify(s);
567
+ assert.ok(!json.includes("tok-abc"), `状态里泄漏了 token:${json}`);
568
+ assert.ok(!("token" in s), "状态里不得出现 token 字段");
569
+ assert.equal(s.bound, true, "绑定状态要如实反映");
570
+ } finally {
571
+ await ilink.close();
572
+ }
573
+ });
574
+
575
+ test("没有 bridge_secret 时控制面拒绝一切(不能变成开放后门)", async () => {
576
+ const ilink = await fakeIlink();
577
+ try {
578
+ const relayDir = tmp();
579
+ saveAccount(relayDir, { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
580
+ const rt = createWeChatRuntime({ relayDir, secret: "", logger: quietLogger() });
581
+ const port = await rt.startControl();
582
+ const r = await fetch(`http://127.0.0.1:${port}/wechat/status`, { headers: { [CONTROL_HEADER]: "" } });
583
+ assert.equal(r.status, 403);
584
+ await rt.stopControl();
585
+ } finally {
586
+ await ilink.close();
587
+ }
588
+ });
589
+
590
+ // ── ⑥ 断线窗口要如实告知(§6 ②) ───────────────────────────────────────────
591
+
592
+ test("★ 断线窗口:订阅报 gap 时必须主动告知「可能漏了」,不能装作没事", async () => {
593
+ const ilink = await fakeIlink();
594
+ try {
595
+ const { rt } = await boundRuntime(ilink);
596
+ await rt.startChannel();
597
+ await new Promise((r) => setTimeout(r, 120));
598
+ const before = ilink.state.sends.length;
599
+ rt.subscriber.emit("gap", { from: 1, to: 2 });
600
+ await new Promise((r) => setTimeout(r, 120));
601
+ assert.ok(ilink.state.sends.length > before, "gap 之后必须发出一条告知");
602
+ assert.match(lastText(ilink.state), /补发|漏/);
603
+ await rt.stop();
604
+ } finally {
605
+ await ilink.close();
606
+ }
607
+ });
608
+
609
+ test("绑定态只有 bound/unbound 两态:未绑定时通道不启动、绑定后启动", async () => {
610
+ const ilink = await fakeIlink();
611
+ try {
612
+ const relayDir = tmp();
613
+ const rt = createWeChatRuntime({ relayDir, secret: SECRET, logger: quietLogger() });
614
+ assert.equal(rt.status().bound, false);
615
+ assert.equal(rt.status().channel_running, false);
616
+ assert.equal(await rt.startChannel(), false, "未绑定不得启动长轮询");
617
+
618
+ saveAccount(relayDir, { token: "t", accountId: "b", baseUrl: ilink.baseUrl, userId: "u", boundAt: 1 });
619
+ const rt2 = createWeChatRuntime({ relayDir, secret: SECRET, logger: quietLogger() });
620
+ rt2.subscriber = makeFakeSubscriber();
621
+ assert.equal(await rt2.startChannel(), true);
622
+ await rt2.stop();
623
+ } finally {
624
+ await ilink.close();
625
+ }
626
+ });