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.
@@ -0,0 +1,412 @@
1
+ # agent/ —— 本地 Agent 运行时(M2 第 2 周:PermissionBridge 雏形)
2
+
3
+ > **状态**:M2 第 2 周交付。**默认关闭**,只有被 bridge/插件显式调用 `start()` 才生效。
4
+ > **零依赖**(只用 Node 内置)、**不 import 任何 DSH 模块**、**不碰现有链路**。
5
+ >
6
+ > 依据:`dsh-relay-enterprise/docs/multi-agent-design.md`(设计稿)+
7
+ > `research/2026-11-strategy/12-multiagent-m2/00-第1周验证报告.md`(实测结论)。
8
+
9
+ ---
10
+
11
+ ## 1. 这个目录解决什么问题
12
+
13
+ Claude Code 要弹审批时,会调用我们注入的 `PermissionRequest` 钩子。本模块:
14
+
15
+ ```
16
+ Claude Code ──HTTP POST──▶ 127.0.0.1:<随机端口>/permission?t=<token>
17
+ │
18
+ ├─ 四道闸:token / 事件名 / 体积 / 并发
19
+ ├─ 归一化:toolName / kind / detail(命令原文或路径)
20
+ ├─ 决策来源 = 可注入回调(M2 第 3 周接手机;现在缺省 fail-closed)
21
+ └─ 回写第 1 周实测确认过的响应体
22
+ ```
23
+
24
+ 同时负责:**版本门禁**(旧版本 Claude Code 不触发钩子 → 明确不可用,不静默丢审批)、
25
+ **既有钩子让位**(用户/企业已有 `PermissionRequest` 钩子时,我们不注入,避免绕过他们的策略)、
26
+ **子进程治理**("停止"必须杀掉整棵进程树,否则 Claude 会孤儿化继续跑)。
27
+
28
+ ---
29
+
30
+ ## 2. 导出什么(挂载方只需要这些)
31
+
32
+ ```js
33
+ import { createAgentRuntime, AGENT_STATUS } from "./agent/index.mjs";
34
+ ```
35
+
36
+ > 第 4 周新增:`agent/events.mjs` 的通用驱动 `createJsonlDriver`、`agent/codex.mjs` 的
37
+ > `mapCodexObject` / `createCodexDriver` / `detectCodex`(接入清单见 §9)。
38
+
39
+ `createAgentRuntime(opts)` 返回一个对象,**默认不启动**:
40
+
41
+ | 方法 | 说明 |
42
+ |---|---|
43
+ | `start()` | 探测 + 门禁 + 起桥接。**永不抛错**,返回状态对象 |
44
+ | `stop()` | 关端口、删临时 settings、结算未决审批(一律 deny)。幂等 |
45
+ | `probe()` | 只探测(不占端口、不写盘),给"入口灰显 + 原因"用 |
46
+ | `status()` | 同步读当前状态 |
47
+
48
+ `start()` 的返回(`ok:true` 时):
49
+
50
+ ```js
51
+ {
52
+ ok: true,
53
+ status: "ready",
54
+ mode: "approval",
55
+ claude: { kind: "npm-global-exe", path: "…/bin/claude.exe", command: "…", argsPrefix: [] },
56
+ version: { major:2, minor:1, patch:318, text:"2.1.318" },
57
+ minVersion: "2.1.318",
58
+ settingsPath: "/tmp/dsh-agent-perm-xxxx/settings.json",
59
+ port: 51234,
60
+ launch: { args: ["--permission-mode", "default", "--settings", "<settingsPath>"] },
61
+ permission: { ttlMs: 90000, hookTimeoutSec: 120, url: "http://127.0.0.1:51234", pendingCount: [Function] },
62
+ stop: [Function]
63
+ }
64
+ ```
65
+
66
+ **挂载方式(最小接入)**:
67
+
68
+ ```js
69
+ const runtime = createAgentRuntime({
70
+ log: (e) => logger.info(JSON.stringify(e)), // 结构化日志;不会收到命令原文
71
+ decide: async (req) => ({ decision: "deny" }) // 第 3 周换成"手机回执";缺省 fail-closed
72
+ });
73
+
74
+ const r = await runtime.start();
75
+ if (r.ok) {
76
+ // 起 Claude Code 时把 r.launch.args 追加进参数数组(不要拼 shell 字符串):
77
+ // spawn(claudeBin.path, [...r.launch.args, "-p", prompt], {...})
78
+ // 手机端审批回调接通后,r.permission.pendingCount() 可做自检
79
+ }
80
+ // 退出/关闭时:
81
+ await runtime.stop();
82
+ ```
83
+
84
+ ### 状态 → 降级(UI 直接映射)
85
+
86
+ | `status` | 含义 | 建议行为 |
87
+ |---|---|---|
88
+ | `disabled` | 未启用(默认) | 不渲染 Agent 入口 |
89
+ | `cli-missing` | 找不到 Claude Code | 灰显 + "请安装 / 检查 PATH 或 DSH_AGENT_CLAUDE_BIN" |
90
+ | `version-unsupported` | 版本低于阈值 | 灰显 + "请升级 Claude Code ≥ `<minVersion>`" |
91
+ | `version-unknown` | 找到候选但认不出版本 | 灰显 + "安装可能损坏" |
92
+ | `existing-hooks` | 本机已有审批钩子(或强制 notify-only) | **只通知模式**,明示"远程审批已让位" |
93
+ | `bridge-error` | 端口/临时目录失败 | 只通知模式 + `reason` 排障 |
94
+ | `ready` | 审批通道就绪 | 正常启用 |
95
+
96
+ > **降级原则**:任何异常都返回 `ok:false` + `status`,**不抛错**;绝不"没审批就默认放行"。
97
+
98
+ ---
99
+
100
+ ## 3. 环境变量
101
+
102
+ | 变量 | 默认 | 说明 |
103
+ |---|---|---|
104
+ | `DSH_AGENT_ENABLED` | 关 | `1` 打开(也可用 `createAgentRuntime({ enabled:true })`) |
105
+ | `DSH_AGENT_CLAUDE_BIN` | — | 显式指定 CLI 路径(排障/企业定制;`.cjs` 会被当成 wrapper 用 node 启动) |
106
+ | `DSH_AGENT_MIN_CLAUDE_VERSION` | `2.1.318` | 最低版本阈值。写错会回落默认并告警(**不会因为配置错就放行**) |
107
+ | `DSH_AGENT_NOTIFY_ONLY` | 关 | `1` = 强制只通知(不注入钩子) |
108
+ | `DSH_AGENT_PERMISSION_TTL_MS` | `90000` | 决策窗口;到期一律 deny |
109
+ | `DSH_AGENT_HOOK_TIMEOUT_SEC` | `120` | 写进临时 settings 的钩子超时(秒;上游单位实测是秒) |
110
+ | `DSH_AGENT_BRIDGE_PORT` | `0` | 桥接端口;`0`=随机(只绑 `127.0.0.1`) |
111
+ | `DSH_AGENT_SETTINGS_DIR` | 系统临时目录 | 临时 settings 的父目录 |
112
+ | `DSH_AGENT_NPM_ROOT` | 自动探测 | npm 全局根(Windows 自定义 prefix 时用) |
113
+ | `DSH_AGENT_PROBE_CACHE_MS` | `300000` | `probe()` 结果缓存;避免面板重绘反复 spawn CLI(杀软敏感)。`probe({force:true})` 可强测 |
114
+
115
+ ---
116
+
117
+ ## 4. 隐私与日志契约
118
+
119
+ - `log` 回调只会收到**元数据**:`{event, requestId, toolName, kind, outcome, durationMs, …}`;
120
+ **绝不包含命令原文 / 文件内容 / token**(有专门单测锁住)。
121
+ - `detail`(命令原文或路径)只交给 `decide()` —— 因为手机审批要给人看。
122
+ - 临时 settings 文件含 token → 写入即 `0600`,`stop()` 必删。
123
+ - 不写 stdout、不落业务日志文件。
124
+
125
+ ---
126
+
127
+ ## 5. 子进程治理(`proc.mjs`)
128
+
129
+ `spawnAgentProcess()`:参数数组 + `shell:false` + posix `detached`(独立进程组)+ `windowsHide`。
130
+
131
+ `terminateProcessTree(pid)`:posix 先 `process.kill(-pid, SIGTERM)`(**整组**)→ 3s → `SIGKILL`
132
+ (升级);Windows 走 `taskkill /PID <pid> /T /F`。
133
+
134
+ > ⚠️ **Windows 分支未真机验证**:返回 `verified:false`,上层只能显示"已请求停止",
135
+ > 不得宣称"已确认杀死"。真机验证清单见第 1 周报告 §4.4(W7)。
136
+
137
+ **为什么必须整组杀**(第 1 周实测):经 `cli-wrapper.cjs` 启动时,SIGTERM 只杀 wrapper,
138
+ 原生 claude 会 reparent 到 PPID=1 继续跑 —— 手机上点"停止"却还在执行工具。
139
+
140
+ ---
141
+
142
+ ## 6. 已做 / 未做(M2 边界)
143
+
144
+ **已做(M2 第 2 周)**
145
+ - 版本门禁(默认 2.1.318,env 可覆盖)+ CLI 四级解析 + `--version` 探测
146
+ - 既有 `PermissionRequest` 钩子扫描(user/project/local,只读)→ 让位
147
+ - PermissionBridge:loopback 随机端口、临时 settings 0600、token/事件名/体积/并发四道闸、
148
+ fail-closed 90s、实测响应体、幂等 stop
149
+ - 进程治理:参数数组 spawn、进程组 kill、SIGTERM→SIGKILL、Windows taskkill 预留
150
+ - 全部外部依赖可注入;单测覆盖门禁/让位/fail-closed/响应体/kill/清理
151
+
152
+ **已做(M2 第 3 周)**
153
+ - `events.mjs`:`claude --output-format stream-json` 逐行 → 5 种归一化事件
154
+ (`status` / `message.delta` / `tool.call` / `tool.result` / `turn.end`),可落 append-only jsonl;
155
+ 未知行/坏 JSON/超长行计数丢弃、落盘失败静默。
156
+ - `agent-e2e-real.mjs`:**真实 Claude Code 端到端联调**(默认跳过),实测 allow 真跑 / deny 不跑 /
157
+ TTL 到期自动拒;顺带用真实输出校验事件映射(`unknown=0`)。
158
+
159
+ **已做(M2 第 4 周)**
160
+ - `events.mjs` 拆成 **通用 jsonl 驱动**(`createJsonlDriver`)+ Claude 映射(`mapClaudeObject` / `mapClaudeLine`),
161
+ 对外行为不变;新增 `frameLine` / `parseLineJson` / `mapClaudeObject` 等可复用件。
162
+ - `codex.mjs`:**第二家 harness 样板** —— `mapCodexObject`(`codex exec --json` → 同一套 5 类事件)、
163
+ `createCodexDriver`(跨行去重)、`detectCodex`(找 CLI / 版本 / `exec --help` 能力位)。
164
+ - 本机实测:`codex-cli 0.150.1`,`--json` / `--output-schema` / `--sandbox` / `--approve-for-me` 均存在,
165
+ 但 **没有"审批回调外部程序"的开关** ⇒ Codex 走 `exec` 时只能 `mode:"notify-only"`。
166
+
167
+ **已做(M2 第 5 周:四家接入骨架齐)**
168
+ - `gemini.mjs`:Gemini CLI 的 **ACP(JSON-RPC over stdio)** 映射(`session/update` 全变体、`tool_call*`、
169
+ `session/request_permission`、`stopReason`),`detectGemini` 实测 ACP 能力位
170
+ (本机 `gemini 0.31.0` 的开关是 **`--experimental-acp`**,不是文档里的 `--acp`)。
171
+ - `opencode.mjs`:OpenCode 的 **HTTP/SSE** 映射(`message.part.updated`、`permission.updated/replied`、
172
+ `session.idle/status/error`)+ **SSE→事件桥接**(`createSseParser` / `attachOpenCodeSse`),`detectOpenCode`。
173
+ - `agent-e2e-codex.mjs` + **Codex 真实校准 1 次**:实得 6 行 JSONL、`unknown=0`;发现并修掉两处差异
174
+ (error item 可早于 turn.started;error.message 是嵌套 JSON 字符串 → `normalizeErrorMessage`)。
175
+
176
+ **未做(后续周)**
177
+ - 手机端决策来源(`decide` 现在缺省 fail-closed)、SessionStore 落盘、`/_agent` 隧道挂载、E2EE、微信 sink、插件面板入口
178
+ - Codex 正常路径校准(本机 model 与账号不兼容 → 只拿到错误路径;修好 model 后可复跑 `agent-e2e-codex.mjs`)
179
+ - Codex 审批(需 `codex app-server`,experimental;exec 模式做不到)
180
+ - Gemini ACP 真实握手(`initialize` / 版本代际 / `request_permission` 回写)与 OpenCode 真实流校准
181
+ (本机未安装 opencode;两家都只有"按官方类型定义映射")
182
+ - Windows 真机验证(`taskkill /T /F` 的实际效果、中文/空格路径)
183
+ - `allow_always` / `updatedPermissions`(会写用户 settings,风险大于收益,M2 不做)
184
+ - 企业 managed settings 检测(M3)
185
+
186
+ ---
187
+
188
+ ## 7. 已知风险 / 未验证(挂载前请知悉)
189
+
190
+ | 项 | 状态 | 说明 |
191
+ |---|---|---|
192
+ | Windows `taskkill /T /F` 实际效果 | **未验证**(无真机) | 返回 `verified:false`;真机清单见第 1 周报告 §4.4 W7 |
193
+ | 用户已在别处传 `--dangerously-skip-permissions` | **未验证** | 命令行参数谁生效取决于参数顺序与权限模式优先级;第 3 周需验证,若不成立则改为"检测到 bypass 模式就拒绝启用审批" |
194
+ | 企业 managed settings(`allowManagedPermissionRulesOnly` 等) | **未验证** | M3 企业版前补检测 |
195
+ | 多个会话并发审批 | 已支持(`maxPending=8`,超限 429) | 但**同一会话多请求的排序/取消**未验证 |
196
+ | Claude Code 版本门槛精确定值 | **未确定** | 只测了 2.1.195 ❌ / 2.1.318 ✅;默认阈值保守取 2.1.318,第 3 周二分 |
197
+
198
+ ## 8. 事件层:通用 jsonl 驱动 + 两家映射(`events.mjs` / `codex.mjs`)+ 真实联调
199
+
200
+ ### 8.1 分层:驱动不认识任何一家 CLI
201
+
202
+ ```js
203
+ import { createJsonlDriver, createJsonlSink } from "./agent/events.mjs";
204
+ import { createCodexMapper } from "./agent/codex.mjs";
205
+
206
+ const driver = createJsonlDriver({
207
+ mapLine: (obj, ctx) => myMap(obj, ctx), // ← 一家一份(Claude 用 mapClaudeObject,Codex 用 createCodexMapper())
208
+ sink: createJsonlSink({ filePath }), // 可选;不给 filePath = 默认不落盘
209
+ onEvent: (e) => phone.send(e), // 每条归一化事件
210
+ onUnknown: (reason, raw) => metrics.count(reason) // 未识别行(计数+忽略,不崩)
211
+ });
212
+ driver.writeLine(rawLine); // 喂一行,返回本行产出的事件
213
+ driver.stats(); // {driverName,lines,events,badJson,tooLong,empty,unknown,mapperErrors,byType,byReason,unknownReasons}
214
+ ```
215
+
216
+ 现成的两家:`createEventMapper()`(Claude,第 3 周 API 不变)、`createCodexDriver()`(Codex,内部跨行去重)。
217
+ 第 3 周的 `mapStreamJsonLine` 保留为 `mapClaudeLine` 的别名(行为一致)。
218
+
219
+ ### 8.2 Claude Code 映射
220
+
221
+ | 归一化事件 | 来源 | 说明 |
222
+ |---|---|---|
223
+ | `status` | `system/init`、`hook_started/hook_response`、`api_retry`、**`permission_denied`** | `permission_denied` 是实测拿到的"审批被拒"回执 |
224
+ | `message.delta` | `assistant` 文本(final=true)、`stream_event` 的 `text_delta`(final=false) | 不重复成 final 事件 |
225
+ | `tool.call` | `tool_use` | **只带 id/name/kind/输入字节数**,不带命令原文 |
226
+ | `tool.result` | `tool_result` | 只带 isError/输出字节数/截断预览(默认 120 字符,可设 0 关闭) |
227
+ | `turn.end` | `result` | reason: completed / error / max-turns / interrupted / aborted |
228
+
229
+ ### 8.3 Codex 映射(`codex.mjs`)与依据来源
230
+
231
+ | Codex 事件(官方 `exec --json`) | 归一化事件 | 说明 |
232
+ |---|---|---|
233
+ | `thread.started` | `status(working, phase=thread)` | 带 `threadId`(未来续接用) |
234
+ | `turn.started` | `status(working, phase=turn)` | |
235
+ | `item.*` + item.type=`agent_message` | `message.delta(final=true)` | 只在 `item.completed` 发 |
236
+ | `item.*` + `reasoning` | `status(thinking)` | **只带 textBytes**,推理内容不落 |
237
+ | `item.*` + `command_execution` | `tool.call(shell/execute)` / `tool.result` | **命令原文不落**,只带字节数;`exit_code`/`status` 判成败 |
238
+ | `item.*` + `file_change` | `tool.call(file_change/edit)` + `tool.result` | 只带文件数,路径不落 |
239
+ | `item.*` + `mcp_tool_call` | `tool.call(mcp:server/tool)` / `tool.result` | arguments 只算字节数 |
240
+ | `item.*` + `web_search` | `tool.call(web_search/fetch)` / `tool.result` | query 不落,完成带结果数 |
241
+ | `item.*` + `todo_list` | `status(plan)` | 只带条数/完成数 |
242
+ | `item.*` + `error` / 顶层 `error` | `status(error, scope=item/stream)` | 错误文案截断 |
243
+ | `turn.completed` | `turn.end(completed)` | `usage` 只保留 token 数与 `cacheWriteInputTokens`(缺失按 0) |
244
+ | `turn.failed` | `turn.end(error)` | error.message 截断 |
245
+ | `item.updated` | **忽略** | 增量语义官方未给样本【未核实】 |
246
+
247
+ **依据来源(全部官方;2026-11 读取)**
248
+
249
+ | 依据 | 出处 |
250
+ |---|---|
251
+ | 顶层事件枚举 + 官方样本 JSONL(测试夹具逐字摘录) | <https://learn.chatgpt.com/docs/non-interactive-mode>("Make output machine-readable" / "Sample JSON stream") |
252
+ | 字段级权威定义(`ThreadEvent` / `ThreadItem` / 各 Item 结构体 / 状态枚举) | `openai/codex` → `codex-rs/exec/src/exec_events.rs` |
253
+ | 同款 TS 类型(跨语言对照) | `openai/codex` → `sdk/typescript/src/events.ts`、`sdk/typescript/src/items.ts` |
254
+ | 本机能力位(`--json` / `--output-schema` / `--sandbox` / `--approve-for-me`) | 本机 `codex exec --help`(`codex-cli 0.150.1`,实测) |
255
+
256
+ **本机真实探测结果(2026-11,`codex-cli 0.150.1`)**:`ok:true`,`mode:"notify-only"`;
257
+ `caps={jsonOutput:true, structuredOutput:true, resume:true, appServer:true, approvalsAutoReview:true, sandboxFlag:true, externalApprovalHook:false}`。
258
+
259
+ **未核实项(不要当事实)**:`item.started` 是否总带 `aggregated_output`;`item.updated` 增量语义;
260
+ `command_execution.status` 是否出现 `declined`(Rust 有、TS 无);`web_search`/`file_change`/`todo_list`/`collab_tool_call` 线上形状;
261
+ 以及最关键的 —— **exec 模式无法把审批回调给外部程序**(本机 help 只有 `--approve-for-me` 与 bypass),
262
+ 所以 Codex 要么只通知,要么走 `codex app-server`(experimental)。
263
+
264
+ ### 8.4 真实联调(默认跳过)
265
+
266
+ ```bash
267
+ export DSH_AGENT_E2E=1
268
+ export DSH_AGENT_CLAUDE_BIN=/path/to/@anthropic-ai/claude-code/bin/claude.exe # 必须 ≥ 2.1.318
269
+ node clients/dsh-remote/agent/agent-e2e-real.mjs
270
+
271
+ # 只跑某一个用例(省额度):allow | deny | timeout
272
+ DSH_AGENT_E2E_CASES=deny node clients/dsh-remote/agent/agent-e2e-real.mjs
273
+ # 保留现场 / 落原始流以便排障
274
+ DSH_AGENT_E2E_KEEP=1 DSH_AGENT_E2E_DUMP_RAW=1 node clients/dsh-remote/agent/agent-e2e-real.mjs
275
+ ```
276
+
277
+ - 退出码:`0` 通过 / `1` 断言失败 / `2` 环境不具备(未开启、CLI 版本不够、起不来)。
278
+ - 每次最多 3 次真实 `claude -p`(脚本硬上限 6 次)。
279
+ - 每个用例都在**临时项目**里写 `permissions.ask` 强制升权,并把探测用的 `homeDir` 指向空目录,
280
+ 避免"用户级 allow 规则吞掉审批"(第 1 周报告 §1.2)与"既有钩子让位"两种干扰。
281
+
282
+ ### 8.5 Gemini CLI(ACP)映射要点
283
+
284
+ ACP 的 stdio 规定**一行一条 JSON-RPC**(官方 transports 原文),所以**直接复用 `createJsonlDriver`**,
285
+ 只用换 `mapLine`(`createGeminiDriver`)。
286
+
287
+ | ACP 消息 | 归一化事件 | 说明 |
288
+ |---|---|---|
289
+ | `session/update` → `agent_message_chunk` | `message.delta(final:false)` | ACP 是流式增量,按 `messageId` 拼 |
290
+ | `session/update` → `agent_thought_chunk` | `status(thinking)` | 只带 `textBytes`,推理内容不落 |
291
+ | `session/update` → `tool_call` | `tool.call` | `kind` 直接用 ACP 词表;`title`/`rawInput` 不落 |
292
+ | `session/update` → `tool_call_update`(completed/failed) | `tool.result` | `in_progress` 增量忽略(避免重复) |
293
+ | `session/update` → `plan` / `notice` / `usage_update` | `status(plan/notice/usage)` | 其余变体(`available_commands_update` 等)**忽略而非 unknown** |
294
+ | `session/request_permission` | `status(waiting_approval)` | JSON-RPC `id` 即回执 id;只暴露 `optionKinds`,不落 title/命令 |
295
+ | `session/prompt` 响应 `stopReason` | `turn.end` | end_turn→completed / max_tokens→max-tokens / max_turn_requests→max-turns / refusal→refused / cancelled→aborted |
296
+ | JSON-RPC `error` 响应 | `status(error, scope=jsonrpc)` | 精确归属 prompt 需状态机(下一步) |
297
+
298
+ **依据**:ACP v1 的 [prompt-turn](https://agentclientprotocol.com/protocol/v1/prompt-turn)、
299
+ [tool-calls](https://agentclientprotocol.com/protocol/v1/tool-calls)、
300
+ [transports](https://agentclientprotocol.com/protocol/v1/transports);
301
+ 本机 `gemini --help`(`0.31.0`)实测 ACP 开关为 `--experimental-acp`。
302
+
303
+ ### 8.6 OpenCode(HTTP/SSE)映射与 **SSE 桥接**
304
+
305
+ OpenCode 的事件不是 stdout,而是 `GET /event` 的 **SSE**。桥接做法(`attachOpenCodeSse`):
306
+
307
+ ```
308
+ HTTP SSE 流 ──stream.on("data")──▶ createSseParser.push(chunk) // 分帧:
309
+
310
+ 分隔、data: 多行拼接、: 心跳
311
+ └▶ {event, data} ──JSON.stringify(data)──▶ createJsonlDriver.writeLine
312
+ // 之后 seq/统计/sink/unknown 全部复用通用驱动
313
+ ```
314
+
315
+ | SSE 事件(SDK v1.19.5 类型) | 归一化事件 | 说明 |
316
+ |---|---|---|
317
+ | `server.connected` | `status(connected)` | |
318
+ | `session.status`(busy/idle/retry) | `status(working/idle/retrying)` | |
319
+ | `session.idle` | `turn.end(completed)` | 文档语义:空闲=回合结束 |
320
+ | `session.error` | `turn.end(error)` | 带 errorName + message 预览 |
321
+ | `message.part.updated`(text) | `message.delta` | 有 `delta` 用增量(final=false),否则全文(final=true,按 part.id 去重) |
322
+ | `message.part.updated`(reasoning) | `status(thinking)` | 只带 `textBytes` |
323
+ | `message.part.updated`(tool) | `tool.call` / `tool.result` | state.status pending/running→call;completed/error→result;input/output 只落字节数/截断预览 |
324
+ | `permission.updated` | `status(waiting_approval)` | **不落 title/pattern**(可能含命令);真回执走 `POST /session/:id/permissions/:permissionID` |
325
+ | `permission.replied` | `status(approval_replied)` | |
326
+ | 其余(`message.updated`/`session.created`/`file.edited`/`todo.updated`/`pty.*`…) | 忽略 | 明确列在 `OPENCODE_IGNORED_EVENTS`,不计 unknown |
327
+
328
+ **依据**:`@opencode-ai/sdk@1.19.5` 的 `dist/gen/types.gen.d.ts`(官方发布产物,逐字对照 `Event`/`Part`/`ToolState`/`Permission`)
329
+ + [opencode.ai/docs/server](https://opencode.ai/docs/server/)。
330
+
331
+ ### 8.7 Codex 真实校准脚本(默认跳过)
332
+
333
+ ```bash
334
+ export DSH_AGENT_E2E_CODEX=1
335
+ node clients/dsh-remote/agent/agent-e2e-codex.mjs # 恰好 1 次 `codex exec --json`(只读沙箱)
336
+ ```
337
+ - 断言:真实流喂 `createCodexDriver` 后 **unknown=0 / badJson=0 / 有 turn 边界**;
338
+ - 退出码:`0` 映射通过(**即使 CLI 因账号/模型配置退出非 0**,只要流合法就算校准成功)、`1` 断言失败、`2` 环境不具备;
339
+ - 本机首次实测:CLI 退出 1(`gpt-6.1-sol` 不被当前 ChatGPT 账号支持),但 6 行流 `unknown=0`,
340
+ 并暴露两处差异(已在 `codex.mjs` 修掉/记录);正常路径待修好 model 后再跑一次。
341
+
342
+ ## 9. 接入清单:四家 × 能力位 × 审批通道 × 下一步
343
+
344
+ > 这是本轮"驱动可复用"的验收口径:Codex 接入**没有**改 events 词表、没有改审批桥、没有改 index。
345
+
346
+ ### 9.1 四家能力表(2026-11 实测/官方依据)
347
+
348
+ | Agent | 驱动 | 结构化输出 | 审批通道 | 需要外部审批通道? | 当前接入状态 | 下一步验证动作 |
349
+ |---|---|---|---|---|---|---|
350
+ | **Claude Code** | jsonl(stream-json) | ✅ `--output-format stream-json` | `PermissionRequest` 钩子(http/command) | ✅ 是(钩子→PermissionBridge) | **真实联调 3/3**,`mode:approval` | Windows 真机;版本阈值二分(2.1.195❌/2.1.318✅) |
351
+ | **Codex CLI** | jsonl(`exec --json`) | ✅ `--json` / `--output-schema` | ❌ exec 无外部审批开关(只有 `--approve-for-me` 自动审查/bypass);审批在 `app-server`(experimental) | **exec 不需要**(只通知);要审批需改接 app-server | 骨架 + **真实校准 1 次**(unknown=0,仅错误路径),`mode:notify-only` | 修本机 model 后跑 `agent-e2e-codex.mjs` 校准正常路径;实测 `codex app-server` 的审批请求形状 |
352
+ | **Gemini CLI** | ACP(JSON-RPC over stdio,一行一条) | ✅ 官方 `--acp`(本机 0.31.0 为 `--experimental-acp`) | `session/request_permission`(**协议内双向请求**,我们作为 ACP client 必须回 outcome) | ❌ 不需要外部钩子(审批在协议内) | 骨架 + 本机探测通过(`mode:approval`,`approvalChannel:acp-jsonrpc`),**未真实握手** | `gemini --experimental-acp` 握手:`initialize` 版本/能力协商、`request_permission` 回写、fs 代理显式拒绝 |
353
+ | **OpenCode** | HTTP + SSE | ✅ `opencode serve` + `GET /event` | `permission.updated` 事件 + `POST /session/:id/permissions/:permissionID` | ❌ 不需要外部钩子(HTTP 端点直接答) | 骨架 + **SSE 桥接实现**,本机未安装(探测 SKIP) | 装 `opencode` 后:`serve` 认证、SSE 真实事件形状、permission response 枚举、`prompt_async` 时序 |
354
+
355
+ ### 9.2 接第二家要改的 3 处
356
+
357
+ | # | 改什么 | 位置 | 本轮实例 |
358
+ |---|---|---|---|
359
+ | ① **mapLine** | 该 CLI 的结构化输出 → 5 类事件 | `agent/<harness>.mjs` | `mapCodexObject` / `mapGeminiObject` / `mapOpenCodeObject` |
360
+ | ② **CLI 探测 + 能力声明** | 找 CLI、`--version`、从 `--help` 探能力位(**探不到就 false,不许默认 true**) | 同文件 `detectXxx()` | `detectCodex` / `detectGemini` / `detectOpenCode` |
361
+ | ③ **传输形态差异** | stdout 行 / SSE / JSON-RPC 的"最后一公里" | 同文件 | ACP 与 jsonl **同构**(直接复用驱动);OpenCode 需 `createSseParser`+`attachOpenCodeSse` 桥接 |
362
+
363
+ ### 9.3 **不许动**的 2 处(动了就说明抽象漏了)
364
+
365
+ | # | 不动什么 | 为什么 |
366
+ |---|---|---|
367
+ | ① **事件词表 + 通用驱动 + sink**(`EVENT_TYPES` / `createJsonlDriver` / `createJsonlSink` / 隐私规则) | 手机端/微信/审计都按这 5 类消费;每接一家改一次 = 每个下游都要跟着改 |
368
+ | ② **审批链路**(`index.mjs` 的 `PermissionBridge` 接线、fail-closed、让位逻辑) | 安全语义只有一套。做不到外部审批的 harness 就明确 `mode:"notify-only"`(Codex exec 是典型),不自己发明一套 |
369
+
370
+ ### 9.4 Gemini ACP 下一步要验什么
371
+
372
+ 1. 本机 0.31.0 的 ACP 代际(文档有 v1/v2)与 `initialize` 能力协商字段;`--experimental-acp` 是否等同 v1。
373
+ 2. `session/new` + `prompt` 时序;`request_permission` 回写 `{outcome:{outcome:"selected",optionId}}` 与 `cancelled` 语义。
374
+ 3. `tool_call_update` 的真实 `status` 序列(pending→in_progress→completed/failed)是否与文档一致。
375
+ 4. **显式拒绝 fs 代理 capability**(否则文件读写要经客户端,行为不可控)。
376
+ 5. `session/cancel` 的落点;会话列表能否通过 ACP 拿到(拿不到就自建 SessionStore)。
377
+ 6. 验收:ACP 驱动产出的 5 类事件与 Claude 同构(同一手机端页面可渲染)。
378
+
379
+ ### 9.5 OpenCode 下一步要验什么
380
+
381
+ 1. `opencode serve` 绑定/认证(`OPENCODE_SERVER_PASSWORD`、默认 127.0.0.1:4096)。
382
+ 2. `GET /event` 真实 SSE:是否带 `event:` 行、心跳格式、`/event` vs `/global/event` 的包装差异。
383
+ 3. `permission.updated` 的 `pattern`/`title` 真实内容(决定审批卡能显示什么;当前为隐私一律不落)。
384
+ 4. `POST /session/:id/permissions/:permissionID` 的 `response` 枚举与 `remember`。
385
+ 5. `prompt_async` 时序(立即 204,结果只从事件流看)与 `session.idle` 是否等价回合结束。
386
+ 6. 验收:同一条任务在 OpenCode 下事件序列与 Claude 一致(status→tool→turn.end)。
387
+
388
+ ## 10. 测试
389
+
390
+ ```bash
391
+ # 本目录相关用例(8 个文件)
392
+ DSH_PROFILE_DIR= NODE_OPTIONS="--require $PWD/scripts/test-net-guard.cjs" \
393
+ node --test --test-concurrency=1 \
394
+ clients/dsh-remote/test/agent-version.test.mjs \
395
+ clients/dsh-remote/test/agent-detect.test.mjs \
396
+ clients/dsh-remote/test/agent-permission-bridge.test.mjs \
397
+ clients/dsh-remote/test/agent-proc.test.mjs \
398
+ clients/dsh-remote/test/agent-runtime.test.mjs \
399
+ clients/dsh-remote/test/agent-events.test.mjs \
400
+ clients/dsh-remote/test/agent-jsonl-driver.test.mjs \
401
+ clients/dsh-remote/test/agent-codex.test.mjs \
402
+ clients/dsh-remote/test/agent-gemini.test.mjs \
403
+ clients/dsh-remote/test/agent-opencode.test.mjs
404
+ ```
405
+
406
+ Codex 真实校准(默认跳过,**恰好 1 次**真实调用):
407
+ `DSH_AGENT_E2E_CODEX=1 node clients/dsh-remote/agent/agent-e2e-codex.mjs`
408
+
409
+ (真实跑请用仓库标准入口:`npm run test:bridge` —— 它带测试期网络护栏,只允许打本地。)
410
+
411
+ > 注:`npm run check` 的检查清单是**显式文件列表**,本轮新增文件不在其中(未改 `package.json`,
412
+ > 避免与其它线冲突);新增文件已逐个 `node --check` 通过,见交付说明。
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * **Codex 事件映射真实校准**(M2 第 5 周)——默认跳过,只有显式开启才跑。
4
+ *
5
+ * 跑**恰好 1 次** `codex exec --json`(只读沙箱,不调模型工具、不改文件),把真实 stdout 喂给
6
+ * `createCodexDriver`,断言映射器能吃下真实流(`unknown=0`),并打印实测与官方文档样本的差异线索。
7
+ *
8
+ * ## 用法
9
+ *
10
+ * ```bash
11
+ * export DSH_AGENT_E2E_CODEX=1
12
+ * node clients/dsh-remote/agent/agent-e2e-codex.mjs
13
+ * ```
14
+ *
15
+ * 退出码:`0` 断言通过(**即使 CLI 本身因账号/模型配置退出非 0**——只要流是合法 JSONL 就算校准成功);
16
+ * `1` 映射断言失败;`2` 环境不具备(未开启 / 找不到 codex CLI)。
17
+ *
18
+ * ## 为什么"CLI 失败"也可能算校准成功
19
+ *
20
+ * 首次实测(本机 0.150.1)CLI 退出码 1:本机 codex 配置的模型 `gpt-6.1-sol` 与当前 ChatGPT 账号不兼容
21
+ * (400 model_not_found)。但 stdout 仍是 6 行合法 JSONL,且暴露了两个**真实差异**
22
+ * (error 类 item 早于 turn.started、error.message 是嵌套 JSON 字符串)——那正是校准要拿到的东西。
23
+ * 所以脚本把"CLI 退出码"和"事件流可映射"分开判定,并明确提示下一步(先修 codex model 才能校准正常路径)。
24
+ *
25
+ * ## 隐私
26
+ *
27
+ * 不打印账号/token/网关配置;不打印命令原文(prompt 是我们自己写的 "Reply with exactly: OK")。
28
+ */
29
+
30
+ import fsSync from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import readline from "node:readline";
34
+
35
+ import { createCodexDriver } from "./codex.mjs";
36
+ import { detectCodex } from "./codex.mjs";
37
+ import { spawnAgentProcess, terminateProcessTree } from "./proc.mjs";
38
+
39
+ /** 硬上限:本脚本**只允许 1 次**真实调用(任务约束)。 */
40
+ const MAX_CLAUDE_CALLS = 1;
41
+ const CASE_TIMEOUT_MS = 180_000;
42
+ const PROMPT = "Reply with exactly: OK";
43
+
44
+ const t0 = Date.now();
45
+ const out = (m = "") => process.stdout.write(m + "\n");
46
+ const elapsed = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
47
+
48
+ async function main() {
49
+ out("=== agent-e2e-codex:真实 `codex exec --json` → 事件映射校准(默认跳过) ===");
50
+
51
+ if (String(process.env.DSH_AGENT_E2E_CODEX || "") !== "1") {
52
+ out("[codex-e2e][SKIP] 未设置 DSH_AGENT_E2E_CODEX=1 —— 默认跳过(避免 CI 误触 / 误耗额度)。");
53
+ process.exit(0);
54
+ }
55
+
56
+ // 1) 探测 CLI(只跑 --version / exec --help,不调模型)
57
+ const bin = String(process.env.DSH_AGENT_CODEX_BIN || "").trim();
58
+ const det = detectCodex({
59
+ env: bin ? { ...process.env, DSH_AGENT_CODEX_BIN: bin } : process.env,
60
+ npmRoot: process.env.DSH_AGENT_NPM_ROOT || undefined
61
+ });
62
+ out(`[codex-e2e] CLI: ${det.cli?.path || "(未找到)"} version=${det.version?.text || "?"} caps.json=${det.caps?.jsonOutput}`);
63
+ if (!det.ok) {
64
+ out(`[codex-e2e][SKIP] 能力探测未通过:status=${det.status} ${det.reason || ""}`);
65
+ process.exit(2);
66
+ }
67
+
68
+ // 2) 起真实 codex(只读沙箱;1 次)
69
+ const scratch = fsSync.mkdtempSync(path.join(os.tmpdir(), "dsh-codex-e2e-"));
70
+ const rawFile = path.join(scratch, "raw.jsonl");
71
+ let child = null;
72
+ let callCount = 0;
73
+ try {
74
+ if (callCount >= MAX_CLAUDE_CALLS) throw new Error("调用次数超限");
75
+ callCount += 1;
76
+
77
+ const args = [
78
+ ...(det.cli.argsPrefix || []),
79
+ "exec",
80
+ "--json",
81
+ "--sandbox",
82
+ "read-only", // 只读:绝不让它改文件
83
+ "--skip-git-repo-check", // 临时目录不是 git 仓库
84
+ PROMPT
85
+ ];
86
+ out(`[codex-e2e] 起 codex(第 ${callCount}/${MAX_CLAUDE_CALLS} 次,只读沙箱)…`);
87
+ const proc = spawnAgentProcess(
88
+ { command: det.cli.command, args, cwd: scratch, env: process.env },
89
+ { platform: process.platform }
90
+ );
91
+ child = proc.child;
92
+
93
+ // 3) 真实 stdout → 驱动
94
+ const driver = createCodexDriver({ now: () => Date.now() });
95
+ const events = [];
96
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
97
+ const rlClosed = new Promise((res) => rl.once("close", res));
98
+ rl.on("line", (line) => {
99
+ try {
100
+ events.push(...driver.writeLine(line));
101
+ } catch {
102
+ /* 驱动本身不抛,双保险 */
103
+ }
104
+ });
105
+ let stderr = "";
106
+ child.stderr.on("data", (b) => {
107
+ if (stderr.length < 2048) stderr += String(b);
108
+ });
109
+
110
+ const exit = await new Promise((resolve) => {
111
+ const timer = setTimeout(() => resolve({ timedOut: true }), CASE_TIMEOUT_MS);
112
+ child.once("exit", (code, signal) => {
113
+ clearTimeout(timer);
114
+ resolve({ timedOut: false, code, signal });
115
+ });
116
+ });
117
+ if (exit.timedOut) await terminateProcessTree(child.pid, { platform: process.platform, graceMs: 1500 }).catch(() => {});
118
+ await rlClosed;
119
+
120
+ const stats = driver.stats();
121
+ const byType = stats.byType || {};
122
+ const turnReasons = stats.byReason || {};
123
+ const sawNormalPath = Boolean(byType["message.delta"] || byType["tool.call"]);
124
+
125
+ out("");
126
+ out("=== 校准结果 ===");
127
+ out(`CLI 退出: ${exit.timedOut ? "TIMEOUT" : `code=${exit.code} signal=${exit.signal || "-"}`}(exit!=0 不一定是失败,见文件头)`);
128
+ out(`行数=${stats.lines} 事件=${stats.events} unknown=${stats.unknown} badJson=${stats.badJson} tooLong=${stats.tooLong} mapperErrors=${stats.mapperErrors}`);
129
+ out(`事件分布: ${JSON.stringify(byType)}`);
130
+ out(`turn.end reason: ${JSON.stringify(turnReasons)}`);
131
+ out(`覆盖到的路径: ${sawNormalPath ? "正常路径(有 message.delta / tool.call)" : "仅错误路径(无 agent_message / tool_call)"}`);
132
+ if (stats.unknownReasons && Object.keys(stats.unknownReasons).length) {
133
+ out(`未识别原因: ${JSON.stringify(stats.unknownReasons)}`);
134
+ }
135
+
136
+ const problems = [];
137
+ if (stats.unknown !== 0) problems.push(`unknown=${stats.unknown}(映射器有未覆盖的真实事件)`);
138
+ if (stats.badJson !== 0) problems.push(`badJson=${stats.badJson}(真实流里有非 JSON 行)`);
139
+ if (stats.mapperErrors !== 0) problems.push(`mapperErrors=${stats.mapperErrors}`);
140
+ if (stats.events === 0) problems.push("一条事件都没映射出来(流是空的?)");
141
+ if (!(turnReasons.completed || turnReasons.error || turnReasons["max-turns"])) {
142
+ problems.push("没有映射出 turn.end/turn.failed 边界");
143
+ }
144
+
145
+ if (!sawNormalPath) {
146
+ out("");
147
+ out("[codex-e2e][提示] 本次只走到错误路径(常见原因:本机 codex 的 model 与账号不兼容)。");
148
+ out(" 下一步:先修 `~/.codex/config.toml` 的 model(或换 API key),再跑本脚本校准正常路径。");
149
+ if (stderr.trim()) out(` stderr 尾: ${stderr.trim().split("\n").slice(-2).join(" | ").slice(0, 300)}`);
150
+ }
151
+
152
+ if (problems.length) {
153
+ out("");
154
+ out(`[codex-e2e][FAIL] ${problems.join(";")}`);
155
+ process.exitCode = 1;
156
+ } else {
157
+ out("");
158
+ out(`[codex-e2e][OK] 真实流可完全映射(unknown=0);用时 ${elapsed()};临时目录 ${scratch}`);
159
+ process.exitCode = 0;
160
+ }
161
+
162
+ if (String(process.env.DSH_AGENT_E2E_CODEX_KEEP || "") !== "1") {
163
+ try {
164
+ fsSync.rmSync(scratch, { recursive: true, force: true });
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ } else {
169
+ out(`[codex-e2e] 保留临时目录(DSH_AGENT_E2E_CODEX_KEEP=1): ${scratch}`);
170
+ }
171
+ } finally {
172
+ if (child) {
173
+ try {
174
+ await terminateProcessTree(child.pid, { platform: process.platform, graceMs: 1200 });
175
+ } catch {
176
+ /* ignore */
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ main().catch((err) => {
183
+ out(`[codex-e2e][FAIL] 未捕获异常: ${String(err?.message || err)}`);
184
+ process.exit(1);
185
+ });