@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.11-beta.3

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 (68) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +214 -1
  16. package/clients/dsh-remote/push-reporter.mjs +54 -0
  17. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  18. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  19. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  20. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  21. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  22. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  23. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  24. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  25. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  26. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  27. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  28. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  29. package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
  30. package/clients/dsh-remote/wecom-bot.mjs +2444 -0
  31. package/dsh-setup.mjs +18 -3
  32. package/package.json +2 -2
  33. package/packages/dsh-remote-web/lib/client.js +1048 -223
  34. package/packages/dsh-remote-web/lib/index.js +299 -2
  35. package/packages/dsh-remote-web/package.json +1 -1
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  39. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  53. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
  54. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  55. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  56. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  57. package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
  58. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  59. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
  60. package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
  61. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  62. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  63. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
  64. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
  65. package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
  66. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  67. package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
  68. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PermissionBridge 契约测试 —— 本轮最核心的模块。
4
+ *
5
+ * 每条用例都对着第 1 周**实测**结论写(报告 §2/§3):
6
+ * ① 响应体形状:只有 `hookSpecificOutput.hookEventName + decision.behavior` 生效,
7
+ * 其他形状一律等于"无决策 = 拒绝"(所以异常输入我们**不返回 allow**);
8
+ * ② 超时 = 拒绝:TTL 到期必须 deny,绝不"没回复就放行";
9
+ * ③ token / 事件名 / 体积 / 并发 四道闸,任何一道不过都不放行;
10
+ * ④ 临时 settings 只写权限钩子、0600、`stop()` 必删;
11
+ * ⑤ **日志不含命令原文**(隐私要求)。
12
+ *
13
+ * 用真实 loopback HTTP(127.0.0.1:0 随机端口),不 mock fetch;外部依赖(时钟/计时器/fs)
14
+ * 可注入的才注入。测试期由 scripts/test-net-guard.cjs 保证只能打本地。
15
+ *
16
+ * 用法: node --test clients/dsh-remote/test/agent-permission-bridge.test.mjs
17
+ */
18
+ import assert from "node:assert/strict";
19
+ import fsSync from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import test from "node:test";
23
+
24
+ import {
25
+ DEFAULT_DENY_MESSAGE,
26
+ buildDecisionResponse,
27
+ buildPermissionSettings,
28
+ createPermissionBridge,
29
+ normalizePermissionRequest,
30
+ permissionOptions,
31
+ tokenMatches,
32
+ toolKindOf
33
+ } from "../agent/permission-bridge.mjs";
34
+
35
+ const TOKEN = "test-token-123";
36
+
37
+ /** 起一个桥,跑完必停。 */
38
+ async function withBridge(opts, fn) {
39
+ const logs = [];
40
+ const bridge = createPermissionBridge({
41
+ token: TOKEN,
42
+ ttlMs: 2000,
43
+ decide: async () => ({ decision: "allow" }),
44
+ log: (e) => logs.push(e),
45
+ ...opts
46
+ });
47
+ const started = await bridge.start();
48
+ try {
49
+ return await fn({ bridge, started, logs });
50
+ } finally {
51
+ await bridge.stop();
52
+ }
53
+ }
54
+
55
+ const post = (started, body, { token = TOKEN, pathname = "/permission" } = {}) =>
56
+ fetch(`${started.url}${pathname}?t=${token}`, {
57
+ method: "POST",
58
+ headers: { "content-type": "application/json" },
59
+ body: typeof body === "string" ? body : JSON.stringify(body)
60
+ });
61
+
62
+ const permBody = (toolName = "Bash", input = { command: "echo hi" }) => ({
63
+ session_id: "s1",
64
+ cwd: "/tmp/proj",
65
+ permission_mode: "default",
66
+ hook_event_name: "PermissionRequest",
67
+ tool_name: toolName,
68
+ tool_input: input
69
+ });
70
+
71
+ test("工具类别映射:未知工具落 other,不猜", () => {
72
+ assert.equal(toolKindOf("Bash"), "execute");
73
+ assert.equal(toolKindOf("Write"), "edit");
74
+ assert.equal(toolKindOf("Read"), "read");
75
+ assert.equal(toolKindOf("WebFetch"), "fetch");
76
+ assert.equal(toolKindOf("SomethingNew"), "other");
77
+ assert.equal(toolKindOf(undefined), "other");
78
+ });
79
+
80
+ test("审批选项:固定两项(实测没有 permission_suggestions,不能依赖上游)", () => {
81
+ const opts = permissionOptions();
82
+ assert.deepEqual(opts.map((o) => o.kind), ["allow_once", "reject_once"]);
83
+ assert.deepEqual(opts.map((o) => o.optionId), ["allow-once", "reject-once"]);
84
+ });
85
+
86
+ test("响应体:只产出实测确认过的两种形状", () => {
87
+ assert.deepEqual(buildDecisionResponse("allow"), {
88
+ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } }
89
+ });
90
+ const deny = buildDecisionResponse("deny", "不许");
91
+ assert.deepEqual(deny, {
92
+ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "不许" } }
93
+ });
94
+ // 未知 decision 一律按拒绝(fail-closed)
95
+ assert.equal(buildDecisionResponse("maybe").hookSpecificOutput.decision.behavior, "deny");
96
+ // message 有上限,避免超长进模型上下文
97
+ assert.equal(buildDecisionResponse("deny", "x".repeat(5000)).hookSpecificOutput.decision.message.length, 1024);
98
+ });
99
+
100
+ test("临时 settings:只声明 PermissionRequest http 钩子,URL 带端口与 token", () => {
101
+ const s = buildPermissionSettings({ port: 4321, token: TOKEN, hookTimeoutSec: 120 });
102
+ assert.deepEqual(Object.keys(s.hooks), ["PermissionRequest"], "不得注入其它事件(SessionStart 实测不支持 http)");
103
+ const hook = s.hooks.PermissionRequest[0].hooks[0];
104
+ assert.equal(hook.type, "http");
105
+ assert.equal(hook.timeout, 120, "timeout 单位是秒(实测)");
106
+ assert.equal(hook.url, `http://127.0.0.1:4321/permission?t=${TOKEN}`);
107
+ assert.equal(s.hooks.PermissionRequest[0].matcher, "*");
108
+ });
109
+
110
+ test("归一化:Bash → execute + 命令原文进 detail;Write → edit + 路径", () => {
111
+ const now = 1_700_000_000_000;
112
+ const bash = normalizePermissionRequest({
113
+ body: permBody("Bash", { command: "rm -rf build" }),
114
+ requestId: "r1",
115
+ now,
116
+ ttlMs: 90_000
117
+ });
118
+ assert.equal(bash.ok, true);
119
+ assert.equal(bash.request.kind, "execute");
120
+ assert.equal(bash.request.detail, "rm -rf build");
121
+ assert.equal(bash.request.expiresAt, now + 90_000);
122
+ assert.equal(bash.request.options.length, 2);
123
+
124
+ const write = normalizePermissionRequest({
125
+ body: permBody("Write", { file_path: "/tmp/a.txt", content: "hi" }),
126
+ requestId: "r2",
127
+ now,
128
+ ttlMs: 1000
129
+ });
130
+ assert.equal(write.request.kind, "edit");
131
+ assert.equal(write.request.detail, "/tmp/a.txt");
132
+ assert.equal(write.request.title, "Write: 写入文件");
133
+ });
134
+
135
+ test("归一化:detail 超长截断并标记(不把整文件塞进内存)", () => {
136
+ const r = normalizePermissionRequest({
137
+ body: permBody("Bash", { command: "x".repeat(5000) }),
138
+ requestId: "r",
139
+ now: 0,
140
+ ttlMs: 1000,
141
+ maxDetailChars: 100
142
+ });
143
+ assert.equal(r.request.detail.length, 100);
144
+ assert.equal(r.request.detailTruncated, true);
145
+ });
146
+
147
+ test("归一化:未知事件 / 缺 tool_name / 非对象 → ok:false(上层按拒绝)", () => {
148
+ assert.equal(normalizePermissionRequest({ body: { hook_event_name: "PreToolUse", tool_name: "Bash" }, requestId: "r", now: 0, ttlMs: 1 }).ok, false);
149
+ assert.equal(normalizePermissionRequest({ body: { hook_event_name: "PermissionRequest" }, requestId: "r", now: 0, ttlMs: 1 }).ok, false);
150
+ assert.equal(normalizePermissionRequest({ body: null, requestId: "r", now: 0, ttlMs: 1 }).ok, false);
151
+ });
152
+
153
+ test("token 比较:等长且相同才通过;空/不等长一律 false", () => {
154
+ assert.equal(tokenMatches("abc", "abc"), true);
155
+ assert.equal(tokenMatches("abc", "abd"), false);
156
+ assert.equal(tokenMatches("abc", "abcd"), false);
157
+ assert.equal(tokenMatches("", ""), false);
158
+ assert.equal(tokenMatches(TOKEN, undefined), false);
159
+ });
160
+
161
+ test("只允许绑回环:广域 bind 直接抛错(不能把审批端点开给局域网)", () => {
162
+ assert.throws(() => createPermissionBridge({ host: "0.0.0.0" }), /只允许绑回环/);
163
+ });
164
+
165
+ test("端到端:允许 → 200 + 实测形状;临时 settings 存在且 0600;stop 后目录被删", async () => {
166
+ const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "agent-bridge-test-"));
167
+ let settingsPath = "";
168
+ try {
169
+ await withBridge({ settingsDir: dir, decide: async () => ({ decision: "allow" }) }, async ({ started }) => {
170
+ settingsPath = started.settingsPath;
171
+ assert.ok(fsSync.existsSync(settingsPath), "临时 settings 必须落盘");
172
+ const mode = fsSync.statSync(settingsPath).mode & 0o777;
173
+ assert.equal(mode, 0o600, "settings 含 token,必须 0600");
174
+
175
+ const saved = JSON.parse(fsSync.readFileSync(settingsPath, "utf8"));
176
+ const url = saved.hooks.PermissionRequest[0].hooks[0].url;
177
+ assert.match(url, new RegExp(`:${started.port}/permission\\?t=${TOKEN}$`));
178
+
179
+ const res = await post(started, permBody());
180
+ assert.equal(res.status, 200);
181
+ assert.deepEqual(await res.json(), buildDecisionResponse("allow"));
182
+ });
183
+ assert.equal(fsSync.existsSync(settingsPath), false, "stop() 必须删掉临时 settings");
184
+ } finally {
185
+ fsSync.rmSync(dir, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test("端到端:拒绝 → 200 + deny 形状(message 原样回传给模型)", async () => {
190
+ await withBridge({ decide: async () => ({ decision: "deny", message: "用户拒绝" }) }, async ({ started }) => {
191
+ const res = await post(started, permBody());
192
+ assert.equal(res.status, 200);
193
+ const body = await res.json();
194
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
195
+ assert.equal(body.hookSpecificOutput.decision.message, "用户拒绝");
196
+ });
197
+ });
198
+
199
+ test("端到端:错误 token → 403(且不返回任何决策体)", async () => {
200
+ await withBridge({ decide: async () => ({ decision: "allow" }) }, async ({ started }) => {
201
+ const res = await post(started, permBody(), { token: "wrong" });
202
+ assert.equal(res.status, 403);
203
+ const body = await res.json();
204
+ assert.equal(body.error, "forbidden");
205
+ assert.equal(body.hookSpecificOutput, undefined, "绝不能给出 allow 形状");
206
+ });
207
+ });
208
+
209
+ test("端到端:未知事件名 → 400(无决策 → 上游 fail-closed)", async () => {
210
+ await withBridge({}, async ({ started }) => {
211
+ const res = await post(started, { ...permBody(), hook_event_name: "SessionStart" });
212
+ assert.equal(res.status, 400);
213
+ assert.equal((await res.json()).error, "unknown-shape");
214
+ });
215
+ });
216
+
217
+ test("端到端:坏 JSON → 400;超体积 → 413;错误路径 → 404;GET /health → 200", async () => {
218
+ await withBridge({ maxBodyBytes: 200 }, async ({ started }) => {
219
+ assert.equal((await post(started, "{oops")).status, 400);
220
+
221
+ const big = await post(started, permBody("Bash", { command: "x".repeat(1000) }));
222
+ assert.equal(big.status, 413);
223
+
224
+ const nf = await fetch(`${started.url}/nope?t=${TOKEN}`, { method: "POST", body: "{}" });
225
+ assert.equal(nf.status, 404);
226
+
227
+ const health = await fetch(`${started.url}/health`);
228
+ assert.equal(health.status, 200);
229
+ assert.equal((await health.json()).ok, true);
230
+ });
231
+ });
232
+
233
+ test("端到端:决策处理器抛错 → 拒绝(不是 500,也不是放行)", async () => {
234
+ await withBridge(
235
+ {
236
+ decide: () => {
237
+ throw new Error("手机通道断了");
238
+ }
239
+ },
240
+ async ({ started }) => {
241
+ const res = await post(started, permBody());
242
+ assert.equal(res.status, 200);
243
+ const body = await res.json();
244
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
245
+ assert.match(body.hookSpecificOutput.decision.message, /手机通道断了/);
246
+ }
247
+ );
248
+ });
249
+
250
+ test("端到端:决策返回 cancelled → 拒绝", async () => {
251
+ await withBridge({ decide: async () => ({ cancelled: true }) }, async ({ started }) => {
252
+ const body = await (await post(started, permBody())).json();
253
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
254
+ });
255
+ });
256
+
257
+ test("端到端:TTL 到期无决策 → **拒绝**(fail-closed,默认文案,不含命令原文)", async () => {
258
+ // 用假计时器精确触发"超时",不真等 90 秒
259
+ const queue = [];
260
+ const timers = {
261
+ setTimeout: (fn) => {
262
+ queue.push(fn);
263
+ return queue.length;
264
+ },
265
+ clearTimeout: () => {}
266
+ };
267
+ let resolveHang;
268
+ const hang = new Promise((r) => {
269
+ resolveHang = r;
270
+ });
271
+ await withBridge(
272
+ {
273
+ timers,
274
+ ttlMs: 90_000,
275
+ decide: () => hang // 永远不返回,模拟"手机没人应答"
276
+ },
277
+ async ({ started }) => {
278
+ const pending = post(started, permBody("Bash", { command: "rm -rf /important" }));
279
+ // 等桥接把请求登记进 pending,再手动触发 TTL
280
+ for (let i = 0; i < 50 && started.pendingCount() === 0; i += 1) await new Promise((r) => setTimeout(r, 5));
281
+ assert.equal(started.pendingCount(), 1);
282
+ assert.equal(queue.length, 1, "TTL 计时器应已挂上");
283
+ queue[0](); // 到期
284
+ const res = await pending;
285
+ const body = await res.json();
286
+ assert.equal(body.hookSpecificOutput.decision.behavior, "deny");
287
+ assert.equal(body.hookSpecificOutput.decision.message, DEFAULT_DENY_MESSAGE);
288
+ assert.doesNotMatch(body.hookSpecificOutput.decision.message, /rm -rf/, "拒绝文案不得回显命令");
289
+ resolveHang({ decision: "allow" }); // 释放挂起的 promise,避免测试退出时悬挂
290
+ }
291
+ );
292
+ });
293
+
294
+ test("端到端:并发上限 → 429(不排队,避免内存/时延被拖长)", async () => {
295
+ let release;
296
+ const hang = new Promise((r) => {
297
+ release = r;
298
+ });
299
+ await withBridge({ maxPending: 1, decide: () => hang }, async ({ started }) => {
300
+ const first = post(started, permBody());
301
+ for (let i = 0; i < 50 && started.pendingCount() === 0; i += 1) await new Promise((r) => setTimeout(r, 5));
302
+ assert.equal(started.pendingCount(), 1);
303
+
304
+ const second = await post(started, permBody());
305
+ assert.equal(second.status, 429);
306
+ assert.equal((await second.json()).error, "too-many-pending");
307
+
308
+ release({ decision: "deny", message: "放行第一笔后收工" });
309
+ assert.equal((await first).status, 200);
310
+ });
311
+ });
312
+
313
+ test("隐私:日志只记元数据,**不含命令原文/路径**", async () => {
314
+ await withBridge({ decide: async () => ({ decision: "deny", message: "no" }) }, async ({ started, logs }) => {
315
+ const secretCommand = "curl -H 'Authorization: Bearer sk-super-secret' http://example.com";
316
+ const res = await post(started, permBody("Bash", { command: secretCommand }));
317
+ assert.equal(res.status, 200);
318
+
319
+ const dump = JSON.stringify(logs);
320
+ assert.ok(logs.some((e) => e.event === "permission.request"), "应记录请求事件");
321
+ assert.ok(logs.some((e) => e.event === "permission.decision"), "应记录决策事件");
322
+ assert.doesNotMatch(dump, /sk-super-secret/, "日志绝不能带命令原文");
323
+ assert.doesNotMatch(dump, /Authorization/, "日志绝不能带命令原文");
324
+ // 元数据字段要在
325
+ const req = logs.find((e) => e.event === "permission.request");
326
+ assert.equal(req.toolName, "Bash");
327
+ assert.equal(req.kind, "execute");
328
+ assert.equal(typeof req.detailTruncated, "boolean");
329
+ });
330
+ });
331
+
332
+ test("生命周期:重复 start 抛错;stop 幂等;stop 后端口不再服务", async () => {
333
+ const bridge = createPermissionBridge({ token: TOKEN, decide: async () => ({ decision: "allow" }) });
334
+ const started = await bridge.start();
335
+ await assert.rejects(() => bridge.start(), /已经启动/);
336
+ const url = started.url;
337
+ await bridge.stop();
338
+ await bridge.stop(); // 幂等
339
+ await assert.rejects(() => fetch(`${url}/health`), /fetch failed|ECONNREFUSED/);
340
+ });
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 子进程治理契约测试。
4
+ *
5
+ * 由来(第 1 周实测,报告 §4.5):经 `cli-wrapper.cjs` 启动时,SIGTERM 只杀 wrapper,
6
+ * **原生 claude 会孤儿化(PPID=1 继续跑)**。手机上点"停止"却还在执行工具,是必须堵住的坑。
7
+ *
8
+ * 这里用**假 spawn / 假 kill / 假 execFile**验证三条路径,**不真的起进程、不真的杀进程**:
9
+ * ① posix「杀进程组 → 3s → SIGKILL」的升级链;
10
+ * ② 组信号 ESRCH 时退回单进程(未 detached / 已是孤儿);
11
+ * ③ Windows 的 `taskkill /T /F` 分支只"请求",结果**明确标 verified:false**
12
+ * (真机未验证,绝不向用户宣称"已确认杀死")。
13
+ *
14
+ * 用法: node --test clients/dsh-remote/test/agent-proc.test.mjs
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import test from "node:test";
18
+
19
+ import {
20
+ DEFAULT_GRACE_MS,
21
+ isProcessAlive,
22
+ spawnAgentProcess,
23
+ terminateProcessTree
24
+ } from "../agent/proc.mjs";
25
+
26
+ /** 假 spawn:记录调用参数,返回一个带 pid 的假 child。 */
27
+ function fakeSpawn({ pid = 4242 } = {}) {
28
+ const calls = [];
29
+ const fn = (command, args, opts) => {
30
+ calls.push({ command, args, opts });
31
+ return { pid };
32
+ };
33
+ return { fn, calls };
34
+ }
35
+
36
+ test("spawn:参数数组 + 不走 shell + posix 建独立进程组(-pid 才能杀整组)", () => {
37
+ const { fn, calls } = fakeSpawn();
38
+ const r = spawnAgentProcess({ command: "/fake/claude", args: ["-p", "hello world"], cwd: "/tmp/p" }, { spawn: fn, platform: "linux" });
39
+ assert.equal(r.pid, 4242);
40
+ assert.equal(r.group, true);
41
+ assert.equal(calls.length, 1);
42
+ assert.equal(calls[0].command, "/fake/claude");
43
+ assert.deepEqual(calls[0].args, ["-p", "hello world"], "绝不能拼成一个字符串");
44
+ assert.equal(calls[0].opts.shell, false, "绝不经过 shell(用户输入不走命令行解析)");
45
+ assert.equal(calls[0].opts.detached, true);
46
+ assert.equal(calls[0].opts.windowsHide, true);
47
+ assert.equal(calls[0].opts.cwd, "/tmp/p");
48
+ });
49
+
50
+ test("spawn:Windows 不 detached(语义不同,杀树走 taskkill)", () => {
51
+ const { fn } = fakeSpawn();
52
+ const r = spawnAgentProcess({ command: "C:\\claude.exe", args: [] }, { spawn: fn, platform: "win32" });
53
+ assert.equal(r.detached, false);
54
+ assert.equal(r.group, false);
55
+ });
56
+
57
+ test("spawn:缺 command 直接抛(调用方写错要立即暴露,不能起个空进程)", () => {
58
+ assert.throws(() => spawnAgentProcess({}, { spawn: () => ({}) }), /缺少 command/);
59
+ });
60
+
61
+ test("isProcessAlive:ESRCH=死、EPERM=活(存在但没权限)", () => {
62
+ const dead = (pid, sig) => {
63
+ if (sig === 0) throw Object.assign(new Error("no such process"), { code: "ESRCH" });
64
+ };
65
+ const denied = (pid, sig) => {
66
+ if (sig === 0) throw Object.assign(new Error("not permitted"), { code: "EPERM" });
67
+ };
68
+ assert.equal(isProcessAlive(123, { kill: dead }), false);
69
+ assert.equal(isProcessAlive(123, { kill: denied }), true);
70
+ assert.equal(isProcessAlive(0, { kill: dead }), false);
71
+ assert.equal(isProcessAlive("x", { kill: dead }), false);
72
+ });
73
+
74
+ test("posix:SIGTERM 后已死 → 不升级,method=group-term", async () => {
75
+ let alive = true;
76
+ const calls = [];
77
+ const kill = (pid, sig) => {
78
+ calls.push([pid, sig]);
79
+ if (sig === "SIGTERM") {
80
+ alive = false; // 模拟收到 TERM 就退出
81
+ return;
82
+ }
83
+ if (sig === 0 && !alive) throw Object.assign(new Error("gone"), { code: "ESRCH" });
84
+ };
85
+ const r = await terminateProcessTree(777, { platform: "linux", kill, graceMs: 0 });
86
+ assert.equal(r.ok, true);
87
+ assert.equal(r.method, "group-term");
88
+ assert.equal(r.escalated, false);
89
+ assert.equal(r.verified, true);
90
+ assert.deepEqual(calls[0], [-777, "SIGTERM"], "必须先杀**进程组**");
91
+ });
92
+
93
+ test("posix:SIGTERM 后仍活 → 优雅期后 SIGKILL 整组(这是「不能留下孤儿」的兜底)", async () => {
94
+ let alive = true;
95
+ const calls = [];
96
+ const kill = (pid, sig) => {
97
+ calls.push([pid, sig]);
98
+ if (sig === "SIGTERM" || sig === "SIGKILL") return; // 模拟"不理 TERM",但 KILL 前仍算活
99
+ if (sig === 0 && !alive) throw Object.assign(new Error("gone"), { code: "ESRCH" });
100
+ };
101
+ const r = await terminateProcessTree(778, { platform: "linux", kill, graceMs: DEFAULT_GRACE_MS });
102
+ assert.equal(r.ok, true);
103
+ assert.equal(r.escalated, true);
104
+ assert.match(r.method, /kill9$/);
105
+ const sigs = calls.map(([, s]) => s);
106
+ assert.deepEqual(sigs.slice(0, 2), ["SIGTERM", 0], "先 TERM,再探活");
107
+ assert.ok(calls.some(([pid, sig]) => pid === -778 && sig === "SIGKILL"), "必须对**整组**发 KILL");
108
+ assert.ok(calls.some(([pid, sig]) => pid === 778 && sig === "SIGKILL"), "单进程也补一刀(组可能已散)");
109
+ });
110
+
111
+ test("posix:组信号 ESRCH(未 detached / 已是孤儿)→ 退回单进程 SIGTERM", async () => {
112
+ const calls = [];
113
+ const kill = (pid, sig) => {
114
+ calls.push([pid, sig]);
115
+ if (pid < 0) throw Object.assign(new Error("no such group"), { code: "ESRCH" });
116
+ if (sig === "SIGTERM") return;
117
+ if (sig === 0) throw Object.assign(new Error("gone"), { code: "ESRCH" });
118
+ };
119
+ const r = await terminateProcessTree(779, { platform: "linux", kill, graceMs: 0 });
120
+ assert.equal(r.ok, true);
121
+ assert.equal(r.method, "pid-term");
122
+ assert.deepEqual(calls.slice(0, 2), [[-779, "SIGTERM"], [779, "SIGTERM"]]);
123
+ });
124
+
125
+ test("posix:进程本来就不在 → already-gone(停止要幂等)", async () => {
126
+ const kill = (pid, sig) => {
127
+ throw Object.assign(new Error("gone"), { code: "ESRCH" });
128
+ };
129
+ const r = await terminateProcessTree(780, { platform: "linux", kill, graceMs: 0 });
130
+ assert.equal(r.ok, true);
131
+ assert.equal(r.method, "already-gone");
132
+ });
133
+
134
+ test("posix:EPERM(不是我们的进程)→ ok:false,不假装成功", async () => {
135
+ const kill = (pid, sig) => {
136
+ if (pid < 0) throw Object.assign(new Error("perm"), { code: "EPERM" });
137
+ };
138
+ const r = await terminateProcessTree(781, { platform: "linux", kill, graceMs: 0 });
139
+ assert.equal(r.ok, false);
140
+ assert.equal(r.verified, false);
141
+ assert.equal(r.error, "eperm");
142
+ });
143
+
144
+ test("win32:taskkill /T /F 成功 → verified:false(真机未验证,不得宣称已确认)", async () => {
145
+ const calls = [];
146
+ const execFile = (file, args, opts, cb) => {
147
+ calls.push({ file, args, opts });
148
+ cb(null);
149
+ };
150
+ const r = await terminateProcessTree(782, { platform: "win32", execFile, kill: () => {} });
151
+ assert.equal(r.ok, true);
152
+ assert.equal(r.method, "taskkill-tree");
153
+ assert.equal(r.verified, false);
154
+ assert.equal(calls[0].file, "taskkill");
155
+ assert.deepEqual(calls[0].args, ["/PID", "782", "/T", "/F"]);
156
+ });
157
+
158
+ test("win32:taskkill 失败 → 退回 kill,ok:true 但标 taskkill-failed", async () => {
159
+ const killed = [];
160
+ const execFile = (file, args, opts, cb) => cb(new Error("taskkill not found"));
161
+ const r = await terminateProcessTree(783, {
162
+ platform: "win32",
163
+ execFile,
164
+ kill: (pid, sig) => killed.push([pid, sig])
165
+ });
166
+ assert.equal(r.ok, true);
167
+ assert.equal(r.method, "taskkill-fallback-kill");
168
+ assert.equal(r.error, "taskkill-failed");
169
+ assert.deepEqual(killed, [[783, "SIGKILL"]]);
170
+ });
171
+
172
+ test("非法 pid → noop(调用方传了垃圾不该炸)", async () => {
173
+ const r = await terminateProcessTree(0, { platform: "linux" });
174
+ assert.deepEqual(r, { ok: false, method: "noop", escalated: false, verified: false, error: "invalid-pid" });
175
+ });