# 03 引擎接入 已定:Claude 用 **Agent SDK**(ADR-0002),Codex 用 **`codex app-server`**(ADR-0003)。 ## 1. 方案比较(选型依据) ### 1.1 Claude Code | 维度 | **Agent SDK(采用)** | CLI `claude -p --output-format stream-json` | |---|---|---| | 集成形态 | TS 库,内部拉起 CLI,返回类型化消息流 | 自行 spawn,逐行解析 JSON | | 交互审批 | `canUseTool` 异步回调,可挂起等待人工 | 只能借 `--permission-prompt-tool` 绕道 MCP | | Agent 提问 | AskUserQuestion 同样经回调,可转成前端表单 | 难以处理 | | 自定义工具 | 进程内 MCP(`createSdkMcpServer`) | 需独立 MCP Server | | 恢复/中断 | `resume` / `interrupt()` | resume 可,中断只能杀进程 | | 配置隔离 | 默认不加载用户本地 settings,行为可预测 | 读取本机配置 | | 图片输入 | 流式输入模式下可发 image content block | 需 stream-json 输入,自行组装 | | 缺点 | 与 SDK 版本耦合 | 一切自建 | ### 1.2 Codex | 维度 | Codex SDK(TS) | **app-server(采用)** | `codex exec --json` | |---|---|---|---| | 形态 | `codex exec` 轻封装 | JSON-RPC over stdio(VS Code 插件同款协议) | 一次性命令 | | 交互审批 | ✗ | ✓ 服务端向客户端发起审批请求 | ✗ | | 流式粒度 | item 级 | item + delta 级 | item 级 | | 多轮/恢复/中断 | ✓ / ✓ / 有限 | ✓ / ✓ / ✓ | 有限 | | 账户/限额查询 | ✗ | ✓(账户与登录相关方法) | ✗ | | 类型 | 自带 | `codex app-server generate-ts` 生成 | 无 | | 缺点 | 无审批 | 协议仍标注 experimental,需锁版本 | 无审批 | **结论**:交互式审批是硬需求,只有 SDK(Claude)和 app-server(Codex)能原生满足。 ## 2. ClaudeAdapter 设计 ### 2.1 调用方式 - 使用 **流式输入模式**(`prompt` 传 AsyncIterable),以便发送图片与在 Run 内追加输入。 - 关键 Options: - `cwd`: 工作区目录 - `resume`: `session.providerSessionId`(首轮为空) - `model`、`systemPrompt`(`{ type: 'preset', preset: 'claude_code', append }`,保留内置工具说明并追加 Profile 提示词) - `permissionMode`: `default`(所有非只读操作经 `canUseTool`) - `canUseTool`: 桥接到 `ctx.interact`(见 2.3) - `allowedTools` / `disallowedTools`: 来自 Profile - `additionalDirectories`: 资料库只读目录 - `mcpServers`: `{ gateway: { type: 'http', url, headers: { Authorization: 'Bearer ' } } }` - `settingSources`: `[]`(不加载个人的 settings、插件、hooks);沙箱与权限规则由文件访问策略生成,通过 `settings` 选项显式传入(见 06 §4.2) - `pathToClaudeCodeExecutable`: 后台设定的 CLI 路径(默认 `~/.local/bin/claude`),与终端使用同一个 CLI 和登录态 - `env`: 使用默认的 `~/.claude` 登录态;**不传** `ANTHROPIC_API_KEY`;设置 `MATPLOTLIBRC` 和 Python venv 的 `PATH` - `includePartialMessages: true`:获取文本增量 - `abortController`: 绑定 `ctx.signal` - 首条 `system/init` 消息中取 `session_id` 回写 `session.providerSessionId`。 ### 2.2 消息 → 事件映射 | SDK 消息 | AgentEvent | |---|---| | `system` (init) | `run.started`(附模型、工具清单) | | `stream_event` text delta | `message.delta` | | `stream_event` thinking delta | `reasoning.delta` | | `assistant` text block | `message.completed` | | `assistant` tool_use block | `tool.started` | | `user` tool_result block | `tool.completed`(Write/Edit 产出文件额外发 `file.changed`) | | `result` success | `usage` + `run.completed` | | `result` error_* | `run.failed` | ### 2.3 交互桥接 ```ts canUseTool: async (toolName, input, { signal, suggestions }) => { if (toolName === 'AskUserQuestion') return answerQuestion(await ctx.interact({ kind: 'question', questions: input.questions })) if (policy.autoAllow(toolName, input)) return { behavior: 'allow', updatedInput: input } const r = await ctx.interact({ kind: 'approval', tool: toolName, input, suggestions }) return r.decision === 'allow' ? { behavior: 'allow', updatedInput: r.updatedInput ?? input } : { behavior: 'deny', message: r.reason ?? '用户拒绝' } } ``` - `policy.autoAllow`:只读工具(Read/Grep/Glob/WebSearch)、工作区内写入、沙箱内的 Bash 自动放行;"总是允许"的选择记入会话级规则。 - 实现时需核对当前 SDK 版本中 AskUserQuestion 的回答格式。 ## 3. CodexAdapter 设计 ### 3.1 进程管理 - 单个常驻进程:`codex app-server`(stdio),使用默认的 `~/.codex` 登录态。 - **必须覆盖个人配置**:你的 `~/.codex/config.toml` 中启用了插件(browser、computer-use 等)和 MCP(node_repl)。启动时通过 `-c` 覆盖禁用这些项(`providers.codex.configOverrides`,在后台可编辑),并在启动自检中确认最终生效的工具清单里不含这些项。 - 启动后执行 `initialize` 握手;崩溃后指数退避重启。 - 协议类型由 `codex app-server generate-ts` 生成,提交到 `apps/server/src/providers/codex/protocol/`,并与锁定的 CLI 版本对应。 ### 3.2 会话映射 | AgentServer | app-server | |---|---| | Session(首轮) | 新建 thread(指定 cwd、model、sandbox、approval policy) | | Session(续聊) | 恢复 thread(`providerSessionId` = threadId) | | Run | 一个 turn(输入:文本 + 本地图片路径) | | interrupt | 中断当前 turn | Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使用**权限配置**(permission profiles,由文件访问策略生成,见 06 §4.2),不再设置 `sandbox_mode`。MCP Gateway 通过 `-c mcp_servers.gateway...` 注入。 > 权限配置能否按 thread 指定(不同 Profile 使用不同策略)需要在实现时核对;如果只能在进程级配置,就在启动时预先注册所有 Profile 的权限配置(`permissions..*`),按 thread 选择使用哪一个。 > 具体方法名以生成的协议类型为准,实现阶段落定后回填本节。 ### 3.3 通知 → 事件映射 | app-server 通知 / 请求 | AgentEvent | |---|---| | turn 开始 | `run.started` | | agent message delta / item 完成 | `message.delta` / `message.completed` | | reasoning delta | `reasoning.delta` | | 命令执行 item 开始/完成 | `tool.started` / `tool.completed`(name=`shell`) | | 文件变更 item | `tool.*` + `file.changed` | | MCP 工具调用 item | `tool.*`(name=`mcp__gateway__xxx`) | | web search item | `tool.*`(name=`web_search`) | | token 用量通知 | `usage` | | turn 完成 / 失败 | `run.completed` / `run.failed` | | **服务端请求**:命令执行审批 / 文件变更审批 | `ctx.interact({kind:'approval'})` → 回复 accept / decline(含"本会话总是允许") | ### 3.4 实现记录(M2,2026-10-23,codex 0.164.0) - **版本与部署**:`@openai/codex@0.164.0` 作为服务端依赖锁定,随 pnpm deploy 发布;协议类型由该版本 `codex app-server generate-ts` 生成,位于 `apps/server/src/providers/codex/protocol/`(升级 codex 时重新生成) - **CODEX_HOME**:服务专用(生产 `/srv/agent-server/home/.codex`,开发 `.dev-data/home/.codex`),不加载个人 `~/.codex` 的插件与 MCP(Q3 关闭);`auth.json` 列入全局禁止项 - **进程**:单个常驻 app-server,JSON-RPC 每行一个 JSON;按 threadId 把通知和服务端请求分发给对应的 Run;进程退出时正在运行的 Run 失败,下次调用自动重启 - **会话**:首轮 `thread/start`,之后 `thread/resume`(失败则新建);每个 Run 一个 `turn/start`,中断用 `turn/interrupt` - **文件访问策略**(Q1 关闭):每次 thread/start / resume 通过 `config` 传入动态权限配置 `default_permissions = "agent_server"` + `permissions.agent_server.filesystem`(受限模式 `":root" = "deny"`),响应中 `activePermissionProfile` 确认生效。**必须**把 `CODEX_HOME/tmp/arg0-linux` 和 codex 二进制目录设为可读,否则沙箱辅助程序无法启动;`/tmp` 不可写,`TMPDIR` 指向工作区 `.agent/tmp` - **环境变量**:`shell_environment_policy.inherit = "core"`,只继承基础变量,再设置 Python/Node 运行环境变量,不把服务进程的密钥带入命令 - **权限模式**:需审批 → `approvalPolicy = "on-request"`(沙箱内命令自动执行,越界才请求审批,与 Claude 的"每条命令都询问"不同);自动 / 只读 → `"never"`;只读模式工作区为 read - **审批**(Q2 关闭):`item/commandExecution/requestApproval` / `item/fileChange/requestApproval` 回复 `accept | acceptForSession | decline | cancel`;**命令联网请求一律拒绝并审计**(Codex 命令联网后不受域名限制);文件变更与 `item/permissions/requestApproval` 先按访问策略检查,越界自动拒绝 - **提问**:`item/tool/requestUserInput` 映射为提问卡片,回复 `{ answers: { [questionId]: { answers: [...] } } }` - **用量**:`thread/tokenUsage/updated` 给出线程累计与最近一次增量,本次 Run 用量 = 最新累计 −(首次通知的累计 − 首次增量) - **登录**:`account/login/start { type: "chatgptDeviceCode" }` 返回网址和短码,管理员在 OpenAI 页面输入,完成后收到 `account/login/completed`;AgentServer 不接触凭证。终端方式:`agent-codex login --device-auth` - **已知差距**:LibreOffice 在 Codex 沙箱中同样无法运行(禁止 Unix socket),`convert_document` / `recalc_workbook` 将在 MCP Gateway(M2 ②)中提供给 Codex;在此之前提示词会告知模型这两项不可用 ## 4. 能力差异与对齐 | 能力 | Claude | Codex | 对齐策略 | |---|---|---|---| | 主动提问 | ✓ AskUserQuestion | 无专门工具 | Codex 侧以普通文本提问,前端照常渲染 | | 图片输入 | ✓ | ✓ | 统一为上传后引用 | | PDF 原生阅读 | ✓(Read 工具多模态) | ✗(需 pdftotext 等) | Profile 中为 Codex 默认附带文档技能 | | 网页抓取 | WebFetch(本机发起) | 无内置抓取,靠 web_search | 见 07 §4 | | 思考过程 | ✓ | ✓(reasoning summary) | 统一 `reasoning.delta`,默认折叠 | | Skills | ✓ | ✓ | 共享同一份技能目录(见 07 §3) | ## 5. 版本管理 - `claude`、`codex` CLI 版本与 SDK 版本在 `apps/server/package.json` 和部署脚本中锁定。 - 升级流程:升级 → 重新生成 Codex 协议类型 → 运行 Playground 回归用例(见 09 §3.6)→ 发布。 ## 6. 实现记录(M1,2026-10-22,SDK 0.3.308 / CLI 2.1.307) 实测中确认、并已体现在代码里的行为: | 现象 | 处理 | |---|---| | `canUseTool` **不会**对只读工具(Read/Grep/Glob)和沙箱内自动放行的 Bash 触发 | 路径校验、私网拦截和是否需要审批统一放在 `PreToolUse` hook 中决定(`providers/claude/policy.ts`);hook 返回 `ask` 时 CLI 才调用 `canUseTool`,再转成交互 | | hook 不显式返回 `allow` 时,MCP 工具仍会进入权限询问 | hook 对放行的工具显式返回 `permissionDecision: 'allow'` | | AskUserQuestion 的回答格式 | `canUseTool` 返回 `allow`,`updatedInput.answers` 以**问题原文**为键,多选用逗号拼接 | | `settingSources: []` 不能阻止加载用户自己的技能(包括 claude.ai 同步的技能) | 始终显式传入 `skills` 列表(来自共享技能目录,形如 `agent-skills:`);目录为空时传 `[]` | | 覆盖 `TMPDIR` 到工作区后,沙箱报 "Failed to create bridge sockets" | 沙箱会在 TMPDIR 中创建 Unix socket,路径过长会失败;不覆盖 TMPDIR | | 单条消息的流式输入在输出 `result` 后正常结束 | 每个 Run 调用一次 `query()` 并用 `resume` 续接,无需常驻进程 | | 登录态 | `system/init` 中 `apiKeySource: none` 表示走 claude.ai OAuth;服务环境中删除 `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | | 按路径加载插件(`plugins: [{ type: 'local', path }]`)时,没有 `.claude-plugin/plugin.json` 的插件以**目录名**作为技能前缀,并加载 `skills/` 下的**全部**技能(官方仓库的 document-skills 实际是 `dbd4588f9e10:xlsx` 等,并会带上属于 example-skills 的 15 个技能) | 读取市场清单 `marketplace.json` 中为该插件声明的技能子集,只展示和启用这些;后台与 Profile 使用友好 ID(`document-skills:xlsx`),运行时翻译为 CLI 的 ID,时间线显示时再换回来(2026-10-23) | | `skills: 'all'` 会包含 CLI 自带的捆绑技能(deep-research、dataviz、code-review 等) | 始终传显式列表,只包含本地技能目录和已启用插件声明的技能 | 服务端提供的进程内 MCP 工具:`mcp__office__convert_document`(LibreOffice 文档转换,见 06 §8)。M2 起会迁移到 MCP Gateway,供 Codex 共用。