@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,535 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex 适配骨架契约测试(M2 第 4 周)。
4
+ *
5
+ * ## 夹具来源(全部官方,逐字摘录;不要凭记忆改)
6
+ *
7
+ * | 夹具 | 出处 |
8
+ * |---|---|
9
+ * | `OFFICIAL_SAMPLE`(5 行 JSONL) | <https://learn.chatgpt.com/docs/non-interactive-mode> 的 **Sample JSON stream** 代码块 |
10
+ * | item / 字段形状(command_execution、file_change、mcp_tool_call、web_search、todo_list、error…) | `openai/codex` → `codex-rs/exec/src/exec_events.rs`(权威 Rust 定义)+ `sdk/typescript/src/items.ts`(同款 TS 类型) |
11
+ * | 顶层事件枚举(8 种) | 同上 `ThreadEvent`;官方文档 "Event types include …" |
12
+ * | 能力位(`--json` / `--output-schema` / `--approve-for-me`) | 本机 `codex exec --help`(`codex-cli 0.150.1`)实测 |
13
+ *
14
+ * ## 本机探测
15
+ *
16
+ * 末尾有一条**条件用例**:本机装了 codex 就真跑 `codex --version` + `codex exec --help`
17
+ * (**不调用模型**,不耗额度);没装则 SKIP。结果用于在报告里记录真实版本与能力位。
18
+ *
19
+ * 用法: node --test clients/dsh-remote/test/agent-codex.test.mjs
20
+ */
21
+ import assert from "node:assert/strict";
22
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
23
+ import path from "node:path";
24
+ import test from "node:test";
25
+
26
+ import {
27
+ CODEX_BIN_ENV,
28
+ buildCodexCandidates,
29
+ createCodexDriver,
30
+ createCodexMapper,
31
+ detectCodex,
32
+ mapCodexLine,
33
+ mapCodexObject,
34
+ normalizeErrorMessage
35
+ } from "../agent/codex.mjs";
36
+ import { CODEX_CAPABILITIES, CODEX_ITEM_TYPES, CODEX_TOP_EVENTS } from "../agent/codex.mjs";
37
+ import { byteLength } from "../agent/events.mjs";
38
+
39
+ const NOW = 1_700_000_000_000;
40
+ const at = () => NOW;
41
+ const line = (obj) => JSON.stringify(obj);
42
+
43
+ /**
44
+ * **官方文档样本(逐字摘录)** —— 来源见文件头。
45
+ * 注意第 1 行是官方原样的 thread_id;不要改它(改了就不再是"真实样本")。
46
+ */
47
+ const OFFICIAL_SAMPLE = [
48
+ '{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}',
49
+ '{"type":"turn.started"}',
50
+ '{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}}',
51
+ '{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Repo contains docs, sdk, and examples directories."}}',
52
+ '{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122,"reasoning_output_tokens":0}}'
53
+ ];
54
+
55
+ test("官方事件/条目枚举与我们的常量一致(防止把文档抄漏)", () => {
56
+ assert.deepEqual([...CODEX_TOP_EVENTS], [
57
+ "thread.started", "turn.started", "turn.completed", "turn.failed",
58
+ "item.started", "item.updated", "item.completed", "error"
59
+ ]);
60
+ assert.ok(CODEX_ITEM_TYPES.includes("command_execution"));
61
+ assert.ok(CODEX_ITEM_TYPES.includes("mcp_tool_call"));
62
+ assert.equal(CODEX_CAPABILITIES.externalApprovalHook, false, "exec 没有审批回调开关(本机 help 实测)");
63
+ });
64
+
65
+ test("官方样本第 1 行:thread.started → status(working) + threadId", () => {
66
+ const r = mapCodexLine(OFFICIAL_SAMPLE[0], { now: at });
67
+ assert.equal(r.ok, true);
68
+ assert.equal(r.events[0].type, "status");
69
+ assert.equal(r.events[0].status, "working");
70
+ assert.equal(r.events[0].phase, "thread");
71
+ assert.equal(r.events[0].threadId, "0199a213-81c0-7800-8aa1-bbab2a035a53");
72
+ });
73
+
74
+ test("官方样本第 2 行:turn.started → status(working, phase=turn)", () => {
75
+ const r = mapCodexLine(OFFICIAL_SAMPLE[1], { now: at });
76
+ assert.equal(r.events[0].type, "status");
77
+ assert.equal(r.events[0].phase, "turn");
78
+ });
79
+
80
+ test("官方样本第 3 行:item.started command_execution → tool.call(只带命令字节数,不带原文)", () => {
81
+ const r = mapCodexLine(OFFICIAL_SAMPLE[2], { now: at });
82
+ assert.equal(r.events.length, 1);
83
+ const ev = r.events[0];
84
+ assert.equal(ev.type, "tool.call");
85
+ assert.equal(ev.name, "shell");
86
+ assert.equal(ev.kind, "execute");
87
+ assert.equal(ev.toolCallId, "item_1");
88
+ assert.equal(ev.inputBytes, byteLength("bash -lc ls"));
89
+ assert.equal(JSON.stringify(ev).includes("bash -lc ls"), false, "命令原文不能进事件");
90
+ });
91
+
92
+ test("官方样本第 4 行:item.completed agent_message → message.delta(final=true)", () => {
93
+ const r = mapCodexLine(OFFICIAL_SAMPLE[3], { now: at });
94
+ assert.equal(r.events.length, 1);
95
+ assert.equal(r.events[0].type, "message.delta");
96
+ assert.equal(r.events[0].final, true);
97
+ assert.match(r.events[0].text, /Repo contains docs/);
98
+ });
99
+
100
+ test("官方样本第 5 行:turn.completed → turn.end(completed) + usage(缺失字段按 0)", () => {
101
+ const r = mapCodexLine(OFFICIAL_SAMPLE[4], { now: at });
102
+ const ev = r.events[0];
103
+ assert.equal(ev.type, "turn.end");
104
+ assert.equal(ev.reason, "completed");
105
+ assert.equal(ev.isError, false);
106
+ assert.equal(ev.usage.inputTokens, 24763);
107
+ assert.equal(ev.usage.cachedInputTokens, 24448);
108
+ assert.equal(ev.usage.outputTokens, 122);
109
+ assert.equal(ev.usage.reasoningOutputTokens, 0);
110
+ assert.equal(ev.usage.cacheWriteInputTokens, 0, "官方样本没这个字段 → 默认 0(未核实线上是否总给)");
111
+ });
112
+
113
+ test("整条官方样本流跑 Codex 驱动:5 类事件齐、unknown=0、seq 连续", () => {
114
+ const d = createCodexDriver({ now: at });
115
+ const produced = [];
116
+ for (const l of OFFICIAL_SAMPLE) produced.push(...d.writeLine(l));
117
+ const s = d.stats();
118
+ assert.equal(s.driverName, "codex");
119
+ assert.equal(s.unknown, 0);
120
+ assert.equal(s.badJson, 0);
121
+ assert.deepEqual(produced.map((e) => e.type), ["status", "status", "tool.call", "message.delta", "turn.end"]);
122
+ assert.deepEqual(produced.map((e) => e.seq), [1, 2, 3, 4, 5]);
123
+ });
124
+
125
+ test("command_execution 完成:exit_code=0 → 成功;非 0/未知 status → isError", () => {
126
+ const okLine = line({
127
+ type: "item.completed",
128
+ item: {
129
+ id: "c1", type: "command_execution", command: "npm test", status: "completed",
130
+ exit_code: 0, aggregated_output: "all green"
131
+ }
132
+ });
133
+ const ok = mapCodexLine(okLine, { now: at });
134
+ const res = ok.events.find((e) => e.type === "tool.result");
135
+ assert.equal(res.isError, false);
136
+ assert.equal(res.exitCode, 0);
137
+ assert.equal(res.outputBytes, byteLength("all green"));
138
+ assert.equal(res.outputPreview, "all green");
139
+
140
+ const bad = mapCodexObject({
141
+ type: "item.completed",
142
+ item: { id: "c2", type: "command_execution", command: "false", status: "completed", exit_code: 1, aggregated_output: "boom" }
143
+ }, { now: at });
144
+ assert.equal(bad.events.find((e) => e.type === "tool.result").isError, true);
145
+
146
+ // 【未核实】Rust 枚举有 declined、TS 类型没有 → 按失败处理
147
+ const declined = mapCodexObject({
148
+ type: "item.completed",
149
+ item: { id: "c3", type: "command_execution", command: "rm x", status: "declined", aggregated_output: "" }
150
+ }, { now: at });
151
+ assert.equal(declined.events.find((e) => e.type === "tool.result").isError, true);
152
+ });
153
+
154
+ test("有状态驱动:started 与 completed 成对出现时 tool.call 只发一次", () => {
155
+ const d = createCodexDriver({ now: at });
156
+ d.writeLine(line({
157
+ type: "item.started",
158
+ item: { id: "x1", type: "command_execution", command: "ls", status: "in_progress" }
159
+ }));
160
+ const out2 = d.writeLine(line({
161
+ type: "item.completed",
162
+ item: { id: "x1", type: "command_execution", command: "ls", status: "completed", exit_code: 0, aggregated_output: "a" }
163
+ }));
164
+ assert.deepEqual(out2.map((e) => e.type), ["tool.result"], "completed 不再重复 tool.call");
165
+ assert.equal(d.stats().byType["tool.call"], 1);
166
+ });
167
+
168
+ test("file_change(仅在 completed 发出)→ tool.call + tool.result;文件数进事件、路径不落", () => {
169
+ const r = mapCodexLine(line({
170
+ type: "item.completed",
171
+ item: {
172
+ id: "f1", type: "file_change", status: "completed",
173
+ changes: [{ path: "/repo/secret-a.ts", kind: "update" }, { path: "/repo/secret-b.ts", kind: "add" }]
174
+ }
175
+ }), { now: at });
176
+ const call = r.events.find((e) => e.type === "tool.call");
177
+ const res = r.events.find((e) => e.type === "tool.result");
178
+ assert.equal(call.kind, "edit");
179
+ assert.equal(call.fileCount, 2);
180
+ assert.equal(res.isError, false);
181
+ assert.equal(JSON.stringify(r.events).includes("secret-a.ts"), false, "文件路径不落日志");
182
+ });
183
+
184
+ test("mcp_tool_call:started → tool.call(mcp:server/tool);completed 成功/失败 → tool.result", () => {
185
+ const started = mapCodexLine(line({
186
+ type: "item.started",
187
+ item: { id: "m1", type: "mcp_tool_call", server: "fs", tool: "read", arguments: { path: "/x" }, status: "in_progress" }
188
+ }), { now: at });
189
+ assert.equal(started.events[0].name, "mcp:fs/read");
190
+ assert.ok(started.events[0].inputBytes > 0);
191
+
192
+ const ok = mapCodexLine(line({
193
+ type: "item.completed",
194
+ item: {
195
+ id: "m1", type: "mcp_tool_call", server: "fs", tool: "read", arguments: {}, status: "completed",
196
+ result: { content: [{ type: "text", text: "file body" }], structured_content: null }
197
+ }
198
+ }), { now: at });
199
+ const okRes = ok.events.find((e) => e.type === "tool.result");
200
+ assert.equal(okRes.isError, false);
201
+ assert.equal(okRes.outputBytes, byteLength("file body"));
202
+
203
+ const bad = mapCodexLine(line({
204
+ type: "item.completed",
205
+ item: { id: "m2", type: "mcp_tool_call", server: "fs", tool: "read", arguments: {}, status: "failed", error: { message: "permission denied" } }
206
+ }), { now: at });
207
+ const badRes = bad.events.find((e) => e.type === "tool.result");
208
+ assert.equal(badRes.isError, true);
209
+ assert.match(badRes.outputPreview, /permission denied/);
210
+ });
211
+
212
+ test("web_search → tool.call(fetch);完成带结果数、不带查询原文", () => {
213
+ const started = mapCodexLine(line({
214
+ type: "item.started", item: { id: "w1", type: "web_search", query: "secret query text" }
215
+ }), { now: at });
216
+ assert.equal(started.events[0].kind, "fetch");
217
+ assert.equal(JSON.stringify(started.events).includes("secret query text"), false);
218
+
219
+ const done = mapCodexLine(line({
220
+ type: "item.completed", item: { id: "w1", type: "web_search", query: "q", results: [{}, {}] }
221
+ }), { now: at });
222
+ assert.equal(done.events.find((e) => e.type === "tool.result").resultCount, 2);
223
+ });
224
+
225
+ test("reasoning → status(thinking) 只带字节数;有状态驱动去重", () => {
226
+ const r = mapCodexLine(line({ type: "item.started", item: { id: "r1", type: "reasoning", text: "I should run rm -rf" } }), { now: at });
227
+ assert.equal(r.events[0].status, "thinking");
228
+ assert.equal(r.events[0].textBytes, byteLength("I should run rm -rf"));
229
+ assert.equal(JSON.stringify(r.events).includes("rm -rf"), false, "推理内容不落");
230
+
231
+ const d = createCodexDriver({ now: at });
232
+ d.writeLine(line({ type: "item.started", item: { id: "r1", type: "reasoning", text: "x" } }));
233
+ const second = d.writeLine(line({ type: "item.completed", item: { id: "r1", type: "reasoning", text: "x" } }));
234
+ assert.deepEqual(second, [], "同一 reasoning 不发两次 thinking");
235
+ });
236
+
237
+ test("todo_list → status(plan) 带条数与完成数", () => {
238
+ const r = mapCodexLine(line({
239
+ type: "item.completed",
240
+ item: { id: "t1", type: "todo_list", items: [{ text: "a", completed: true }, { text: "b", completed: false }] }
241
+ }), { now: at });
242
+ assert.equal(r.events[0].status, "plan");
243
+ assert.equal(r.events[0].planItems, 2);
244
+ assert.equal(r.events[0].planDone, 1);
245
+ });
246
+
247
+ test("错误两种:item error → status(item);顶层 error → status(stream);turn.failed → turn.end(error)", () => {
248
+ const itemErr = mapCodexLine(line({ type: "item.completed", item: { id: "e1", type: "error", message: "boom item" } }), { now: at });
249
+ assert.equal(itemErr.events[0].status, "error");
250
+ assert.equal(itemErr.events[0].scope, "item");
251
+
252
+ const streamErr = mapCodexLine(line({ type: "error", message: "boom stream" }), { now: at });
253
+ assert.equal(streamErr.events[0].status, "error");
254
+ assert.equal(streamErr.events[0].scope, "stream");
255
+
256
+ const failed = mapCodexLine(line({ type: "turn.failed", error: { message: "model overloaded" } }), { now: at });
257
+ assert.equal(failed.events[0].type, "turn.end");
258
+ assert.equal(failed.events[0].reason, "error");
259
+ assert.match(failed.events[0].errorMessage, /model overloaded/);
260
+ });
261
+
262
+ test("collab_tool_call(Rust 有、TS 无 → 未核实)也能映射成 tool.call,不崩", () => {
263
+ const r = mapCodexLine(line({
264
+ type: "item.started",
265
+ item: { id: "cb1", type: "collab_tool_call", tool: "spawn_agent", prompt: "do x", status: "in_progress" }
266
+ }), { now: at });
267
+ assert.equal(r.events[0].name, "collab:spawn_agent");
268
+ assert.equal(r.events[0].kind, "other");
269
+ });
270
+
271
+ test("item.updated 忽略(增量语义未核实);agent_message 的 started 不发事件", () => {
272
+ assert.deepEqual(mapCodexLine(line({
273
+ type: "item.updated", item: { id: "c9", type: "command_execution", command: "ls", status: "in_progress" }
274
+ }), { now: at }).events, []);
275
+ assert.deepEqual(mapCodexLine(line({
276
+ type: "item.started", item: { id: "a1", type: "agent_message", text: "partial" }
277
+ }), { now: at }).events, []);
278
+ });
279
+
280
+ test("未知形状:未知顶层 type / 未知 item type / 坏 JSON / 超长行 —— 全不崩", () => {
281
+ assert.equal(mapCodexLine(line({ type: "thread.paused" }), { now: at }).reason, "unknown-type:thread.paused");
282
+ assert.equal(
283
+ mapCodexLine(line({ type: "item.started", item: { id: "z", type: "teleport" } }), { now: at }).reason,
284
+ "unknown-item-type:teleport"
285
+ );
286
+ assert.equal(mapCodexLine("{bad", { now: at }).reason, "bad-json");
287
+ assert.equal(mapCodexLine(line({ type: "turn.started", pad: "x".repeat(200) }), { now: at, maxLineBytes: 50 }).reason, "too-long");
288
+ assert.equal(mapCodexLine(line({ type: "item.started" }), { now: at }).reason, "item-without-payload");
289
+
290
+ // 驱动层:未知计数不崩,后续行照常
291
+ const d = createCodexDriver({ now: at });
292
+ d.writeLine(line({ type: "thread.paused" }));
293
+ d.writeLine(OFFICIAL_SAMPLE[1]);
294
+ const s = d.stats();
295
+ assert.equal(s.unknown, 1);
296
+ assert.deepEqual(s.unknownReasons, { "unknown-type:thread.paused": 1 });
297
+ assert.equal(s.events, 1);
298
+ });
299
+
300
+ /**
301
+ * **真实校准夹具**(M2 第 5 周,1 次真实 `codex exec --json`,逐字摘录 stdout)。
302
+ * 命令:`codex exec --json --sandbox read-only --skip-git-repo-check "Reply with exactly: OK"`(本机 0.150.1)。
303
+ * 该次 CLI 因本机 model 与账号不兼容退出 1,但流是合法 JSONL —— 正好校准了**错误路径**。
304
+ */
305
+ const REAL_CAPTURE = [
306
+ '{"type":"thread.started","thread_id":"01a1acb4-1656-7582-ae8e-a504ade1fe23"}',
307
+ '{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Model metadata for `gpt-6.1-sol` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."}}',
308
+ '{"type":"turn.started"}',
309
+ '{"type":"item.completed","item":{"id":"item_1","type":"error","message":"Skill descriptions were shortened to fit the skills context budget."}}',
310
+ '{"type":"error","message":"{\\"type\\":\\"error\\",\\"status\\":400,\\"error\\":{\\"type\\":\\"invalid_request_error\\",\\"message\\":\\"The \'gpt-6.1-sol\' model is not supported when using Codex with a ChatGPT account.\\",\\"code\\":\\"model_not_found\\"}}"}',
311
+ '{"type":"turn.failed","error":{"message":"{\\"type\\":\\"error\\",\\"status\\":400,\\"error\\":{\\"type\\":\\"invalid_request_error\\",\\"message\\":\\"The \'gpt-6.1-sol\' model is not supported when using Codex with a ChatGPT account.\\",\\"code\\":\\"model_not_found\\"}}"}}'
312
+ ];
313
+
314
+ test("真实校准:官方之外的真实流也能全部映射(unknown=0),并覆盖错误路径", () => {
315
+ const d = createCodexDriver({ now: at });
316
+ const events = [];
317
+ for (const l of REAL_CAPTURE) events.push(...d.writeLine(l));
318
+ const s = d.stats();
319
+ assert.equal(s.lines, 6);
320
+ assert.equal(s.unknown, 0, "真实流的 6 行都必须能映射");
321
+ assert.equal(s.badJson, 0);
322
+ assert.equal(s.mapperErrors, 0);
323
+ // 实测顺序:thread.started → item.error ×2 → turn.started → error → turn.failed
324
+ assert.deepEqual(events.map((e) => e.type), ["status", "status", "status", "status", "status", "turn.end"]);
325
+ assert.equal(events.filter((e) => e.status === "error").length, 3, "两条 item 错误 + 一条流错误");
326
+ assert.equal(events[events.length - 1].reason, "error");
327
+ });
328
+
329
+ test("真实校准差异①:error 类 item 可以出现在 turn.started 之前、且可以多条(不依赖顺序)", () => {
330
+ const beforeTurn = mapCodexLine(REAL_CAPTURE[1], { now: at });
331
+ assert.equal(beforeTurn.ok, true);
332
+ assert.equal(beforeTurn.events[0].scope, "item");
333
+ const turnStarted = mapCodexLine(REAL_CAPTURE[2], { now: at });
334
+ assert.equal(turnStarted.events[0].phase, "turn");
335
+ });
336
+
337
+ test("真实校准差异②:error/turn.failed 的 message 是嵌套 JSON 字符串 → 被解一层", () => {
338
+ const streamErr = mapCodexLine(REAL_CAPTURE[4], { now: at });
339
+ const msg = streamErr.events[0].message;
340
+ assert.match(msg, /not supported when using Codex with a ChatGPT account/);
341
+ assert.equal(msg.startsWith("{"), false, "不能把原始 JSON 直接塞给 UI");
342
+
343
+ const failed = mapCodexLine(REAL_CAPTURE[5], { now: at });
344
+ assert.match(failed.events[0].errorMessage, /not supported when using Codex/);
345
+ });
346
+
347
+ test("normalizeErrorMessage:内层 error.message / 内层 message / 普通字符串 / 非法 JSON", () => {
348
+ assert.equal(normalizeErrorMessage('{"error":{"message":"inner"}}'), "inner");
349
+ assert.equal(normalizeErrorMessage('{"message":"top"}'), "top");
350
+ assert.equal(normalizeErrorMessage("plain text"), "plain text");
351
+ assert.equal(normalizeErrorMessage("{not json"), "{not json");
352
+ assert.equal(normalizeErrorMessage(""), "");
353
+ assert.equal(normalizeErrorMessage(undefined), "");
354
+ });
355
+
356
+ test("隐私总检查:官方样本 + 常见工具行映射后,任何事件都不含命令/查询原文", () => {
357
+ const samples = [
358
+ ...OFFICIAL_SAMPLE,
359
+ line({ type: "item.started", item: { id: "c", type: "command_execution", command: "curl -H 'Authorization: Bearer sk-xyz' https://x", status: "in_progress" } }),
360
+ line({ type: "item.started", item: { id: "w", type: "web_search", query: "how to delete prod db" } }),
361
+ line({ type: "item.started", item: { id: "m", type: "mcp_tool_call", server: "s", tool: "t", arguments: { secret: "sk-abc" } } })
362
+ ];
363
+ const d = createCodexDriver({ now: at });
364
+ const all = [];
365
+ for (const l of samples) all.push(...d.writeLine(l));
366
+ const dump = JSON.stringify(all);
367
+ for (const needle of ["bash -lc ls", "sk-xyz", "sk-abc", "delete prod db", "Authorization"]) {
368
+ assert.equal(dump.includes(needle), false, `事件里不应出现 ${needle}`);
369
+ }
370
+ });
371
+
372
+ // ── CLI 探测(假实现;真机探测见文件末尾条件用例) ──────────────────────────
373
+
374
+ const HELP_TEXT = [
375
+ "Run Codex non-interactively",
376
+ " --json",
377
+ " -o, --output-last-message <FILE>",
378
+ " --output-schema <FILE>",
379
+ " --sandbox <SANDBOX_MODE>",
380
+ " --approve-for-me",
381
+ " --dangerously-bypass-approvals-and-sandbox"
382
+ ].join("\n");
383
+
384
+ function makeFs(overrides = {}) {
385
+ const enoent = () => {
386
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
387
+ };
388
+ return { existsSync: () => false, statSync: () => enoent(), realpathSync: (p) => p, ...overrides };
389
+ }
390
+
391
+ function fakeSpawn({ version = "codex-cli 0.150.1", help = HELP_TEXT } = {}) {
392
+ return (command, args) => {
393
+ if (args.includes("--version")) return { status: 0, stdout: `${version}\n`, stderr: "" };
394
+ if (args.includes("exec") && args.includes("--help")) return { status: 0, stdout: help, stderr: "" };
395
+ return { status: 0, stdout: "", stderr: "" };
396
+ };
397
+ }
398
+
399
+ test("候选:显式 env 的 .js 用 node 跑;PATH 解软链;npm 全局 codex.js 兜底", () => {
400
+ const js = "/usr/lib/node_modules/@openai/codex/bin/codex.js";
401
+ const c = buildCodexCandidates({
402
+ env: { [CODEX_BIN_ENV]: "/custom/codex", PATH: "/usr/bin", DSH_AGENT_NPM_ROOT: "" },
403
+ platform: "linux",
404
+ execPath: "/usr/bin/node",
405
+ fs: makeFs({ statSync: (p) => ({ isFile: () => p === js, mode: 0o755 }) }),
406
+ npmRoot: "/usr/lib/node_modules",
407
+ isExecutable: (p) => p === "/usr/bin/codex" || p === js,
408
+ realpath: (p) => (p === "/usr/bin/codex" ? "/real/codex" : p)
409
+ });
410
+ assert.deepEqual(c.map((x) => x.kind), ["explicit", "path", "npm-node"]);
411
+ assert.equal(c[1].command, "/real/codex");
412
+ assert.equal(c[2].command, "/usr/bin/node");
413
+ assert.deepEqual(c[2].argsPrefix, [js]);
414
+
415
+ const jsExplicit = buildCodexCandidates({
416
+ env: { [CODEX_BIN_ENV]: "/custom/codex.js" },
417
+ execPath: "/usr/bin/node",
418
+ fs: makeFs(),
419
+ npmRoot: ""
420
+ });
421
+ assert.equal(jsExplicit[0].kind, "explicit-node");
422
+ });
423
+
424
+ test("探测:正常路径 → ok + notify-only + caps 来自 exec --help", () => {
425
+ const r = detectCodex({
426
+ env: { [CODEX_BIN_ENV]: "/fake/codex", PATH: "" },
427
+ platform: "linux",
428
+ fs: makeFs(),
429
+ spawnSync: fakeSpawn(),
430
+ npmRoot: ""
431
+ });
432
+ assert.equal(r.ok, true);
433
+ assert.equal(r.status, "ok");
434
+ assert.equal(r.mode, "notify-only", "exec 不能回调审批 → 只通知");
435
+ assert.equal(r.version.text, "0.150.1");
436
+ assert.equal(r.caps.jsonOutput, true);
437
+ assert.equal(r.caps.structuredOutput, true);
438
+ assert.equal(r.caps.externalApprovalHook, false);
439
+ assert.equal(r.caps.approvalsAutoReview, true);
440
+ });
441
+
442
+ test("探测:CLI 不存在 → cli-missing;有候选但版本认不出 → version-unknown", () => {
443
+ const missing = detectCodex({
444
+ env: { PATH: "" },
445
+ platform: "linux",
446
+ fs: makeFs(),
447
+ spawnSync: fakeSpawn(),
448
+ npmRoot: ""
449
+ });
450
+ assert.equal(missing.ok, false);
451
+ assert.equal(missing.status, "cli-missing");
452
+
453
+ const unknown = detectCodex({
454
+ env: { [CODEX_BIN_ENV]: "/fake/codex", PATH: "" },
455
+ platform: "linux",
456
+ fs: makeFs(),
457
+ spawnSync: () => ({ status: 0, stdout: "weird", stderr: "" }),
458
+ npmRoot: ""
459
+ });
460
+ assert.equal(unknown.ok, false);
461
+ assert.equal(unknown.status, "version-unknown");
462
+ assert.equal(unknown.attempts.length, 1);
463
+ });
464
+
465
+ test("探测:exec --help 失败不致命(ok 但要带 warning,jsonOutput 视为未确认)", () => {
466
+ const r = detectCodex({
467
+ env: { [CODEX_BIN_ENV]: "/fake/codex" },
468
+ platform: "linux",
469
+ fs: makeFs(),
470
+ npmRoot: "",
471
+ spawnSync: (command, args) => {
472
+ if (args.includes("--version")) return { status: 0, stdout: "codex-cli 0.150.1", stderr: "" };
473
+ return { status: 2, stdout: "", stderr: "help boom" };
474
+ }
475
+ });
476
+ assert.equal(r.ok, true);
477
+ assert.equal(r.version.text, "0.150.1");
478
+ assert.equal(r.caps.jsonOutput, false, "没探到就不得声称支持");
479
+ assert.ok(r.warnings.some((w) => w.code === "caps-probe-failed"));
480
+ });
481
+
482
+ test("探测:Windows 只认 codex.exe(不把 codex.cmd 当候选)", () => {
483
+ const seen = [];
484
+ buildCodexCandidates({
485
+ env: { PATH: "C:\\Users\\x\\AppData\\Roaming\\npm" },
486
+ platform: "win32",
487
+ fs: makeFs(),
488
+ npmRoot: "",
489
+ isExecutable: (p) => {
490
+ seen.push(path.basename(p));
491
+ return /codex\.exe$/i.test(p);
492
+ }
493
+ });
494
+ assert.deepEqual(seen, ["codex.exe"]);
495
+ });
496
+
497
+ // ── 本机真实探测(装了就跑;不调用模型、不耗额度) ──────────────────────────
498
+
499
+ const REAL_CODEX = (() => {
500
+ try {
501
+ const r = nodeSpawnSync("codex", ["--version"], {
502
+ encoding: "utf8",
503
+ timeout: 8000,
504
+ windowsHide: true,
505
+ stdio: ["ignore", "pipe", "pipe"]
506
+ });
507
+ if (r.status === 0 && /codex/i.test(String(r.stdout || ""))) return { ok: true, raw: String(r.stdout).trim() };
508
+ return { ok: false, raw: String(r.stdout || r.stderr || "").trim() };
509
+ } catch {
510
+ return { ok: false, raw: "" };
511
+ }
512
+ })();
513
+
514
+ test(
515
+ "本机 codex 真实探测(未安装则 SKIP):版本 + exec --help 能力位",
516
+ { skip: REAL_CODEX.ok ? false : `本机未安装 codex CLI(探测结果: ${REAL_CODEX.raw || "无"})` },
517
+ () => {
518
+ const r = detectCodex({
519
+ env: { ...process.env, [CODEX_BIN_ENV]: "" },
520
+ platform: process.platform,
521
+ npmRoot: "" // 只走 PATH:真实文件系统 + 真实 spawn,不 mock
522
+ });
523
+ assert.equal(r.ok, true, `探测失败: ${JSON.stringify(r.attempts || r.reason)}`);
524
+ assert.equal(r.cli.kind, "path");
525
+ assert.match(r.version.text, /^\d+\.\d+\.\d+$/, "版本号必须能解析(codex-cli X.Y.Z)");
526
+ assert.equal(r.mode, "notify-only");
527
+ assert.equal(r.caps.externalApprovalHook, false);
528
+ // 记录实测能力位(失败时会打印)
529
+ assert.equal(typeof r.caps.jsonOutput, "boolean");
530
+ // 本机 0.150.1 实测有 --json/--output-schema/--approve-for-me/sandbox
531
+ assert.equal(r.caps.jsonOutput, true, "本机实测 help 里有 --json");
532
+ assert.equal(r.caps.structuredOutput, true, "本机实测 help 里有 --output-schema");
533
+ assert.equal(r.caps.sandboxFlag, true, "本机实测 help 里有 --sandbox");
534
+ }
535
+ );