@mrrisega/dsh-remote 0.7.0-beta.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +17 -17
- package/clients/dsh-remote/dsh-events.mjs +6 -6
- package/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/clients/dsh-remote/mobile-adapter.mjs +21 -21
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +1 -1
- package/clients/dsh-remote/test/device-registration-report.test.mjs +1 -1
- package/clients/dsh-remote/test/dsh-events.test.mjs +10 -10
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +4 -4
- package/clients/dsh-remote/test/e2ee-client.test.mjs +7 -7
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +1 -1
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +4 -4
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +2 -2
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +6 -6
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +1 -1
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +5 -5
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
- package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
- package/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/docs/telemetry.md +3 -3
- package/dsh-setup.mjs +63 -26
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +56 -56
- package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
- package/packages/dsh-remote-web/lib/index.js +43 -43
- package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +21 -21
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +63 -26
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
- package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
- package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +6 -4
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
- package/packages/dsh-remote-web/test/setup-output.test.mjs +32 -9
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
- package/scripts/test-net-guard.cjs +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - MK 派生向量与 enterprise 参考实现(e2ee.js deriveE2eeMasterKey)一致(固定口令/盐 → 固定 hex);
|
|
5
5
|
* - 信封加解密往返 / 篡改拒绝 / nonce 重放拒绝 / ws 计数重放拒绝;
|
|
6
6
|
* - 参数端点容错(非 200 / 无 e2ee / enabled=false → disabled 不回退抛错);
|
|
7
|
-
* - 桥端控制通道握手(hello→ack
|
|
7
|
+
* - 桥端控制通道握手(hello→ack、探测好/坏密钥)、http 明文编解码、ws 标签解析。
|
|
8
8
|
*
|
|
9
9
|
* 用法: node --test test/e2ee-client.test.mjs
|
|
10
10
|
*/
|
|
@@ -216,7 +216,7 @@ function flipB64(b64) {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
test("★ 0.6.15 二进制明文框架:正文不再 base64(省掉三层里的第一层),且旧 JSON 形态仍能解", () => {
|
|
219
|
-
//
|
|
219
|
+
// 用户实测"打开慢几分钟"的直接原因之一:同一条响应被 base64 编了三层(1.333³≈2.37×)。
|
|
220
220
|
// 第一层就是"gzip 正文塞进 JSON 明文"。二进制框架把它去掉,且必须**两种形态都能解**
|
|
221
221
|
// (老客户端只发旧形态,新客户端声明后收新形态)。
|
|
222
222
|
const body = Buffer.from("首屏聚合包".repeat(500), "utf8");
|
|
@@ -323,7 +323,7 @@ test("信封标记头:封包/识别/解析一致;content-type 与 x-dsh-e2ee 双
|
|
|
323
323
|
assert.equal(parseEnvelopeMarker({}), null);
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
-
// ---------- 桥端服务:握手 /
|
|
326
|
+
// ---------- 桥端服务:握手 / 探测 / 开关 ----------
|
|
327
327
|
|
|
328
328
|
test("E2eeService:disabled 状态 caps 为空;enabled 后 caps=['e2ee-v2']", () => {
|
|
329
329
|
const off = new E2eeService({ enabled: false, reason: "server_disabled" });
|
|
@@ -345,7 +345,7 @@ test("E2eeService.introGrant:启用时一次性下发派生 MK(32B b64url);禁
|
|
|
345
345
|
assert.equal(g.epoch, 3);
|
|
346
346
|
assert.equal(typeof g.mk, "string");
|
|
347
347
|
assert.equal(Buffer.from(g.mk, "base64url").length, 32);
|
|
348
|
-
assert.equal(Buffer.from(g.mk, "base64url").toString("hex"), mk.toString("hex"), "引导下发的 MK 应与桥端派生 MK 一致(
|
|
348
|
+
assert.equal(Buffer.from(g.mk, "base64url").toString("hex"), mk.toString("hex"), "引导下发的 MK 应与桥端派生 MK 一致(否则手机无法通过探测)");
|
|
349
349
|
});
|
|
350
350
|
|
|
351
351
|
test("E2eeService.init:服务端 enabled=false/401/无密码/禁用开关 → 明文回退原因", async () => {
|
|
@@ -387,7 +387,7 @@ test("握手:hello→ack(b 随机 32B);salt/profile 不符拒绝;disabled 服务
|
|
|
387
387
|
assert.throws(() => off.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: newSessId(), a: randomB64(32), salt: SALT_B64 }), (e) => e.code === "disabled");
|
|
388
388
|
});
|
|
389
389
|
|
|
390
|
-
test("
|
|
390
|
+
test("探测:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会话被拒", () => {
|
|
391
391
|
const mk = deriveMasterKey("unit-password", SALT_B64);
|
|
392
392
|
const svc = new E2eeService({ enabled: true, mk, salt: SALT_B64, profile: "pbkdf2-sha256-600k" });
|
|
393
393
|
const sessId = newSessId();
|
|
@@ -398,7 +398,7 @@ test("探针:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会
|
|
|
398
398
|
return ack.b;
|
|
399
399
|
})();
|
|
400
400
|
const client = new E2eeSession({ sessId, ...deriveShk(mk, aB64, bB64), profile: "pbkdf2-sha256-600k" });
|
|
401
|
-
//
|
|
401
|
+
// 探测(明文 {p,t,c})
|
|
402
402
|
const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: Date.now(), c: 0 }) });
|
|
403
403
|
const { reply, session } = svc.handleProbe(sessId, probe);
|
|
404
404
|
assert.equal(reply.k, "ctrl");
|
|
@@ -407,7 +407,7 @@ test("探针:正确 MK 通过并置 verified;错误 MK → bad_key;未解锁会
|
|
|
407
407
|
// bridge 回 probe-ok → 手机可开
|
|
408
408
|
const okPlain = client.open({ kind: "ctrl", dir: "b2p", env: reply, counter: reply.c });
|
|
409
409
|
assert.equal(JSON.parse(okPlain.data.toString("utf8")).p, "dsh-e2ee-probe-ok");
|
|
410
|
-
//
|
|
410
|
+
// 重放同一探测 → 计数重放拒绝
|
|
411
411
|
assert.throws(() => svc.handleProbe(sessId, probe), (e) => e.code === "replay" || e.code === "bad_counter");
|
|
412
412
|
// 错误 MK(错误口令)的手机 → bad_key
|
|
413
413
|
const wrongMk = deriveMasterKey("wrong-password", SALT_B64);
|
|
@@ -566,7 +566,7 @@ test("fetch 包装:真实字节往返(shim 密封 → 桥端打开 → 封回
|
|
|
566
566
|
// ─────────────── 2026-09-27:P0-1 收尾(设备作用域必须在"没有会话"时也生效) ───────────────
|
|
567
567
|
|
|
568
568
|
test("★ WS 设备作用域:不改写就走放行分支时,也必须用**改写后**的 URL", () => {
|
|
569
|
-
//
|
|
569
|
+
// 故障现场:镜像页**没有会话**(未解锁)→ 不加密 → 走放行分支。
|
|
570
570
|
// 官方客户端把 mux 拼成根路径 `/api/remote.mux`,中继只认会过期的 dsh_device cookie → 404
|
|
571
571
|
// (rejectUpgrade 写状态行后立刻 destroy → nginx 只看到 009)→ e2ee-hello 送不到 →
|
|
572
572
|
// 会话永远建不起来 → 每个受保护请求 unknown_session 502。死循环。
|
|
@@ -123,7 +123,7 @@ test("信封逐字段一致:http 请求 / http 响应 / w(文本·二进制)/ ct
|
|
|
123
123
|
else assert.equal(Buffer.from(back.data).toString("utf8"), data);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// ctrl
|
|
126
|
+
// ctrl 探测
|
|
127
127
|
const probePlain = JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1700000000000, c: 0 });
|
|
128
128
|
const envCN = nodeS.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: probePlain, nonce: NONCE_12() });
|
|
129
129
|
const envCW = await wcS.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: probePlain, nonceBytes: NONCE_12() });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Cookie **按 authority 隔离**回归(2026-10-09
|
|
3
|
+
* Cookie **按 authority 隔离**回归(2026-10-09 故障根治)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 【故障原貌】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端
|
|
6
6
|
* (每次启动随机端口)是**两个宿主进程**,各自的插件都往里写自己 authority 的 Cookie →
|
|
7
7
|
* 互相覆盖。桥撞 401 后重读磁盘拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
|
|
8
8
|
* → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**,手机端持续白页。
|
|
@@ -69,9 +69,9 @@ test("本宿主专属文件优先:`.harness-cookie.<authority>.json` 收到的
|
|
|
69
69
|
assert.equal(r.cookie, "dsh-auth-OWN");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
test("★
|
|
72
|
+
test("★ 回归锁:旧版共享文件里是**另一个宿主**的 Cookie → 一个字节都不许发出去", async () => {
|
|
73
73
|
clearCookies();
|
|
74
|
-
//
|
|
74
|
+
// 这正是故障现场:共享文件被另一宿主(3080)覆盖过
|
|
75
75
|
writeDoc(LEGACY, { authority: "127.0.0.1:3080", cookie: "dsh-auth-FOREIGN", mintedAt: Date.now() });
|
|
76
76
|
const r = await ask();
|
|
77
77
|
assert.equal(r.status, 200);
|
|
@@ -113,8 +113,8 @@ test("机器指纹:darwin/linux 两条老分支没有被改坏", () => {
|
|
|
113
113
|
assert.equal(linux.fn(), "0123456789abcdef", "linux 仍读 /etc/machine-id(并去掉换行)");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin
|
|
117
|
-
//
|
|
116
|
+
test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin 的真实故障)", () => {
|
|
117
|
+
// 故障(2026-09-22 真机日志):bridge 由 launchd 拉起,plist 给的 PATH 是
|
|
118
118
|
// /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin —— 没有 /usr/sbin,而 ioreg 在那里。
|
|
119
119
|
// 日志里出现 `/bin/sh: ioreg: command not found`,指纹**静默**退化成 hostname 哈希:
|
|
120
120
|
// 用户改一次主机名就被服务端当成新设备。这类"PATH 假设"必须用绝对路径堵死。
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* mobile-adapter
|
|
3
|
+
* mobile-adapter 手机端两个真实故障的回归测试(假 DOM + 事件传播模型 + vm 跑真实注入脚本)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 故障 1(2026-09-19,微信内置浏览器实测反馈)
|
|
6
6
|
* 「用微信语音转文字输入时,它在自动整理文字的过程中会直接触发输入框的发送」——半截话被发出。
|
|
7
7
|
* 取证:官方输入区是 Lexical contenteditable(根元素带 data-composer-input / role=textbox /
|
|
8
8
|
* aria-multiline / data-lexical-editor),官方键位图自带的 IME 守卫只看
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* (isComposing / keyCode 229 / compositionend 后 60ms 内)一律 stopPropagation;
|
|
14
14
|
* ② ≤820px 时输入框里的 Enter **只换行不发送**,发送只由官方发送按钮负责。
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* 故障 2(2026-09-19,Windows 电脑通道实测反馈)
|
|
17
17
|
* 「展开左边抽屉,无法选择历史会话,点任何东西都没有反应;界面右边有一个白色的框占了点位置」。
|
|
18
18
|
* 根因:`isDrawer()` 只查 `r.right > 8`,而**向右滑出屏外的第三列**右边坐标是很大的正数
|
|
19
19
|
* (真机实测 left=400 / right=759 / width=358)→ 完全看不见的详情列被判成"展开的详情列" →
|
|
@@ -915,7 +915,7 @@ test("★ 迭代速度护栏:适配层测试的假定时器**必须 unref**(
|
|
|
915
915
|
});
|
|
916
916
|
|
|
917
917
|
test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层就是这么做、然后永久盖住 UI 的)", () => {
|
|
918
|
-
//
|
|
918
|
+
// 故障 3(2026-09-25,用户实测):「界面都已经加载完了…但这个遮罩一直不消失」,
|
|
919
919
|
// 截图里官方 UI 就在 88% 不透明的全屏层下面透出来,秒表走到 187 秒 —— 刷新也一样。
|
|
920
920
|
//
|
|
921
921
|
// 根因是**设计错误**:那个提示层是 `position:fixed; inset:0; z-index:2147483000` 的全屏层,
|
|
@@ -923,7 +923,7 @@ test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层
|
|
|
923
923
|
// 两组信号全不命中 → 层永远留着。当时我写的"pointer-events:none 所以不会挡点击"是自我安慰:
|
|
924
924
|
// **盖住视线和进不去对用户没区别**。
|
|
925
925
|
//
|
|
926
|
-
//
|
|
926
|
+
// 用户决定:直接删掉。这条护栏守两件事:
|
|
927
927
|
// ① `dsh-ma-boot` 不许回来;
|
|
928
928
|
// ② **解析期**(主机门之前)不许有任何 `appendChild` —— 页面还没确认是官方 dsh web,
|
|
929
929
|
// 更不该有任何覆盖层;所有注入节点都必须在 boot() 里、过了主机门之后才做。
|
|
@@ -1058,7 +1058,7 @@ test("★ 官方右栏里有内容时:适配层**不插手**(交还官方 op
|
|
|
1058
1058
|
assert.equal(env.html.classList.contains("dsh-ma-scrim-on"), false, "★ 更不得为它开遮罩");
|
|
1059
1059
|
});
|
|
1060
1060
|
|
|
1061
|
-
test("★ 2026-09-19
|
|
1061
|
+
test("★ 2026-09-19 白框故障的**结构性**回归锁:适配层任何情况下都不滑出第三列", () => {
|
|
1062
1062
|
const { js } = injected();
|
|
1063
1063
|
// 当年白框的三种触发现场各来一遍,断言"我们什么都不做" ⇒ 白框在结构上不可能再出现
|
|
1064
1064
|
for (const opts of [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* mobile-adapter **图片上传兼容**契约测试(假 DOM + vm 执行真实注入脚本)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 用户反馈(2026-09):「手机端无法识别图片」——选了照片发出去,模型看不到图 / 被当成普通文件。
|
|
6
6
|
*
|
|
7
7
|
* 根因(官方 dsh web 客户端 `dsh-client-ui-conversation` 的 createDrafts):
|
|
8
8
|
* 只有 `file.type ∈ {image/png,image/jpeg,image/webp,image/gif}` 才走**图片**通道
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* mobile-adapter **运行时**契约测试(假 DOM + vm 执行真实注入脚本)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 故障背景(2026-09-15 生产反馈):
|
|
6
6
|
* 手机端用户打开后**整屏被阴影覆盖、点哪都没反应**,只有左侧栏(z-index 300,在遮罩之上)能点。
|
|
7
7
|
* 根因是适配层自己创建的全屏 scrim(div.dsh-ma-scrim, z-index 290):
|
|
8
8
|
* · 判定「侧栏是否展开」用的是 `!frame.hasAttribute("data-sidebar-collapsed")`,
|
|
@@ -274,7 +274,7 @@ test("运行时:判定为展开时不再隐藏汉堡按钮(避免用户没
|
|
|
274
274
|
const ham = findHamburger(env);
|
|
275
275
|
assert.ok(ham, "汉堡按钮应存在");
|
|
276
276
|
assert.notEqual(ham.style.display, "none",
|
|
277
|
-
"抽屉展开时也不应把汉堡隐藏 ——
|
|
277
|
+
"抽屉展开时也不应把汉堡隐藏 —— 判定一旦出错,用户会连入口都没有(故障现场就是这个死局)");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
test("运行时:找不到官方 toggle 时,点遮罩必须能自救(摘掉遮挡类)", () => {
|
|
@@ -537,11 +537,11 @@ test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程
|
|
|
537
537
|
});
|
|
538
538
|
|
|
539
539
|
test("★ 亚毫秒样本(本机/回环自测)也必须出数 —— 不得永远停在「测速中…」", async () => {
|
|
540
|
-
//
|
|
540
|
+
// 用户 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
|
|
541
541
|
// 根因:旧实现是 `if (r < 1) return;`(注释写"亚毫秒=同帧缓存命中"),但**本机/回环**访问
|
|
542
542
|
// (桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms —— 于是**每一个**样本
|
|
543
543
|
// 都被丢掉,界面永远停在空态。面板侧 chip 从来没这个毛病(`v <= 0` + `Math.max(1, …)`),
|
|
544
|
-
//
|
|
544
|
+
// 这条用例把两侧约定钉在一起。
|
|
545
545
|
const env = harness({ width: 390, fetchImpl: async () => ({ status: 200, ok: true }) }); // 零延迟 = 亚毫秒
|
|
546
546
|
runAdapter(env);
|
|
547
547
|
const rtt = findFab(env).querySelector('[data-role="rtt"]');
|
|
@@ -580,7 +580,7 @@ test("★ 悬浮窗被官方 SPA 重渲染重建后,已积累的样本仍要
|
|
|
580
580
|
});
|
|
581
581
|
|
|
582
582
|
test("★ 采样器由 boot 安装(不依赖菜单)+ 空态说得清原因 + 被顶掉能自愈重挂", async () => {
|
|
583
|
-
// 2026-10-10
|
|
583
|
+
// 2026-10-10 二修:用户第二次反馈「镜像页一直测速中」,而同一时段镜像页打了 69 次
|
|
584
584
|
// /api/agentPresets/list 这类**完全合格**的请求 —— 说明真根因是**采样没接上**
|
|
585
585
|
// (旧实现把包装写在 buildRemoteMenu 内部的一次性分支里),而不是样本被过滤。
|
|
586
586
|
const env = harness({
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* 上游(dsh web)地址发现契约测试。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 背景(两起真实故障,见 upstream-discovery.mjs 顶部注释):
|
|
6
6
|
* ① `dsh web --port 8090` / `--port 0`(系统分配端口)→ 写死 3080 的探测永远失败;
|
|
7
7
|
* ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120、占用则 +1,插件 0.6.10
|
|
8
8
|
* 实测「面板永久停在 starting、设备从未登记」——watcher 探 3080 探不到就永不派生 bridge。
|
|
@@ -1768,7 +1768,7 @@ test("完成模板:头一行按 reason 区分,completed 不许读成失败", ()
|
|
|
1768
1768
|
});
|
|
1769
1769
|
|
|
1770
1770
|
test("★ 微信不渲染 Markdown:模型产出的表格/加粗/链接必须先转成纯文本", () => {
|
|
1771
|
-
//
|
|
1771
|
+
// 用户原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」。
|
|
1772
1772
|
// 根因:微信**不渲染** Markdown(腾讯自己的插件在出站路径主动剥离),所以模型的表格会以
|
|
1773
1773
|
// 字面竖线、加粗会以字面星号出现在气泡里。这儿锁住"发送前必须已转换"。
|
|
1774
1774
|
const md = [
|
|
@@ -1847,7 +1847,7 @@ test("完成模板:结论要**尽量完整**(未超上限不截断),超长才显
|
|
|
1847
1847
|
// 结论**独立成段**(不再挤在 "结论:" 前缀后面) —— 压成一行正是"密密麻麻"的来源
|
|
1848
1848
|
assert.match(withSummary.text, /—— 结论 ——\n结论正文/, "结论要单独成段、原样展示");
|
|
1849
1849
|
|
|
1850
|
-
// ★
|
|
1850
|
+
// ★ 用户约定(2026-09-22):「结论被大量截断了,加长一些,更完整地展示 —— 这可能是用户关注的内容」
|
|
1851
1851
|
// 旧上限 400 连一条正常的修复总结(实测 190+ 字)都快保不住。
|
|
1852
1852
|
const fits = "长".repeat(1200);
|
|
1853
1853
|
const kept = formatCompletion({ title: "T", reason: "completed", summary: fits });
|
|
@@ -1967,10 +1967,10 @@ test("危险工具:真阴性(普通的读/查/测试/构建**不是**破坏性)"
|
|
|
1967
1967
|
}
|
|
1968
1968
|
});
|
|
1969
1969
|
|
|
1970
|
-
test("危险工具:看不到内容时**不再**降级(
|
|
1970
|
+
test("危险工具:看不到内容时**不再**降级(用户拍板:DSH 自身有权限控制,别过严),且绝不抛", () => {
|
|
1971
1971
|
// ⚠️ 2026-09-22 行为变更:原先「能跑命令 + 完全看不到内容 → 判破坏性」。
|
|
1972
1972
|
// 那条规则会把**绝大多数正常 Bash 审批**降级成"只能回电脑确认"(DSH 的审批节点
|
|
1973
|
-
// 本来就常常没有命令原文)
|
|
1973
|
+
// 本来就常常没有命令原文),过严且伤体验。用户明确安全边界可以松一点。
|
|
1974
1974
|
// 现在**只看内容**:内容为空 → 不拦(保留一步回执)。
|
|
1975
1975
|
assert.equal(isDestructiveTool("Bash"), false, "看不到内容不再降级");
|
|
1976
1976
|
assert.equal(isDestructiveTool("Bash", {}), false);
|
|
@@ -2045,7 +2045,7 @@ test("入站分类:纯函数 + 全量(不改入参、怪输入也不抛)", () =>
|
|
|
2045
2045
|
});
|
|
2046
2046
|
|
|
2047
2047
|
test("★ 回归:换绑必须清掉旧 token 的冷却(否则用户刚绑上就一小时不能聊天)", async () => {
|
|
2048
|
-
// 实测 bug(
|
|
2048
|
+
// 实测 bug(用户撞到):旧 token 撞 -14 → 冷却 1 小时 → 用户解绑 + 重新绑定拿到**新** token,
|
|
2049
2049
|
// 却仍被旧 token 的冷却挡着 → "无法跟机器人聊天",而面板只说"通知会延迟"。
|
|
2050
2050
|
// 新 token 的会话与旧 token 的超时毫无关系,冷却必须清。
|
|
2051
2051
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "wc-cooldown-"));
|
|
@@ -339,12 +339,12 @@ test("★ handleCommand 的字段是 replyText:回 /help 必须真的发出帮
|
|
|
339
339
|
test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按最低档", () => {
|
|
340
340
|
const { capabilitiesFor, WECHAT_CAPABILITY_TABLE, grantsCap, limitsFor } = rtmod;
|
|
341
341
|
const free = capabilitiesFor("free");
|
|
342
|
-
// 免费档(
|
|
342
|
+
// 免费档(用户约定):收得到通知、点得了审批、看得到状态、停得下任务 —— 但**遥控不了**。
|
|
343
343
|
for (const cap of ["notify", "approve", "status", "stop"]) {
|
|
344
344
|
assert.ok(free.includes(cap), `免费档必须有 ${cap}`);
|
|
345
345
|
}
|
|
346
346
|
for (const cap of ["assign", "sessions", "summary"]) {
|
|
347
|
-
assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(
|
|
347
|
+
assert.ok(!free.includes(cap), `免费档**不得**有 ${cap}(用户:那是付费能力)`);
|
|
348
348
|
}
|
|
349
349
|
// 付费档 = 免费档超集 + 遥控
|
|
350
350
|
const pro = capabilitiesFor("pro");
|
|
@@ -355,7 +355,7 @@ test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按
|
|
|
355
355
|
assert.ok(grantsCap(pro, cap), `付费档必须有 ${cap}`);
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
-
// ★★
|
|
358
|
+
// ★★ 用户 2026-09-22 拍板的核心不变量:免费**能续接已有会话、不能开新会话**
|
|
359
359
|
assert.ok(grantsCap(free, "assign.continue"), "免费档必须能「继续已有会话」(回复即续接)");
|
|
360
360
|
assert.ok(!grantsCap(free, "assign.new"), "★免费档**不得**能开新会话(那是会员能力的差异点)");
|
|
361
361
|
assert.ok(!grantsCap(free, "sessions"), "免费档不得能切换会话");
|
|
@@ -368,7 +368,7 @@ test("★ 分层:免费档 = 通知+审批,付费档才给遥控;未知档位按
|
|
|
368
368
|
assert.ok(!grantsCap(["assign"], "constructor"), "前缀规则不能被开头相同的无关词命中");
|
|
369
369
|
assert.ok(!grantsCap(free, "steer") && !grantsCap(["whatever"], "assign.new"), "未知能力不授权");
|
|
370
370
|
|
|
371
|
-
// 额度(
|
|
371
|
+
// 额度(用户:「免费给每月 N 条消息额度,超出再引导升级」;付费档不限)
|
|
372
372
|
assert.ok(Number(limitsFor("free").messages_per_month) > 0, "免费档必须有每月消息额度");
|
|
373
373
|
assert.equal(Number(limitsFor("pro").messages_per_month || 0), 0, "付费档不应限量(0 = 不限)");
|
|
374
374
|
assert.equal(Number(limitsFor("pro_max").messages_per_month || 0), 0, "最高档同样不限量");
|
|
@@ -402,7 +402,7 @@ test("★ 分层:免费档被挡且回复里带 App 链接;付费档放行;未
|
|
|
402
402
|
const freeText = lastText(ilink.state);
|
|
403
403
|
assert.match(freeText, /会员功能/, `要说清为什么被拒,实际:${freeText}`);
|
|
404
404
|
assert.ok(freeText.includes(APP_URL),
|
|
405
|
-
`★回复必须带上可点击的 App 链接(
|
|
405
|
+
`★回复必须带上可点击的 App 链接(用户:免费用户要去做别的,就给他访问 App 的链接),实际:${freeText}`);
|
|
406
406
|
assert.match(freeText, /\/help/, "还要告诉他现在能做什么,而不是死路一条");
|
|
407
407
|
|
|
408
408
|
// ② 付费档:同一句话必须放行
|
|
@@ -543,9 +543,9 @@ test("★ v2:有当前会话时纯文本进当前会话(不是每次开新的)",
|
|
|
543
543
|
}
|
|
544
544
|
});
|
|
545
545
|
|
|
546
|
-
test("★ v2 安全边界(
|
|
546
|
+
test("★ v2 安全边界(用户拍板放宽):看不到内容照给一键回执,只看得出破坏性才降级", async () => {
|
|
547
547
|
// 背景:DSH 的审批节点常常只下发 toolName、没有命令原文。原先「看不到内容也算破坏性」
|
|
548
|
-
// 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" ——
|
|
548
|
+
// 会把绝大多数正常 Bash 审批都降级成"只能回电脑确认" —— 过严。用户明确:DSH 自身有
|
|
549
549
|
// 权限控制,安全边界可以松一点。所以现在**只看内容**。
|
|
550
550
|
const ilink = await fakeIlink();
|
|
551
551
|
try {
|
|
@@ -647,7 +647,7 @@ test("★ v2:完成推送带会话名称 + 结论,而不只是「任务已停止
|
|
|
647
647
|
});
|
|
648
648
|
|
|
649
649
|
test("★ 0.6.15:完成推送必须把「当前会话」切到刚跑完的那个(否则回话会发进上一次的会话)", async () => {
|
|
650
|
-
//
|
|
650
|
+
// 用户实测:B 任务跑完推了结论,他在微信里直接回一句"再改一下",那条消息却发给了
|
|
651
651
|
// **上一次的当前会话 A** —— A 平白多了一条指令,B 永远收不到。
|
|
652
652
|
const ilink = await fakeIlink();
|
|
653
653
|
try {
|
|
@@ -819,7 +819,7 @@ test("★★ 日报发送失败时**不能**把当天标记为已发(否则当
|
|
|
819
819
|
});
|
|
820
820
|
|
|
821
821
|
test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带品牌露出", async () => {
|
|
822
|
-
//
|
|
822
|
+
// 用户:「每天早上发『今日干了啥事儿』,这肯定不太对劲。应该是晚上发,比如晚上 6 点,发『今天干了啥』」
|
|
823
823
|
// 且一天只发一次(它兼作 24h 推送窗口的心跳,早晚各一次就太频繁了)。
|
|
824
824
|
assert.equal(rtmod.DEFAULT_DIGEST_HOUR, 18, "日报时刻必须是晚上 18 点,不能是早上 9 点");
|
|
825
825
|
const ilink = await fakeIlink();
|
|
@@ -847,7 +847,7 @@ test("★ 日报:晚上发(不是早上)、内容是「今天干了啥」、带
|
|
|
847
847
|
|
|
848
848
|
test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一次,同一天不重复", async () => {
|
|
849
849
|
// 这条**以前根本不存在**:notifyMembershipExpiring() 只有定义、全仓零调用方 ——
|
|
850
|
-
//
|
|
850
|
+
// 用户以为"提前两天发、提前一天也发",实际一次都没发过。这里锁住补上的触发点。
|
|
851
851
|
const ilink = await fakeIlink();
|
|
852
852
|
try {
|
|
853
853
|
const DAY = 86_400_000;
|
|
@@ -888,7 +888,7 @@ test("★★ 会员到期提醒:提前 2 天 / 1 天 / 到期当天各提醒一
|
|
|
888
888
|
}
|
|
889
889
|
});
|
|
890
890
|
|
|
891
|
-
test("★★ A:免费档「能续接已有会话、不能开新会话」(
|
|
891
|
+
test("★★ A:免费档「能续接已有会话、不能开新会话」(用户拍板的分级)", async () => {
|
|
892
892
|
const ilink = await fakeIlink();
|
|
893
893
|
try {
|
|
894
894
|
// ① 免费 + **没有**当前会话 → 纯文本等于开新任务 → 必须被拒
|
|
@@ -906,7 +906,7 @@ test("★★ A:免费档「能续接已有会话、不能开新会话」(业主
|
|
|
906
906
|
b.rt.currentSessionTitle = "已有任务";
|
|
907
907
|
await b.rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续修" } }] });
|
|
908
908
|
const prompted = subB.calls.filter((c) => c.m === "promptSession");
|
|
909
|
-
assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(
|
|
909
|
+
assert.equal(prompted.length, 1, "★免费 + 有当前会话:必须能继续聊(用户要的就是这个)");
|
|
910
910
|
assert.equal(prompted[0].text, "继续修", "内容要原样下发");
|
|
911
911
|
|
|
912
912
|
// ③ 免费仍不能开新会话、不能切换会话
|
|
@@ -993,7 +993,7 @@ test("★★ 权益包:服务端下发的 caps/limits 覆盖内置表(后台改
|
|
|
993
993
|
});
|
|
994
994
|
|
|
995
995
|
test("★★ 多条待审批:回数字必须答**最早那条**(先进先出),答完把下一条顶到底部", async () => {
|
|
996
|
-
//
|
|
996
|
+
// 用户报的真 bug:「如果需要用户回应多条消息,而用户一开始只看到第一条审批消息,
|
|
997
997
|
// 回应一个数字之后,流程就结束了。后面的几条消息没有让用户继续回应,直接提示为『未回应』」
|
|
998
998
|
// 成因:旧实现按**栈顶(最新)**匹配数字,而微信是**从上往下读**的、消息位置一发出去就固定
|
|
999
999
|
// → 用户看着第 1 条回「1」,系统答了最后一条,第 1 条永远没人回应,DSH 最终标它「未回应」。
|
|
@@ -1535,7 +1535,7 @@ test("★ 多审批 + 提醒混排:回执仍按 FIFO 走审批(提醒不参与
|
|
|
1535
1535
|
// 原先这条路径透传上游的静态 HELP_TEXT —— 那份清单把 /new、/ls、/use、/summary 与
|
|
1536
1536
|
// "直接发一句话"一并列成"你能用的"。免费用户敲错一个字,拿到的就是这张**会员清单**,
|
|
1537
1537
|
// 照着做再吃一次拒绝,于是"打错命令"被理解成"这功能坏了"。
|
|
1538
|
-
//
|
|
1538
|
+
// 用户红线:话术与权限必须一致、不多承诺。
|
|
1539
1539
|
// ---------------------------------------------------------------------------
|
|
1540
1540
|
|
|
1541
1541
|
test("★ 免费用户敲未知指令:要回显命令 + 给**分层**帮助,不得把会员指令列成『你能用的』", async () => {
|
|
@@ -1719,7 +1719,7 @@ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/
|
|
|
1719
1719
|
});
|
|
1720
1720
|
|
|
1721
1721
|
// ---------------------------------------------------------------------------
|
|
1722
|
-
// 日报**重复推送**的根治(2026-09-23
|
|
1722
|
+
// 日报**重复推送**的根治(2026-09-23 用户实测「每日简报推送了两次」)
|
|
1723
1723
|
//
|
|
1724
1724
|
// 根因:`#maybeDigest` 是「读 state → 等网络发送 → 写 state」的**跨 await 读-改-写**。
|
|
1725
1725
|
// 发送要等网络,这段窗口里第二个执行者会读到"今天还没发" → 两条都发出去。
|
|
@@ -1798,7 +1798,7 @@ test("★ 日报占位一天一个,换天时会清掉旧占位(不攒文件)", a
|
|
|
1798
1798
|
});
|
|
1799
1799
|
|
|
1800
1800
|
// ---------------------------------------------------------------------------
|
|
1801
|
-
// 出站必须回带 context_token(2026-09-23
|
|
1801
|
+
// 出站必须回带 context_token(2026-09-23 用户实测「没收到微信通道」)
|
|
1802
1802
|
//
|
|
1803
1803
|
// 平台要求出站消息带回「这条会话的上下文令牌」,而它只出现在**入站消息**里
|
|
1804
1804
|
// (官方实现:messaging/send.js 的 buildTextMessageReq → `msg.context_token`;
|
|
@@ -1831,7 +1831,7 @@ test("★ 没有 context_token 时**完全不带这个键**(而不是空串)", a
|
|
|
1831
1831
|
await rt.handleInbound({ from_user_id: "user-1", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
1832
1832
|
const last = ilink.state.sends.at(-1);
|
|
1833
1833
|
assert.ok(!("context_token" in last.msg),
|
|
1834
|
-
"★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined`
|
|
1834
|
+
"★拿不到令牌时不得塞空串(与官方 `contextToken ?? undefined` 同约定)");
|
|
1835
1835
|
} finally {
|
|
1836
1836
|
await ilink.close();
|
|
1837
1837
|
}
|
|
@@ -1887,14 +1887,14 @@ test("★ extractContextToken 防御式:形状不认识一律当没有,绝不猜
|
|
|
1887
1887
|
assert.equal(extractContextToken({ contextToken: "CT-2" }), "CT-2", "兼容驼峰写法");
|
|
1888
1888
|
});
|
|
1889
1889
|
|
|
1890
|
-
// ═════════════ 2026-09-25
|
|
1890
|
+
// ═════════════ 2026-09-25 故障:完成推送"消失" + 回话跑进另一个会话 ═════════════
|
|
1891
1891
|
//
|
|
1892
|
-
//
|
|
1892
|
+
// 现场(用户):任务跑完一条微信都没收到;他在微信里回了一句,结果**发进了上一个会话**。
|
|
1893
1893
|
// 日志证据:48 条 `sendmessage 失败:sendMessage: ret=-2 errmsg=prepare failed`
|
|
1894
1894
|
// —— 平台侧发送是**间歇性**失败的,而旧实现失败即丢弃:那条"任务跑完了"永远消失了。
|
|
1895
1895
|
// 状态证据:`current_session_id` 停在几天前的旧会话上(因为切换只在推送成功后发生)。
|
|
1896
1896
|
|
|
1897
|
-
test("★
|
|
1897
|
+
test("★ 回归锁:主动推送失败**不能消失** —— 入队,并在下一次入站时补发(带「补发」标记)", async () => {
|
|
1898
1898
|
let failSends = true;
|
|
1899
1899
|
const ilink = await fakeIlink({
|
|
1900
1900
|
onSendMessage: (_state, reply) => (failSends
|
|
@@ -1926,7 +1926,7 @@ test("★ 事故锁:主动推送失败**不能消失** —— 入队,并在
|
|
|
1926
1926
|
}
|
|
1927
1927
|
});
|
|
1928
1928
|
|
|
1929
|
-
test("★
|
|
1929
|
+
test("★ 回归锁:静音(/quiet)也要记下「最近完成的任务」,回话不能跑进上一个会话", async () => {
|
|
1930
1930
|
const ilink = await fakeIlink();
|
|
1931
1931
|
try {
|
|
1932
1932
|
const sub = makeFakeSubscriber();
|
|
@@ -1958,7 +1958,7 @@ test("★ 事故锁:静音(/quiet)也要记下「最近完成的任务」
|
|
|
1958
1958
|
}
|
|
1959
1959
|
});
|
|
1960
1960
|
|
|
1961
|
-
test("★
|
|
1961
|
+
test("★ 回归锁:用户主动 /use 切走的会话不能被「最近完成」抢回去", async () => {
|
|
1962
1962
|
const ilink = await fakeIlink();
|
|
1963
1963
|
try {
|
|
1964
1964
|
const sub = makeFakeSubscriber();
|
|
@@ -1972,7 +1972,7 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
|
|
|
1972
1972
|
assert.equal(rt.currentSessionId, "session-mine", "前置条件:/use 切到了 2 号");
|
|
1973
1973
|
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: 3 });
|
|
1974
1974
|
|
|
1975
|
-
//
|
|
1975
|
+
// 完成事件在处理时会把它自己设为回复目标(这是产品约定);但用户**之后**再切回去要算数:
|
|
1976
1976
|
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/use 2" } }] });
|
|
1977
1977
|
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "继续聊我的" } }] });
|
|
1978
1978
|
const prompt = sub.calls.filter((c) => c.m === "promptSession").pop();
|
|
@@ -1984,7 +1984,7 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
|
|
|
1984
1984
|
});
|
|
1985
1985
|
|
|
1986
1986
|
test("★ 待补发队列:过期的**不发**(丢弃最早的),且只保留最近几条", async () => {
|
|
1987
|
-
//
|
|
1987
|
+
// 用户 2026-10-09 约定:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
1988
1988
|
// 两条理由:① 迟到几小时的"任务跑完了"只会误导用户;② 攒到十几条会在平台恢复时一次连打,
|
|
1989
1989
|
// 实测正是这种突发批量触发 `ret=-2 prepare failed` 被限流 → 补发又失败 → 队列再涨(死循环)。
|
|
1990
1990
|
const ilink = await fakeIlink();
|
|
@@ -2054,7 +2054,7 @@ test("★ 待补发队列:超过上限只留最近几条(丢最早,且如
|
|
|
2054
2054
|
});
|
|
2055
2055
|
|
|
2056
2056
|
|
|
2057
|
-
test("★
|
|
2057
|
+
test("★ 回归锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
|
|
2058
2058
|
// 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
|
|
2059
2059
|
// `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
|
|
2060
2060
|
// 全都不留任何痕迹。现在必须:① 一条 warn 进 bridge 日志;② 非预期的记进状态文件。
|
|
@@ -2079,7 +2079,7 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
|
|
|
2079
2079
|
const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
2080
2080
|
assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
|
|
2081
2081
|
// ★2026-10-09:预期内的拒绝**不能写成 warn** —— 每生成一个子代理就会来一条,
|
|
2082
|
-
// 成片的 `订阅异常 session/agent-busy …`
|
|
2082
|
+
// 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(用户现场看到的就是这个)。
|
|
2083
2083
|
assert.equal(warns.length, warnCountBefore, `expected 的拒绝不得写 warn,实际:${JSON.stringify(warns)}`);
|
|
2084
2084
|
assert.ok(infos.some((m) => m.includes("订阅跳过(预期") && m.includes("session=sub-1")),
|
|
2085
2085
|
`expected 的拒绝要有一条 info 留痕(绝不静默),实际:${JSON.stringify(infos)}`);
|
|
@@ -45,7 +45,7 @@ function loadTierResolver({ localKey = "", token = "tok-1", status = 200, body =
|
|
|
45
45
|
if (throws) throw new Error("network down");
|
|
46
46
|
return { ok: status >= 200 && status < 300, status, json: async () => bodyFor() };
|
|
47
47
|
};
|
|
48
|
-
// 多出口两个"内部状态"
|
|
48
|
+
// 多出口两个"内部状态"的只读探测 —— 否则"token 缓存被清空""快照不因缺 user 被覆盖"
|
|
49
49
|
// 这两条只能用间接行为猜,抓不住回归(它们都在闭包里)。
|
|
50
50
|
const make = new Function("process", "resolveToken", "API_BASE", "fetch", "AbortSignal", "Date",
|
|
51
51
|
`${BRIDGE_SRC.slice(from, to)}
|
|
@@ -145,7 +145,7 @@ test("★档位来源:启动时必须复用桥接刚拿到的 JWT(否则每
|
|
|
145
145
|
assert.ok(seedAt < startAt, "★必须在 startWeChat() **之前**喂,否则档位校准仍会再登录一次");
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL
|
|
148
|
+
test("App 链接:由账号 API 地址推导,与插件半的 DEFAULT_APP_URL 同约定", () => {
|
|
149
149
|
assert.equal(loadAppUrl(API_BASE)(), "https://api.example/app/");
|
|
150
150
|
assert.equal(loadAppUrl("https://n.risegao.cn:13443/relay-api")(), "https://n.risegao.cn:13443/app/",
|
|
151
151
|
"生产地址必须推导出真实可点的 App 入口");
|
|
@@ -83,7 +83,7 @@ export function resolveClientVersion(raw) {
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* 数值协议常量 —— **逐个抄自归档参考实现**,不是猜的:
|
|
86
|
-
*
|
|
86
|
+
* OpenClaw 微信插件(第三方实现,仅作字段命名参考)
|
|
87
87
|
* · MessageType (types.ts:64-68) NONE:0 / USER:1 / BOT:2
|
|
88
88
|
* · MessageItemType (types.ts:70-79) NONE:0 TEXT:1 IMAGE:2 VOICE:3 FILE:4 VIDEO:5
|
|
89
89
|
* TOOL_CALL_START:11 TOOL_CALL_RESULT:12
|
|
@@ -702,7 +702,7 @@ export class IlinkClient {
|
|
|
702
702
|
// **入站消息**里(官方实现见 messaging/send.js 的 buildTextMessageReq:`msg.context_token`;
|
|
703
703
|
// 腾讯自家插件同样是「从入站取出 → 按 (账号, 用户) 缓存 → 发送时回带」)。
|
|
704
704
|
// 🔴 我们以前**从不发它** → 真机症状就是日志里的 `ret=-2 errmsg=prepare failed`:
|
|
705
|
-
//
|
|
705
|
+
// 消息发不出去(用户 2026-09-23:「没收到微信通道」)。
|
|
706
706
|
// ⚠️ 只在"有令牌且属于同一个对端"时带上,绝不拿 A 的上下文发给 B。
|
|
707
707
|
const peer = String(to);
|
|
708
708
|
// 本产品一个 bot 只绑定一个微信用户,所以只要有令牌就回带;`contextTokenPeer` 仅作诊断。
|
|
@@ -715,7 +715,7 @@ export class IlinkClient {
|
|
|
715
715
|
message_type: MessageType.BOT,
|
|
716
716
|
message_state: MessageState.FINISH,
|
|
717
717
|
item_list: text ? [{ type: MessageItemType.TEXT, text_item: { text: String(text) } }] : [],
|
|
718
|
-
// 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined`
|
|
718
|
+
// 没有就**不出现这个键**(不是空串)—— 与官方 `contextToken ?? undefined` 同约定
|
|
719
719
|
...(ctxToken ? { context_token: ctxToken } : {})
|
|
720
720
|
};
|
|
721
721
|
const { json } = await this.request({
|
|
@@ -1774,7 +1774,7 @@ export function markUnbound(relayDir, reason = "", onWarn) {
|
|
|
1774
1774
|
export const DEFAULT_REPLY_TTL_MINUTES = 5;
|
|
1775
1775
|
|
|
1776
1776
|
/**
|
|
1777
|
-
* 完成通知里「结论」的展示上限(字)
|
|
1777
|
+
* 完成通知里「结论」的展示上限(字)。用户约定(2026-09-22):
|
|
1778
1778
|
* 「任务完成通知里有些关键结论被大量截断了,加长一些,更完整地展示最终结论 ——
|
|
1779
1779
|
* 这可能是用户关注的内容,所以都给他们展示出来」。
|
|
1780
1780
|
* 原值 400 对真实结论太短(实测一条正常的修复总结就有 190+ 字,稍详细就超)。
|
|
@@ -1898,7 +1898,7 @@ export function formatNotification(node = {}, opts = {}) {
|
|
|
1898
1898
|
for (const l of Array.isArray(node.lines) ? node.lines : []) lines.push(String(l));
|
|
1899
1899
|
// 措辞对齐"回顾今天做了什么" —— 日报不是待办清单
|
|
1900
1900
|
if (!Array.isArray(node.lines) || !node.lines.length) lines.push("今天这台电脑上没有跑任务。");
|
|
1901
|
-
// 品牌轻露出(
|
|
1901
|
+
// 品牌轻露出(用户:让用户知道我们在给他提供服务)。**克制一行**、放在最底,不喧宾夺主。
|
|
1902
1902
|
lines.push("", "—— DSH 远程控制 · 微信机器人通道");
|
|
1903
1903
|
// ⚠️ 这句是**功能**不是客套:微信 24h 推送窗口靠用户回消息续期,
|
|
1904
1904
|
// 简报的产品作用正是每天制造一次互动(§7)。必须出现「回复」字样,否则用户不会回。
|
|
@@ -1974,7 +1974,7 @@ function clipText(s, n) {
|
|
|
1974
1974
|
* 为什么必须转:微信**不渲染 Markdown**(最强证据是腾讯自己的插件在出站路径跑
|
|
1975
1975
|
* `StreamingMarkdownFilter` 主动剥离"不支持的 markdown 语法")。所以模型写的表格会以
|
|
1976
1976
|
* `| a | b |` 的**字面竖线**出现在气泡里、`**加粗**` 会显示成星号。
|
|
1977
|
-
*
|
|
1977
|
+
* 用户原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」
|
|
1978
1978
|
* —— 根因就在这里,不是"排版没调好"。
|
|
1979
1979
|
*
|
|
1980
1980
|
* 规则(只改"怎么显示",不改语义):
|
|
@@ -2365,10 +2365,10 @@ export function isDestructiveTool(toolName, detail) {
|
|
|
2365
2365
|
if (DESTRUCTIVE_TOOL_NAME_RE.test(name)) return true;
|
|
2366
2366
|
|
|
2367
2367
|
const text = flattenToolDetail(detail);
|
|
2368
|
-
// ⚠️ 2026-09-22
|
|
2368
|
+
// ⚠️ 2026-09-22 按用户决定**删掉**了原来的规则③:「能跑命令的工具 + 完全看不到内容 → 算破坏性」。
|
|
2369
2369
|
// 原意是防"盲批",但 DSH 的审批节点本来就只下发 toolName、常常没有命令原文,
|
|
2370
2370
|
// 那条规则会把**绝大多数正常 Bash 审批**都降级成"只能回电脑确认",过严且伤体验。
|
|
2371
|
-
//
|
|
2371
|
+
// 用户明确:安全边界松一点没问题,**DSH 自身有权限控制**。
|
|
2372
2372
|
// 所以现在只看**内容**:看得出是 rm -rf / 强推 / DROP TABLE / 批量覆盖这类才拦;
|
|
2373
2373
|
// 看不到内容时按普通审批处理(保留一步回执)。SHELL_TOOL_NAME_RE 仍保留,
|
|
2374
2374
|
// 供将来若要恢复"盲批保护"时使用,并由测试固化其语义。
|
|
@@ -2834,7 +2834,7 @@ export class WeChatChannel {
|
|
|
2834
2834
|
// 冷却原本只按"撞到 -14"计时,但**换绑会拿到全新的 bot_token** ——
|
|
2835
2835
|
// 旧 token 的会话超时对新 token 毫无意义。不清的后果:用户解绑→重绑拿到新 token,
|
|
2836
2836
|
// 却仍被旧 token 的 1 小时冷却挡着,表现成"刚绑上就一小时内不能聊天",
|
|
2837
|
-
// 而且面板只说"通知会延迟",用户完全不知道为什么。(
|
|
2837
|
+
// 而且面板只说"通知会延迟",用户完全不知道为什么。(用户实测撞到)
|
|
2838
2838
|
this.cooldown.clear();
|
|
2839
2839
|
return this.uiAccount();
|
|
2840
2840
|
}
|