dsh-remote-web 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.
- package/lib/client.js +350 -8
- package/lib/index.js +26 -0
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/runtime/dsh-setup.mjs +5 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* **真实 Claude Code 端到端联调**(M2 第 3 周 A 项)——默认跳过,只有显式开启才跑。
|
|
4
|
+
*
|
|
5
|
+
* 证明的是这条链:`Claude Code 钩子 → 本机 HTTP(PermissionBridge)→ decide 回调 → 回写 → 命令真跑/不跑`。
|
|
6
|
+
*
|
|
7
|
+
* ## 用法
|
|
8
|
+
*
|
|
9
|
+
* ```bash
|
|
10
|
+
* # 1) 必须显式开启(避免 CI 误触 / 误耗额度)
|
|
11
|
+
* export DSH_AGENT_E2E=1
|
|
12
|
+
*
|
|
13
|
+
* # 2) 指向一个 ≥ 2.1.318 的 CLI(本机旧版 2.1.195 不触发 PermissionRequest 钩子,见第 1 周报告 §2.2)
|
|
14
|
+
* export DSH_AGENT_CLAUDE_BIN=/path/to/@anthropic-ai/claude-code/bin/claude.exe
|
|
15
|
+
*
|
|
16
|
+
* node clients/dsh-remote/agent/agent-e2e-real.mjs
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* 退出码:`0` 全部通过;`1` 有断言失败;`2` 环境不具备(未开启 / CLI 版本不够 / 起不来),**未执行断言**。
|
|
20
|
+
*
|
|
21
|
+
* ## 三个用例(各 1 次真实 `claude -p`,总共 3 次;脚本硬上限 6 次)
|
|
22
|
+
*
|
|
23
|
+
* | 用例 | decide 返回 | 断言 |
|
|
24
|
+
* |---|---|---|
|
|
25
|
+
* | allow | `{decision:"allow"}` | 钩子被调用(bridge 日志有 `permission.request`)+ 目标文件真的被写入 |
|
|
26
|
+
* | deny | `{decision:"deny"}` | 钩子被调用 + 目标文件**不存在** |
|
|
27
|
+
* | timeout | 首个请求永挂(模拟手机没响应) | TTL 到期 → 桥接日志出现 `outcome:"expired"` → 拒绝 + 目标文件不存在 + 进程正常结束 |
|
|
28
|
+
*
|
|
29
|
+
* ## 为什么必须用项目级 `permissions.ask`
|
|
30
|
+
*
|
|
31
|
+
* 第 1 周报告 §1.2 的方法学:**用户级 `~/.claude/settings.json` 的宽 allow 规则会让钩子根本不触发**,
|
|
32
|
+
* 于是第一轮测试得到"钩子不生效"的假结论。这里在临时项目里写 `permissions.ask` 强制升权,
|
|
33
|
+
* 并把探测用的 `homeDir` 指到空目录(隔离用户级 `PermissionRequest` 钩子 → 避免"让位"模式)。
|
|
34
|
+
*
|
|
35
|
+
* ## 隐私
|
|
36
|
+
*
|
|
37
|
+
* 不打印账号、token、网关配置;不打印 `tool_input`(命令原文);事件落盘走 `agent/events.mjs`,
|
|
38
|
+
* 该模块本身只把"输入字节数"写进 `tool.call`。
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fsSync from "node:fs";
|
|
42
|
+
import os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import readline from "node:readline";
|
|
45
|
+
|
|
46
|
+
import { createAgentRuntime } from "./index.mjs";
|
|
47
|
+
import { createEventMapper, createJsonlSink } from "./events.mjs";
|
|
48
|
+
import { spawnAgentProcess, terminateProcessTree } from "./proc.mjs";
|
|
49
|
+
|
|
50
|
+
/** 硬上限:本脚本最多允许几次真实 `claude -p`。 */
|
|
51
|
+
const MAX_CLAUDE_CALLS = 6;
|
|
52
|
+
/** 单个用例的进程上限(真实模型 + 工具执行)。 */
|
|
53
|
+
const CASE_TIMEOUT_MS = 180_000;
|
|
54
|
+
/** 超时用例:TTL 设短一点,断言"进程在预期时间内结束"(模型耗时之外还要加 TTL)。 */
|
|
55
|
+
const TIMEOUT_CASE_TTL_MS = 6_000;
|
|
56
|
+
const TIMEOUT_CASE_MAX_MS = 150_000;
|
|
57
|
+
/** 目标文件内容标记(allow 用例检查它)。 */
|
|
58
|
+
const MARKER = "agent-e2e-ok";
|
|
59
|
+
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
let claudeCalls = 0;
|
|
62
|
+
const results = [];
|
|
63
|
+
|
|
64
|
+
function out(msg = "") {
|
|
65
|
+
// 联调脚本是人工工具,允许直写 stdout(不是 bridge 运行期日志)
|
|
66
|
+
process.stdout.write(msg + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function elapsed() {
|
|
70
|
+
return `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 环境不具备 → 退出码 2(区别于"断言失败")。 */
|
|
74
|
+
function envSkip(reason) {
|
|
75
|
+
out(`[E2E][SKIP] ${reason}`);
|
|
76
|
+
out(`[E2E] 用时 ${elapsed()};未执行任何 claude -p 调用。`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 断言失败但要收尾。 */
|
|
81
|
+
function fail(reason, extra = "") {
|
|
82
|
+
out(`[E2E][FAIL] ${reason}${extra ? `\n${extra}` : ""}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 等子进程退出(带超时)。 */
|
|
86
|
+
function waitExit(child, timeoutMs) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
let done = false;
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
if (done) return;
|
|
91
|
+
done = true;
|
|
92
|
+
resolve({ timedOut: true, code: null, signal: null });
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
child.once("exit", (code, signal) => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
done = true;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve({ timedOut: false, code, signal });
|
|
99
|
+
});
|
|
100
|
+
child.once("error", (err) => {
|
|
101
|
+
if (done) return;
|
|
102
|
+
done = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve({ timedOut: false, code: null, signal: null, error: err });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 跑一个用例:起 runtime(= PermissionBridge)→ 真起 claude → 喂事件 → 断言。
|
|
111
|
+
*
|
|
112
|
+
* @returns {Promise<{name:string, ok:boolean, detail:object}>}
|
|
113
|
+
*/
|
|
114
|
+
async function runCase({
|
|
115
|
+
name,
|
|
116
|
+
scratch,
|
|
117
|
+
binPath,
|
|
118
|
+
decideFactory,
|
|
119
|
+
ttlMs,
|
|
120
|
+
expectFile,
|
|
121
|
+
expectExpired
|
|
122
|
+
}) {
|
|
123
|
+
const caseDir = path.join(scratch, `case-${name}`);
|
|
124
|
+
const projectDir = path.join(caseDir, "project");
|
|
125
|
+
const homeDir = path.join(caseDir, "home"); // 空目录:隔离用户级钩子,避免"让位"模式
|
|
126
|
+
const settingsDir = path.join(caseDir, "bridge-settings");
|
|
127
|
+
const targetFile = path.join(projectDir, `out-${name}.txt`);
|
|
128
|
+
fsSync.mkdirSync(path.join(projectDir, ".claude"), { recursive: true });
|
|
129
|
+
fsSync.mkdirSync(homeDir, { recursive: true });
|
|
130
|
+
fsSync.mkdirSync(settingsDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
// 关键:项目级 ask 强制升权(用户级 allow 规则否则会把审批吞掉,见第 1 周报告 §1.2)
|
|
133
|
+
fsSync.writeFileSync(
|
|
134
|
+
path.join(projectDir, ".claude", "settings.json"),
|
|
135
|
+
JSON.stringify({ permissions: { ask: ["Bash", "Write", "Edit"] } }, null, 2) + "\n"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const logs = [];
|
|
139
|
+
const decideCalls = [];
|
|
140
|
+
const decide = decideFactory({ decideCalls });
|
|
141
|
+
|
|
142
|
+
const runtime = createAgentRuntime({
|
|
143
|
+
enabled: true,
|
|
144
|
+
decide,
|
|
145
|
+
log: (e) => logs.push(e),
|
|
146
|
+
env: {
|
|
147
|
+
...process.env,
|
|
148
|
+
DSH_AGENT_ENABLED: "1",
|
|
149
|
+
DSH_AGENT_CLAUDE_BIN: binPath,
|
|
150
|
+
DSH_AGENT_SETTINGS_DIR: settingsDir,
|
|
151
|
+
DSH_AGENT_PERMISSION_TTL_MS: String(ttlMs),
|
|
152
|
+
DSH_AGENT_HOOK_TIMEOUT_SEC: "120"
|
|
153
|
+
},
|
|
154
|
+
deps: { cwd: projectDir, homeDir }
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const detail = {
|
|
158
|
+
runtimeStatus: "",
|
|
159
|
+
bridgePort: 0,
|
|
160
|
+
permissionRequests: 0,
|
|
161
|
+
decideCalls: 0,
|
|
162
|
+
decisions: [],
|
|
163
|
+
expiredLogged: false,
|
|
164
|
+
fileCreated: false,
|
|
165
|
+
fileContentOk: false,
|
|
166
|
+
claudeExit: "",
|
|
167
|
+
events: {},
|
|
168
|
+
eventFile: "",
|
|
169
|
+
elapsedMs: 0
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let child = null;
|
|
173
|
+
let bridgeStopped = false;
|
|
174
|
+
const caseStart = Date.now();
|
|
175
|
+
try {
|
|
176
|
+
const r = await runtime.start();
|
|
177
|
+
detail.runtimeStatus = r.status;
|
|
178
|
+
if (!r.ok) {
|
|
179
|
+
return { name, ok: false, detail: { ...detail, reason: `runtime 不可用: ${r.status} ${r.reason || ""}` } };
|
|
180
|
+
}
|
|
181
|
+
detail.bridgePort = r.port;
|
|
182
|
+
|
|
183
|
+
// 真实调用计数(硬上限 6)
|
|
184
|
+
if (claudeCalls >= MAX_CLAUDE_CALLS) {
|
|
185
|
+
return { name, ok: false, detail: { ...detail, reason: `已达 claude -p 调用上限 ${MAX_CLAUDE_CALLS},停止` } };
|
|
186
|
+
}
|
|
187
|
+
claudeCalls += 1;
|
|
188
|
+
|
|
189
|
+
const prompt = [
|
|
190
|
+
"用 Bash 工具执行下面这一条命令,只执行这一条,不要询问、不要做别的事,执行后简要报告结果:",
|
|
191
|
+
`printf '${MARKER}' > ${targetFile}`
|
|
192
|
+
].join("\n");
|
|
193
|
+
|
|
194
|
+
const spawnArgs = [
|
|
195
|
+
...(r.claude.argsPrefix || []),
|
|
196
|
+
...r.launch.args, // ["--permission-mode","default","--settings",<tmp settings>]
|
|
197
|
+
"-p",
|
|
198
|
+
prompt,
|
|
199
|
+
"--output-format",
|
|
200
|
+
"stream-json",
|
|
201
|
+
"--verbose"
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
out(`[E2E] ${name}: 起 claude(binKind=${r.claude.kind}, port=${r.port}, ttl=${ttlMs}ms)`);
|
|
205
|
+
const proc = spawnAgentProcess(
|
|
206
|
+
{ command: r.claude.command, args: spawnArgs, cwd: projectDir, env: process.env },
|
|
207
|
+
{ platform: process.platform }
|
|
208
|
+
);
|
|
209
|
+
child = proc.child;
|
|
210
|
+
|
|
211
|
+
// 事件映射(B 项在真实输出上的验证):落 jsonl 便于事后回看
|
|
212
|
+
const eventFile = path.join(caseDir, `events-${name}.jsonl`);
|
|
213
|
+
detail.eventFile = eventFile;
|
|
214
|
+
const sink = createJsonlSink({ filePath: eventFile });
|
|
215
|
+
const mapper = createEventMapper({ sink });
|
|
216
|
+
|
|
217
|
+
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
218
|
+
// 先挂 close 的 promise:若在 waitExit 之后才挂,close 可能已经发过 → 永远等不到
|
|
219
|
+
const rlClosed = new Promise((res) => rl.once("close", res));
|
|
220
|
+
const dumpRaw = String(process.env.DSH_AGENT_E2E_DUMP_RAW || "") === "1";
|
|
221
|
+
const rawFile = path.join(caseDir, `raw-${name}.jsonl`);
|
|
222
|
+
rl.on("line", (line) => {
|
|
223
|
+
if (dumpRaw) {
|
|
224
|
+
try {
|
|
225
|
+
fsSync.appendFileSync(rawFile, line + "\n", { encoding: "utf8" });
|
|
226
|
+
} catch {
|
|
227
|
+
/* 诊断用,失败无所谓 */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
mapper.writeLine(line);
|
|
232
|
+
} catch {
|
|
233
|
+
/* 映射器本身不抛,这里只是双保险 */
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let stderr = "";
|
|
238
|
+
child.stderr.on("data", (buf) => {
|
|
239
|
+
if (stderr.length < 4096) stderr += String(buf);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const timeoutMs = expectExpired ? TIMEOUT_CASE_MAX_MS : CASE_TIMEOUT_MS;
|
|
243
|
+
const exit = await waitExit(child, timeoutMs);
|
|
244
|
+
detail.claudeExit = exit.timedOut ? "TIMEOUT" : `code=${exit.code} signal=${exit.signal || "-"}`;
|
|
245
|
+
if (exit.timedOut) {
|
|
246
|
+
await terminateProcessTree(child.pid, { platform: process.platform, graceMs: 1500 }).catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
await rlClosed;
|
|
249
|
+
|
|
250
|
+
// stop() 会结算可能还挂着的审批(一律 deny)——超时用例必须靠它/桥接 TTL
|
|
251
|
+
await runtime.stop();
|
|
252
|
+
bridgeStopped = true;
|
|
253
|
+
|
|
254
|
+
// ── 汇总 ────────────────────────────────────────────────────────────────
|
|
255
|
+
const stats = mapper.stats();
|
|
256
|
+
detail.events = stats.byType;
|
|
257
|
+
detail.eventReasons = stats.byReason;
|
|
258
|
+
detail.eventStats = {
|
|
259
|
+
lines: stats.lines,
|
|
260
|
+
unknown: stats.unknown,
|
|
261
|
+
badJson: stats.badJson,
|
|
262
|
+
tooLong: stats.tooLong,
|
|
263
|
+
unknownReasons: stats.unknownReasons || {}
|
|
264
|
+
};
|
|
265
|
+
detail.permissionRequests = logs.filter((e) => e && e.event === "permission.request").length;
|
|
266
|
+
detail.decideCalls = decideCalls.length;
|
|
267
|
+
detail.decisions = decideCalls.map((d) => d.decision);
|
|
268
|
+
detail.expiredLogged = logs.some((e) => e && e.event === "permission.decision" && e.outcome === "expired");
|
|
269
|
+
detail.fileCreated = fsSync.existsSync(targetFile);
|
|
270
|
+
detail.fileContentOk = detail.fileCreated && fsSync.readFileSync(targetFile, "utf8").includes(MARKER);
|
|
271
|
+
detail.elapsedMs = Date.now() - caseStart;
|
|
272
|
+
|
|
273
|
+
// ── 断言 ────────────────────────────────────────────────────────────────
|
|
274
|
+
const problems = [];
|
|
275
|
+
if (detail.permissionRequests < 1) problems.push("钩子没有被调用(permission.request 日志为 0)");
|
|
276
|
+
if (detail.decideCalls < 1) problems.push("decide 回调没有被调用");
|
|
277
|
+
if (detail.events["turn.end"] !== 1) problems.push(`turn.end 事件数异常: ${detail.events["turn.end"] || 0}`);
|
|
278
|
+
if (exit.timedOut) problems.push(`claude 进程超时(>${timeoutMs}ms)未结束`);
|
|
279
|
+
|
|
280
|
+
if (expectFile) {
|
|
281
|
+
if (!detail.fileCreated) problems.push("目标文件未创建(allow 未生效?)");
|
|
282
|
+
else if (!detail.fileContentOk) problems.push(`目标文件内容不含标记 ${MARKER}`);
|
|
283
|
+
} else if (detail.fileCreated) {
|
|
284
|
+
problems.push("目标文件被创建了(deny/超时未生效!)");
|
|
285
|
+
}
|
|
286
|
+
if (expectExpired && !detail.expiredLogged) problems.push("没有观察到 TTL 到期(outcome:expired)");
|
|
287
|
+
if (expectExpired && detail.elapsedMs > TIMEOUT_CASE_MAX_MS) {
|
|
288
|
+
problems.push(`超时用例耗时 ${detail.elapsedMs}ms,超过预期上限 ${TIMEOUT_CASE_MAX_MS}ms`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (problems.length) {
|
|
292
|
+
const tail = stderr.trim().slice(-600);
|
|
293
|
+
return { name, ok: false, detail: { ...detail, reason: problems.join(";"), stderrTail: tail } };
|
|
294
|
+
}
|
|
295
|
+
return { name, ok: true, detail };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return { name, ok: false, detail: { ...detail, reason: `异常: ${String(err?.message || err).slice(0, 300)}` } };
|
|
298
|
+
} finally {
|
|
299
|
+
if (!bridgeStopped) {
|
|
300
|
+
try {
|
|
301
|
+
await runtime.stop();
|
|
302
|
+
} catch {
|
|
303
|
+
/* ignore */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (child) {
|
|
307
|
+
try {
|
|
308
|
+
await terminateProcessTree(child.pid, { platform: process.platform, graceMs: 1200 });
|
|
309
|
+
} catch {
|
|
310
|
+
/* ignore */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function main() {
|
|
317
|
+
out("=== agent-e2e-real:真实 Claude Code 审批闭环联调 ===");
|
|
318
|
+
|
|
319
|
+
if (String(process.env.DSH_AGENT_E2E || "") !== "1") {
|
|
320
|
+
out("[E2E][SKIP] 未设置 DSH_AGENT_E2E=1 —— 默认跳过(避免 CI 误触 / 误耗额度)。");
|
|
321
|
+
out(" 开启方式:DSH_AGENT_E2E=1 DSH_AGENT_CLAUDE_BIN=/path/to/claude node clients/dsh-remote/agent/agent-e2e-real.mjs");
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const binPath = String(process.env.DSH_AGENT_CLAUDE_BIN || "").trim();
|
|
326
|
+
if (!binPath) {
|
|
327
|
+
envSkip("未设置 DSH_AGENT_CLAUDE_BIN。本机全局 CLI 若低于 2.1.318 不会触发审批钩子(第 1 周报告 §2.2),请显式指向一个 ≥2.1.318 的 CLI。");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 先探测一次(版本门禁 / 既有钩子 / CLI 可用性),拿不到明确原因就跳过,不硬跑
|
|
331
|
+
const probeRt = createAgentRuntime({
|
|
332
|
+
enabled: true,
|
|
333
|
+
env: { ...process.env, DSH_AGENT_ENABLED: "1", DSH_AGENT_CLAUDE_BIN: binPath },
|
|
334
|
+
deps: { cwd: process.cwd(), homeDir: os.tmpdir() }
|
|
335
|
+
});
|
|
336
|
+
const probe = probeRt.probe({ force: true });
|
|
337
|
+
out(`[E2E] CLI: ${probe.claude?.path || "(未找到)"} version=${probe.version?.text || "?"} min=${probe.gate?.required || "?"}`);
|
|
338
|
+
if (!probe.ok) {
|
|
339
|
+
envSkip(`能力探测未通过:status=${probe.status};${probe.reason || ""}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const scratch = fsSync.mkdtempSync(path.join(os.tmpdir(), "dsh-agent-e2e-"));
|
|
343
|
+
out(`[E2E] 临时工作区: ${scratch}`);
|
|
344
|
+
|
|
345
|
+
const requested = String(process.env.DSH_AGENT_E2E_CASES || "allow,deny,timeout")
|
|
346
|
+
.split(",")
|
|
347
|
+
.map((x) => x.trim())
|
|
348
|
+
.filter(Boolean);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
if (requested.includes("allow")) {
|
|
352
|
+
// ① allow:钩子被调用 + allow → 命令真的执行
|
|
353
|
+
results.push(await runCase({
|
|
354
|
+
name: "allow",
|
|
355
|
+
scratch,
|
|
356
|
+
binPath,
|
|
357
|
+
ttlMs: 90_000,
|
|
358
|
+
expectFile: true,
|
|
359
|
+
expectExpired: false,
|
|
360
|
+
decideFactory: ({ decideCalls }) => async (req) => {
|
|
361
|
+
decideCalls.push({ decision: "allow", toolName: req.toolName });
|
|
362
|
+
return { decision: "allow" };
|
|
363
|
+
}
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
}
|
|
367
|
+
if (requested.includes("deny")) {
|
|
368
|
+
// ② deny:钩子被调用 + deny → 命令不执行
|
|
369
|
+
results.push(await runCase({
|
|
370
|
+
name: "deny",
|
|
371
|
+
scratch,
|
|
372
|
+
binPath,
|
|
373
|
+
ttlMs: 90_000,
|
|
374
|
+
expectFile: false,
|
|
375
|
+
expectExpired: false,
|
|
376
|
+
decideFactory: ({ decideCalls }) => async (req) => {
|
|
377
|
+
decideCalls.push({ decision: "deny", toolName: req.toolName });
|
|
378
|
+
return { decision: "deny", message: "agent-e2e:已按拒绝处理" };
|
|
379
|
+
}
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
}
|
|
383
|
+
if (requested.includes("timeout")) {
|
|
384
|
+
// ③ timeout:决定一直不到 → TTL 到期自动 deny(模拟手机没响应)
|
|
385
|
+
results.push(await runCase({
|
|
386
|
+
name: "timeout",
|
|
387
|
+
scratch,
|
|
388
|
+
binPath,
|
|
389
|
+
ttlMs: TIMEOUT_CASE_TTL_MS,
|
|
390
|
+
expectFile: false,
|
|
391
|
+
expectExpired: true,
|
|
392
|
+
decideFactory: ({ decideCalls }) => (req) => {
|
|
393
|
+
decideCalls.push({ decision: "hang", toolName: req.toolName });
|
|
394
|
+
if (decideCalls.length === 1) return new Promise(() => {}); // 第一条:永挂 → 交给桥接 TTL
|
|
395
|
+
return { decision: "deny", message: "agent-e2e:重试不再等待,已拒绝" };
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
} finally {
|
|
400
|
+
if (String(process.env.DSH_AGENT_E2E_KEEP || "") === "1") {
|
|
401
|
+
out(`[E2E] 保留临时工作区(DSH_AGENT_E2E_KEEP=1): ${scratch}`);
|
|
402
|
+
} else {
|
|
403
|
+
try {
|
|
404
|
+
fsSync.rmSync(scratch, { recursive: true, force: true });
|
|
405
|
+
} catch {
|
|
406
|
+
/* ignore */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── 摘要(脱敏:只有元数据) ─────────────────────────────────────────────
|
|
412
|
+
out("");
|
|
413
|
+
out("=== 结果摘要 ===");
|
|
414
|
+
for (const r of results) {
|
|
415
|
+
const d = r.detail || {};
|
|
416
|
+
const ev = d.events || {};
|
|
417
|
+
out(
|
|
418
|
+
`${r.ok ? "✅" : "❌"} ${r.name.padEnd(7)} ` +
|
|
419
|
+
`permissionRequests=${d.permissionRequests ?? 0} decideCalls=${d.decideCalls ?? 0} ` +
|
|
420
|
+
`decisions=[${(d.decisions || []).join(",")}] expired=${d.expiredLogged ? "yes" : "no"} ` +
|
|
421
|
+
`file=${d.fileCreated ? "created" : "absent"} exit=${d.claudeExit || "-"} ` +
|
|
422
|
+
`events={tool.call:${ev["tool.call"] || 0},tool.result:${ev["tool.result"] || 0},turn.end:${ev["turn.end"] || 0}} ` +
|
|
423
|
+
`elapsed=${((d.elapsedMs || 0) / 1000).toFixed(1)}s`
|
|
424
|
+
);
|
|
425
|
+
if (d.eventStats && (d.eventStats.unknown || d.eventStats.badJson || d.eventStats.tooLong)) {
|
|
426
|
+
out(` 未识别行: unknown=${d.eventStats.unknown} badJson=${d.eventStats.badJson} tooLong=${d.eventStats.tooLong}` +
|
|
427
|
+
(Object.keys(d.eventStats.unknownReasons || {}).length
|
|
428
|
+
? ` 原因=${JSON.stringify(d.eventStats.unknownReasons)}`
|
|
429
|
+
: ""));
|
|
430
|
+
}
|
|
431
|
+
if (!r.ok) out(` 原因: ${d.reason}${d.stderrTail ? `\n stderr尾: ${d.stderrTail.split("\n").slice(-4).join(" | ")}` : ""}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const passed = results.filter((r) => r.ok).length;
|
|
435
|
+
out("");
|
|
436
|
+
out(`[E2E] ${passed}/${results.length} 通过;真实 claude -p 调用 ${claudeCalls} 次(上限 ${MAX_CLAUDE_CALLS});总用时 ${elapsed()}`);
|
|
437
|
+
process.exit(passed === results.length && results.length > 0 ? 0 : 1);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
main().catch((err) => {
|
|
441
|
+
out(`[E2E][FAIL] 未捕获异常: ${String(err?.message || err)}`);
|
|
442
|
+
process.exit(1);
|
|
443
|
+
});
|