@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10

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 (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Agent Runtime 编排契约测试 —— 验证"默认关 / 门禁 / 让位 / 就绪 / 降级"五种出口。
4
+ *
5
+ * 这是挂载方唯一会直接调的入口,所以每条出口都要有明确、可断言的返回,
6
+ * 不能"抛错让上层猜":
7
+ *
8
+ * disabled → 不探测不占端口;cli-missing / version-* → 灰显原因;
9
+ * existing-hooks → **只通知模式**(让位给本机策略);bridge-error → 降级原因;
10
+ * ready → 返回 launch.args + 临时 settings,并真的接上决策回调。
11
+ *
12
+ * 用法: node --test clients/dsh-remote/test/agent-runtime.test.mjs
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import fsSync from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+
20
+ import { AGENT_STATUS, createAgentRuntime } from "../agent/index.mjs";
21
+
22
+ /** ENOENT 风格的 fs 假实现。 */
23
+ function makeFs(overrides = {}) {
24
+ const enoent = () => {
25
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
26
+ };
27
+ return {
28
+ existsSync: () => false,
29
+ statSync: () => enoent(),
30
+ readFileSync: () => enoent(),
31
+ realpathSync: (p) => p,
32
+ constants: {},
33
+ ...overrides
34
+ };
35
+ }
36
+
37
+ const okVersion = (text) => () => ({ status: 0, stdout: `${text} (Claude Code)\n`, stderr: "" });
38
+ const noNpm = () => {
39
+ throw new Error("npm 不存在");
40
+ };
41
+
42
+ test("默认关:enabled 未给 → disabled,且**连探测都不做**(不占端口、不跑 CLI)", async () => {
43
+ let spawned = false;
44
+ const rt = createAgentRuntime({
45
+ env: {},
46
+ deps: {
47
+ spawnSync: () => {
48
+ spawned = true;
49
+ return { status: 0, stdout: "2.1.318", stderr: "" };
50
+ }
51
+ }
52
+ });
53
+ const r = await rt.start();
54
+ assert.equal(r.ok, false);
55
+ assert.equal(r.status, AGENT_STATUS.DISABLED);
56
+ assert.equal(r.mode, "notify-only");
57
+ assert.equal(spawned, false, "关闭状态不该探测(不浪费资源、不暴露能力)");
58
+ assert.equal(rt.status().status, AGENT_STATUS.DISABLED);
59
+ });
60
+
61
+ test("env 打开:DSH_AGENT_ENABLED=1 即启用(挂载方不必传参)", async () => {
62
+ const rt = createAgentRuntime({
63
+ env: { DSH_AGENT_ENABLED: "1", DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
64
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm }
65
+ });
66
+ const probed = rt.probe();
67
+ assert.equal(probed.ok, true, "显式 bin + 达标版本应探测通过");
68
+ });
69
+
70
+ test("probe():只探测不占端口、不写盘(给入口灰显用)", () => {
71
+ const rt = createAgentRuntime({
72
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
73
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm }
74
+ });
75
+ const p = rt.probe();
76
+ assert.equal(p.ok, true);
77
+ assert.equal(p.status, "ok");
78
+ assert.equal(p.claude.path, "/fake/claude");
79
+ assert.equal(p.version.text, "2.1.318");
80
+ assert.equal(rt.status().status, AGENT_STATUS.DISABLED, "probe 不应改变运行状态");
81
+ });
82
+
83
+ test("probe() 结果缓存:面板重绘不反复 spawn CLI;force 可强测", () => {
84
+ let spawnCount = 0;
85
+ const rt = createAgentRuntime({
86
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
87
+ deps: {
88
+ fs: makeFs(),
89
+ execFileSync: noNpm,
90
+ spawnSync: () => {
91
+ spawnCount += 1;
92
+ return { status: 0, stdout: "2.1.318 (Claude Code)\n", stderr: "" };
93
+ }
94
+ }
95
+ });
96
+ assert.equal(rt.probe().ok, true);
97
+ assert.equal(rt.probe().ok, true);
98
+ assert.equal(spawnCount, 1, "缓存期内第二次 probe 不应再 spawn(杀软会盯上常驻轮询)");
99
+ assert.equal(rt.probe({ force: true }).ok, true);
100
+ assert.equal(spawnCount, 2, "force 必须绕过缓存");
101
+ });
102
+
103
+ test("版本门禁:2.1.195 → version-unsupported,不启动桥接", async () => {
104
+ const rt = createAgentRuntime({
105
+ enabled: true,
106
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
107
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.195"), execFileSync: noNpm }
108
+ });
109
+ const r = await rt.start();
110
+ assert.equal(r.ok, false);
111
+ assert.equal(r.status, AGENT_STATUS.VERSION_UNSUPPORTED);
112
+ assert.equal(r.mode, "notify-only");
113
+ assert.match(r.reason, /低于最低支持版本/);
114
+ assert.equal(r.port, undefined, "不可用时不得占端口");
115
+ assert.equal(rt.status().status, AGENT_STATUS.VERSION_UNSUPPORTED);
116
+ });
117
+
118
+ test("找不到 CLI → cli-missing(不抛错)", async () => {
119
+ const rt = createAgentRuntime({
120
+ enabled: true,
121
+ env: { PATH: "" },
122
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm, npmRoot: "" }
123
+ });
124
+ const r = await rt.start();
125
+ assert.equal(r.ok, false);
126
+ assert.equal(r.status, AGENT_STATUS.CLI_MISSING);
127
+ assert.match(r.reason, /没有找到 Claude Code/);
128
+ });
129
+
130
+ test("既有审批钩子 → existing-hooks + notify-only(让位,不注入自己的钩子)", async () => {
131
+ const homeDir = "/home/u";
132
+ const fs = makeFs({
133
+ readFileSync: (p) => {
134
+ if (p === path.join(homeDir, ".claude", "settings.json")) {
135
+ return JSON.stringify({ hooks: { PermissionRequest: [{ hooks: [{ type: "command", command: "policy.sh" }] }] } });
136
+ }
137
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
138
+ }
139
+ });
140
+ const rt = createAgentRuntime({
141
+ enabled: true,
142
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
143
+ deps: { fs, spawnSync: okVersion("2.1.318"), execFileSync: noNpm, homeDir }
144
+ });
145
+ const r = await rt.start();
146
+ assert.equal(r.ok, false);
147
+ assert.equal(r.status, AGENT_STATUS.EXISTING_HOOKS);
148
+ assert.equal(r.mode, "notify-only");
149
+ assert.match(r.reason, /让位/);
150
+ });
151
+
152
+ test("DSH_AGENT_NOTIFY_ONLY=1 → 强制只通知(企业策略/排障)", async () => {
153
+ const rt = createAgentRuntime({
154
+ env: {
155
+ DSH_AGENT_ENABLED: "1",
156
+ DSH_AGENT_NOTIFY_ONLY: "1",
157
+ DSH_AGENT_CLAUDE_BIN: "/fake/claude"
158
+ },
159
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm }
160
+ });
161
+ const r = await rt.start();
162
+ assert.equal(r.ok, false);
163
+ assert.equal(r.status, AGENT_STATUS.EXISTING_HOOKS);
164
+ assert.equal(r.mode, "notify-only");
165
+ assert.match(r.reason, /强制只通知/);
166
+ });
167
+
168
+ test("就绪 → 返回 launch.args + 临时 settings,并真的接上决策回调", async () => {
169
+ const settingsDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "agent-rt-"));
170
+ const decided = [];
171
+ let settingsPath = "";
172
+ try {
173
+ const rt = createAgentRuntime({
174
+ enabled: true,
175
+ env: {
176
+ DSH_AGENT_CLAUDE_BIN: "/fake/claude",
177
+ DSH_AGENT_SETTINGS_DIR: settingsDir
178
+ },
179
+ decide: async (req) => {
180
+ decided.push(req);
181
+ return { decision: "deny", message: "手机端拒绝" };
182
+ },
183
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm }
184
+ });
185
+
186
+ const r = await rt.start();
187
+ assert.equal(r.ok, true);
188
+ assert.equal(r.status, AGENT_STATUS.READY);
189
+ assert.equal(r.mode, "approval");
190
+ assert.ok(r.port > 0, "随机端口应已占用");
191
+ assert.equal(r.launch.args[0], "--permission-mode");
192
+ assert.equal(r.launch.args[1], "default");
193
+ assert.equal(r.launch.args[2], "--settings");
194
+ settingsPath = r.launch.args[3];
195
+ assert.equal(settingsPath, r.settingsPath);
196
+ assert.ok(fsSync.existsSync(settingsPath), "临时 settings 必须落盘");
197
+
198
+ // 从 settings 里取出带 token 的钩子 URL,模拟 Claude Code 发一次审批
199
+ const saved = JSON.parse(fsSync.readFileSync(settingsPath, "utf8"));
200
+ const hookUrl = saved.hooks.PermissionRequest[0].hooks[0].url;
201
+ const res = await fetch(hookUrl, {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({
205
+ session_id: "s",
206
+ cwd: "/tmp/p",
207
+ permission_mode: "default",
208
+ hook_event_name: "PermissionRequest",
209
+ tool_name: "Bash",
210
+ tool_input: { command: "rm -rf build" }
211
+ })
212
+ });
213
+ assert.equal(res.status, 200);
214
+ const body = await res.json();
215
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
216
+ assert.equal(body.hookSpecificOutput.decision.message, "手机端拒绝");
217
+ assert.equal(decided.length, 1, "决策回调应被调用一次");
218
+ assert.equal(decided[0].toolName, "Bash");
219
+ assert.equal(decided[0].detail, "rm -rf build", "detail 给决策方(手机)看");
220
+
221
+ await rt.stop();
222
+ assert.equal(fsSync.existsSync(settingsPath), false, "stop() 必须清理临时 settings");
223
+ assert.equal(rt.status().status, AGENT_STATUS.DISABLED, "停止后回到未启用状态");
224
+ await rt.stop(); // 幂等
225
+ } finally {
226
+ fsSync.rmSync(settingsDir, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ test("没有决策来源 → 立即拒绝(fail-closed,不等 90s)", async () => {
231
+ const settingsDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "agent-rt2-"));
232
+ try {
233
+ const rt = createAgentRuntime({
234
+ enabled: true,
235
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude", DSH_AGENT_SETTINGS_DIR: settingsDir },
236
+ deps: { fs: makeFs(), spawnSync: okVersion("2.1.318"), execFileSync: noNpm }
237
+ });
238
+ const r = await rt.start();
239
+ assert.equal(r.ok, true);
240
+ const saved = JSON.parse(fsSync.readFileSync(r.settingsPath, "utf8"));
241
+ const res = await fetch(saved.hooks.PermissionRequest[0].hooks[0].url, {
242
+ method: "POST",
243
+ headers: { "content-type": "application/json" },
244
+ body: JSON.stringify({
245
+ hook_event_name: "PermissionRequest",
246
+ tool_name: "Bash",
247
+ tool_input: { command: "echo hi" }
248
+ })
249
+ });
250
+ const body = await res.json();
251
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
252
+ assert.match(body.hookSpecificOutput.decision.message, /未接入决策来源/);
253
+ await rt.stop();
254
+ } finally {
255
+ fsSync.rmSync(settingsDir, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test("桥接启动失败(临时目录不可写)→ bridge-error + notify-only,不抛错", async () => {
260
+ const rt = createAgentRuntime({
261
+ enabled: true,
262
+ env: { DSH_AGENT_CLAUDE_BIN: "/fake/claude" },
263
+ deps: {
264
+ fs: makeFs(),
265
+ spawnSync: okVersion("2.1.318"),
266
+ execFileSync: noNpm,
267
+ bridgeFs: {
268
+ mkdtempSync: () => {
269
+ throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
270
+ },
271
+ writeFileSync: () => {},
272
+ rmSync: () => {}
273
+ }
274
+ }
275
+ });
276
+ const r = await rt.start();
277
+ assert.equal(r.ok, false);
278
+ assert.equal(r.status, AGENT_STATUS.BRIDGE_ERROR);
279
+ assert.equal(r.mode, "notify-only");
280
+ assert.match(r.reason, /审批桥接启动失败/);
281
+ assert.equal(rt.status().status, AGENT_STATUS.BRIDGE_ERROR);
282
+ });
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 版本门禁契约测试。
4
+ *
5
+ * 背景(M2 第 1 周实测,报告 §2.2):同一条 `PermissionRequest` 钩子注入,
6
+ * **2.1.318 生效、2.1.195 完全不触发**。旧版本上动作仍会被 Claude Code 自己拒绝,
7
+ * 但我们的手机端**永远收不到审批** —— 静默失败比明确不可用危险得多,所以门禁必须硬。
8
+ *
9
+ * 这里守四件事:
10
+ * ① 解析 `claude --version` 的真实输出形态(`2.1.195 (Claude Code)` 等);
11
+ * ② 比较语义(含预发布 < 正式版);
12
+ * ③ 阈值可被 env 覆盖,但**写错必须回落保守默认**(不能因为配置错就放行);
13
+ * ④ 版本低于阈值 → `ok:false` + 明确 status;等于阈值 → 放行(边界)。
14
+ *
15
+ * 用法: node --test clients/dsh-remote/test/agent-version.test.mjs
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import test from "node:test";
19
+
20
+ import {
21
+ DEFAULT_MIN_CLAUDE_VERSION,
22
+ MIN_VERSION_ENV,
23
+ checkVersionGate,
24
+ compareVersions,
25
+ formatVersion,
26
+ parseClaudeVersion,
27
+ resolveMinVersion
28
+ } from "../agent/version.mjs";
29
+
30
+ test("解析:本机实测输出 `2.1.195 (Claude Code)`", () => {
31
+ const v = parseClaudeVersion("2.1.195 (Claude Code)\n");
32
+ assert.deepEqual(
33
+ { major: v.major, minor: v.minor, patch: v.patch, prerelease: v.prerelease },
34
+ { major: 2, minor: 1, patch: 195, prerelease: "" }
35
+ );
36
+ });
37
+
38
+ test("解析:兼容 `claude 2.1.318` / 纯版本号 / 预发布", () => {
39
+ assert.equal(parseClaudeVersion("claude 2.1.318").patch, 318);
40
+ assert.equal(parseClaudeVersion("2.1.318").minor, 1);
41
+ const pre = parseClaudeVersion("2.1.318-beta.2 (Claude Code)");
42
+ assert.equal(pre.prerelease, "beta.2");
43
+ });
44
+
45
+ test("解析:认不出就返回 null(不抛错,交给门禁保守处理)", () => {
46
+ for (const bad of ["", " ", "not-a-version", "Claude Code", undefined, null]) {
47
+ assert.equal(parseClaudeVersion(bad), null, `应返回 null: ${String(bad)}`);
48
+ }
49
+ });
50
+
51
+ test("比较:数字段优先,预发布 < 同号正式版", () => {
52
+ const a = parseClaudeVersion("2.1.195");
53
+ const b = parseClaudeVersion("2.1.318");
54
+ const c = parseClaudeVersion("2.2.0");
55
+ assert.equal(compareVersions(a, b), -1);
56
+ assert.equal(compareVersions(b, a), 1);
57
+ assert.equal(compareVersions(a, a), 0);
58
+ assert.equal(compareVersions(b, c), -1, "minor 更大即更新");
59
+ const pre = parseClaudeVersion("2.1.318-beta.1");
60
+ const rel = parseClaudeVersion("2.1.318");
61
+ assert.equal(compareVersions(pre, rel), -1);
62
+ assert.equal(compareVersions(rel, pre), 1);
63
+ });
64
+
65
+ test("格式化:预发布保留后缀", () => {
66
+ assert.equal(formatVersion(parseClaudeVersion("2.1.318")), "2.1.318");
67
+ assert.equal(formatVersion(parseClaudeVersion("2.1.318-rc.1")), "2.1.318-rc.1");
68
+ assert.equal(formatVersion(null), "");
69
+ });
70
+
71
+ test("阈值:默认 2.1.318(实测唯一确认可用的版本)", () => {
72
+ const r = resolveMinVersion({});
73
+ assert.equal(r.source, "default");
74
+ assert.equal(r.minText, DEFAULT_MIN_CLAUDE_VERSION);
75
+ assert.equal(DEFAULT_MIN_CLAUDE_VERSION, "2.1.318");
76
+ });
77
+
78
+ test("阈值:env 可覆盖(版本二分/灰度用)", () => {
79
+ const r = resolveMinVersion({ [MIN_VERSION_ENV]: "2.1.250" });
80
+ assert.equal(r.source, "env");
81
+ assert.equal(r.minText, "2.1.250");
82
+ assert.equal(r.warning, undefined);
83
+ });
84
+
85
+ test("阈值:env 写错必须回落默认 + 告警(不能因为配置错就放行)", () => {
86
+ const r = resolveMinVersion({ [MIN_VERSION_ENV]: "latest" });
87
+ assert.equal(r.source, "default");
88
+ assert.equal(r.minText, DEFAULT_MIN_CLAUDE_VERSION);
89
+ assert.match(r.warning, /不是合法版本号/);
90
+ });
91
+
92
+ test("门禁:低于阈值 → version-unsupported(带 required/actual)", () => {
93
+ const r = checkVersionGate({
94
+ version: parseClaudeVersion("2.1.195"),
95
+ min: parseClaudeVersion("2.1.318"),
96
+ minText: "2.1.318",
97
+ versionText: "2.1.195"
98
+ });
99
+ assert.equal(r.ok, false);
100
+ assert.equal(r.status, "version-unsupported");
101
+ assert.equal(r.required, "2.1.318");
102
+ assert.equal(r.actual, "2.1.195");
103
+ assert.match(r.reason, /低于最低支持版本/);
104
+ });
105
+
106
+ test("门禁:等于阈值放行(边界不能把达标版本挡掉)", () => {
107
+ const r = checkVersionGate({
108
+ version: parseClaudeVersion("2.1.318"),
109
+ min: parseClaudeVersion("2.1.318"),
110
+ minText: "2.1.318",
111
+ versionText: "2.1.318"
112
+ });
113
+ assert.equal(r.ok, true);
114
+ assert.equal(r.status, "ok");
115
+ });
116
+
117
+ test("门禁:拿不到版本 → version-unknown(保守不放行)", () => {
118
+ const r = checkVersionGate({ version: null, min: parseClaudeVersion("2.1.318"), minText: "2.1.318" });
119
+ assert.equal(r.ok, false);
120
+ assert.equal(r.status, "version-unknown");
121
+ assert.match(r.reason, /无法解析/);
122
+ });
@@ -0,0 +1,192 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { createApprovalCoordinator, DENY_MESSAGES, MAX_PENDING_APPROVALS } from "../approval-coordinator.mjs";
5
+
6
+ /** 可控假时钟:手动 flush 到点的定时器。 */
7
+ function makeClock() {
8
+ let seq = 0;
9
+ const timers = new Map();
10
+ return {
11
+ setTimeoutImpl(fn, ms) {
12
+ const id = ++seq;
13
+ timers.set(id, { fn, ms, at: ms });
14
+ return id;
15
+ },
16
+ clearTimeoutImpl(id) { timers.delete(id); },
17
+ /** 触发所有到点的定时器(简单起见:全部触发)。 */
18
+ fireAll() {
19
+ const list = [...timers.entries()].slice();
20
+ for (const [id, t] of list) { timers.delete(id); t.fn(); }
21
+ return list.length;
22
+ },
23
+ count() { return timers.size; }
24
+ };
25
+ }
26
+
27
+ const flush = () => new Promise((r) => setImmediate(r));
28
+
29
+ test("① 手机点允许 → ask() 返回 allow(只允许一次,由调用方映射)", async () => {
30
+ const clock = makeClock();
31
+ const reports = [];
32
+ const c = createApprovalCoordinator({
33
+ report: (p) => { reports.push(p); },
34
+ ...clock
35
+ });
36
+ const p = c.ask({ requestId: "r1", toolName: "Bash", detail: "npm run deploy" });
37
+ assert.deepEqual(c._pendingIds(), ["r1"]);
38
+ assert.equal(reports.length, 1);
39
+ assert.equal(reports[0].requestId, "r1");
40
+ assert.equal(reports[0].summary, "npm run deploy");
41
+ assert.equal(c.resolve("r1", "allow"), true);
42
+ assert.deepEqual(await p, { decision: "allow", message: undefined });
43
+ assert.equal(c.status().allowed, 1);
44
+ assert.equal(clock.count(), 0, "settle 后必须清掉定时器");
45
+ });
46
+
47
+ test("② 手机点拒绝 → deny,且带拒绝说明", async () => {
48
+ const clock = makeClock();
49
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
50
+ const p = c.ask({ requestId: "r2", toolName: "Write" });
51
+ assert.equal(c.resolve("r2", "deny"), true);
52
+ const r = await p;
53
+ assert.equal(r.decision, "deny");
54
+ assert.equal(c.status().denied, 1);
55
+ });
56
+
57
+ test("③ TTL 到点 → 必须 deny(fail-closed:没人拍板 ≠ 同意)", async () => {
58
+ const clock = makeClock();
59
+ const c = createApprovalCoordinator({ report: () => true, ttlMs: 90_000, ...clock });
60
+ const p = c.ask({ requestId: "r3", toolName: "Bash", detail: "rm -rf build" });
61
+ assert.equal(clock.fireAll(), 1);
62
+ const r = await p;
63
+ assert.equal(r.decision, "deny");
64
+ assert.equal(r.message, DENY_MESSAGES.timeout);
65
+ assert.equal(c.status().expired, 1);
66
+ });
67
+
68
+ test("④ 重复 resolve 幂等返回 false(不覆盖第一次决定)", async () => {
69
+ const clock = makeClock();
70
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
71
+ const p = c.ask({ requestId: "r4" });
72
+ assert.equal(c.resolve("r4", "allow"), true);
73
+ assert.equal(c.resolve("r4", "deny"), false);
74
+ assert.equal((await p).decision, "allow");
75
+ });
76
+
77
+ test("⑤ 不认识的 requestId → resolve 返回 false(交给 DSH 那条路处理)", () => {
78
+ const clock = makeClock();
79
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
80
+ assert.equal(c.resolve("not-mine", "allow"), false);
81
+ });
82
+
83
+ test("⑥ 并发上限:超过上限直接拒绝,不让第 9 条挂着", async () => {
84
+ const clock = makeClock();
85
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
86
+ const ps = [];
87
+ for (let i = 0; i < MAX_PENDING_APPROVALS; i++) ps.push(c.ask({ requestId: "ok-" + i }));
88
+ const over = await c.ask({ requestId: "over-1" });
89
+ assert.equal(over.decision, "deny");
90
+ assert.equal(over.message, DENY_MESSAGES.overflow);
91
+ assert.equal(c.status().overflow, 1);
92
+ // 清理:全部决议掉,避免悬挂
93
+ for (let i = 0; i < MAX_PENDING_APPROVALS; i++) c.resolve("ok-" + i, "deny");
94
+ await Promise.all(ps);
95
+ });
96
+
97
+ test("⑦ 同一请求重复 ask(钩子重试)复用同一个 Promise,且只上报一次", async () => {
98
+ const clock = makeClock();
99
+ let reports = 0;
100
+ const c = createApprovalCoordinator({ report: () => { reports += 1; }, ...clock });
101
+ const p1 = c.ask({ requestId: "dup" });
102
+ const p2 = c.ask({ requestId: "dup" });
103
+ assert.equal(p1, p2);
104
+ assert.equal(reports, 1);
105
+ c.resolve("dup", "allow");
106
+ assert.deepEqual(await p1, { decision: "allow", message: undefined });
107
+ });
108
+
109
+ test("⑧ stop() 把全部未决审批结算为拒绝(绝不留悬挂 Promise)", async () => {
110
+ const clock = makeClock();
111
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
112
+ const p1 = c.ask({ requestId: "s1" });
113
+ const p2 = c.ask({ requestId: "s2" });
114
+ const n = c.stop();
115
+ assert.equal(n, 2);
116
+ assert.equal((await p1).decision, "deny");
117
+ assert.equal((await p2).decision, "deny");
118
+ assert.equal(c._pendingIds().length, 0);
119
+ // stop 之后新请求立即拒绝,不再挂着
120
+ const r = await c.ask({ requestId: "s3" });
121
+ assert.equal(r.decision, "deny");
122
+ assert.equal(r.message, DENY_MESSAGES.stopped);
123
+ });
124
+
125
+ test("⑨ 上报抛错不影响超时兜底(不会挂死钩子)", async () => {
126
+ const clock = makeClock();
127
+ const c = createApprovalCoordinator({
128
+ report: () => { throw new Error("network down"); },
129
+ ...clock
130
+ });
131
+ const p = c.ask({ requestId: "r9" });
132
+ assert.equal(clock.fireAll(), 1);
133
+ const r = await p;
134
+ assert.equal(r.decision, "deny");
135
+ });
136
+
137
+ test("⑩ 没有上报通道时也能工作(到点拒绝),并告警一次", async () => {
138
+ const clock = makeClock();
139
+ const warnings = [];
140
+ const c = createApprovalCoordinator({
141
+ log: (lvl, msg) => { if (lvl === "warn") warnings.push(msg); },
142
+ ...clock
143
+ });
144
+ const p = c.ask({ requestId: "r10" });
145
+ clock.fireAll();
146
+ assert.equal((await p).decision, "deny");
147
+ assert.ok(warnings.some((w) => /没有上报通道/.test(w)));
148
+ });
149
+
150
+ test("⑪ 日志不含命令原文(隐私);只含 requestId/toolName/结论", async () => {
151
+ const clock = makeClock();
152
+ const logs = [];
153
+ const c = createApprovalCoordinator({
154
+ report: () => true,
155
+ log: (lvl, msg) => logs.push(msg),
156
+ ...clock
157
+ });
158
+ const p = c.ask({ requestId: "r11", toolName: "Bash", detail: "rm -rf /important" });
159
+ c.resolve("r11", "allow");
160
+ await p;
161
+ const all = logs.join("\n");
162
+ assert.ok(!all.includes("rm -rf /important"), "日志绝不能出现命令原文");
163
+ assert.match(all, /r11/);
164
+ });
165
+
166
+ test("⑫ 缺 requestId 的请求直接拒绝(不挂起)", async () => {
167
+ const clock = makeClock();
168
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
169
+ const r = await c.ask({ toolName: "Bash" });
170
+ assert.equal(r.decision, "deny");
171
+ assert.equal(c._pendingIds().length, 0);
172
+ });
173
+
174
+ test("⑬ status() 字段齐全(面板/诊断用)", () => {
175
+ const clock = makeClock();
176
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
177
+ const st = c.status();
178
+ for (const k of ["asked", "allowed", "denied", "expired", "overflow", "pending", "ttlMs", "maxPending"]) {
179
+ assert.ok(k in st, "status 缺字段 " + k);
180
+ }
181
+ assert.equal(st.ttlMs, 90_000);
182
+ });
183
+
184
+ test("⑭ resolve 后立刻 await 能拿到结果(微任务级,不需要额外 tick)", async () => {
185
+ const clock = makeClock();
186
+ const c = createApprovalCoordinator({ report: () => true, ...clock });
187
+ const p = c.ask({ requestId: "fast" });
188
+ c.resolve("fast", "allow");
189
+ const r = await p;
190
+ assert.equal(r.decision, "allow");
191
+ await flush();
192
+ });
@@ -0,0 +1,90 @@
1
+ // E2EE 死会话拒绝的**可观测字段**回归 · bridge 侧(0.7.10 / W1-T3)。
2
+ //
3
+ // 背景(2026-10 生产复盘):手机端与电脑之间的 WS 升级被拒里 **71.8%** 是
4
+ // `e2ee_session_locked`(死会话),涉 163 用户;单个设备一天能撞 8997 次。
5
+ // 0.7.3+ 已闭环(行为回归见 e2ee-shim-selfheal / mobile-adapter-resume 两个用例):
6
+ // · 手机侧:清内存会话 + 清交接单 + 每标签页最多一次受控 reload,徽标写「加密会话已失效」;
7
+ // · bridge 侧:拒绝时回 `retryable:true, action:"rehandshake"`,手机可自动重握手。
8
+ //
9
+ // 缺的那一半是「闭环之后还剩多少」:这个比例以前只在 nginx/router 侧算得出来,
10
+ // 客户端自己的支持包答不出。本文件锁死新增的可观测记账:
11
+ // ① 拒绝次数进 `.dsh-bridge-state.json`(节流 ≥10s,不加定时器、不额外写盘);
12
+ // ② 协议行为一个字节都不变(拒绝帧仍带 retryable/action)。
13
+ // 插件侧「复制诊断信息」带出这一行的用例见 packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs。
14
+ import assert from "node:assert/strict";
15
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+
20
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "dsh-e2ee-rej-"));
21
+ // 环境必须在 import dsh-bridge 之前定好(模块级常量在 import 时求值)
22
+ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, ".dsh-config.json");
23
+ process.env.DSH_BRIDGE_UPSTREAM = "http://127.0.0.1:1"; // 本用例只走拒绝分支,不连上游
24
+ process.env.DSH_BRIDGE_TUNNEL_URL = "ws://127.0.0.1:1";
25
+ const bridge = await import("../dsh-bridge.mjs");
26
+ const e2eeMod = await import("../e2ee-client.mjs");
27
+ const STATE_FILE = path.join(tmpDir, ".dsh-bridge-state.json");
28
+
29
+ test.after(async () => {
30
+ try { bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false })); } catch { /* 忽略 */ }
31
+ try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
32
+ });
33
+
34
+ function spy() {
35
+ const frames = [];
36
+ return { frames, send: (obj) => { frames.push(obj); return true; } };
37
+ }
38
+ const readState = async () => {
39
+ try { return JSON.parse(await readFile(STATE_FILE, "utf8")) || {}; } catch { return {}; }
40
+ };
41
+
42
+ test("★ e2ee 会话失效拒绝:协议不变(retryable+rehandshake)且记进状态文件", async () => {
43
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({
44
+ enabled: true, mk: Buffer.alloc(32, 7), profile: "pbkdf2-sha256-600k", salt: "salt-abc", epoch: 0
45
+ }));
46
+ try {
47
+ const before = bridge.__e2eeRejectStatsForTest().locked;
48
+ const { frames, send } = spy();
49
+ const sessId = e2eeMod.newSessId();
50
+ await bridge.handleWsOpen(send, {
51
+ id: "e2ee-locked",
52
+ type: "ws-open",
53
+ path: `/ws-echo?e2ee=${sessId}&w=0123456789abcdef`,
54
+ headers: {}
55
+ });
56
+ assert.equal(frames.length, 1);
57
+ assert.equal(frames[0].ok, false);
58
+ assert.equal(frames[0].code, 1008);
59
+ assert.equal(frames[0].retryable, true, "★ 必须告诉手机这是可自动重试的(0.7.3+ 闭环的一半)");
60
+ assert.equal(frames[0].action, "rehandshake", "★ 让手机自动重握手,而不是弹「请重新解锁」");
61
+ assert.match(String(frames[0].reason || ""), /e2ee_session_locked/);
62
+
63
+ assert.equal(bridge.__e2eeRejectStatsForTest().locked, before + 1, "拒绝必须记账(可观测字段的来源)");
64
+ // 首次拒绝立即落盘(之后 10s 内节流合并)—— 支持包里不必等心跳就能看到
65
+ const state = await readState();
66
+ assert.equal(state.e2ee_ws_reject_locked, before + 1, "状态文件应带 e2ee_ws_reject_locked");
67
+ assert.ok(Number(state.e2ee_ws_reject_at) > 0, "应有最近一次拒绝时间(判断是否还在持续撞)");
68
+ } finally {
69
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
70
+ }
71
+ });
72
+
73
+ test("★ e2ee 未启用(明文回退)的拒绝单独计数,不与死会话混在一起", async () => {
74
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
75
+ try {
76
+ const before = bridge.__e2eeRejectStatsForTest().disabled;
77
+ const { frames, send } = spy();
78
+ await bridge.handleWsOpen(send, {
79
+ id: "e2ee-off",
80
+ type: "ws-open",
81
+ path: `/ws-echo?e2ee=${e2eeMod.newSessId()}&w=0123456789abcdef`,
82
+ headers: {}
83
+ });
84
+ assert.equal(frames[0].ok, false);
85
+ assert.match(String(frames[0].reason || ""), /e2ee_disabled/);
86
+ assert.equal(bridge.__e2eeRejectStatsForTest().disabled, before + 1, "e2ee_disabled 必须单独计数");
87
+ } finally {
88
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
89
+ }
90
+ });