只留用户原话和近期工具痕迹。合成脚手架、普通 developer 消息丢掉。超长内容打上 truncated 标记。
新功能先找落脚的 crate,core 是最后一档
打开仓库,第一反应是往 core 里加。仓库把这件事写成禁令:先找现有的非 core crate,否则新建一个。依赖方向会把叶子类型挡在核心之外。
codex-core。依赖方向怎么把它挡在某一层之外,挡住之后正确的落点在哪。这条禁令没有机器红灯,靠什么还在运转。
- workspace members 是一份显式数组,当前 135 个Cargo.toml L3
- 目录叫 core,crate 名叫 codex-coreAGENTS.md L4
- 禁令:resist adding code to codex-coreAGENTS.md L76
- 先问现有的非 core crate 能不能住AGENTS.md L80
- 否则新建 workspace crate,并允许重构旧代码AGENTS.md L81
- 评审对不必要地进 core 的 PR 主动挡AGENTS.md L83
- core 已经依赖拆出去的 context-fragmentscore/Cargo.toml L35
- 非机械改动一次不超过 800 行AGENTS.md L127
你要给这台 agent 加一个小功能:每轮对话开头把当前 git 分支名写进模型上下文。打开仓库,第一反应是往 codex-core 里加。session、context、guardian、tools 都在那儿,新文件放进去最省事。依赖表不用改,调用链不用跨 crate。
过了一周,同样的理由又进来三条。一个截断工具输出的 helper,一个模型供应商适配,一个会话恢复的边角。core/src/lib.rs 顶部又多三个 mod。下游只要写 use codex_core,编译图跟着变宽。有人想单独复用上下文片段那个类型,发现它焊在 core 里。要复用就得把整个 core 拉进来,包括沙箱、MCP、Guardian。
仓库把这件事写成禁令。AGENTS.md 用加粗英文写 resist adding code to codex-core。新概念先问现有的非 core crate 能不能住。再问该不该新建一个 workspace crate,并且允许为此重构旧代码。core 是最后一档。评审遇到往 core 里堆功能的 PR,被要求主动挡回去。
出处:AGENTS.md 第 72 至 83 行
禁令写进文件的日期是 2026-03-26。当时 core 已经是最大 crate。立完之后 workspace 成员从 75 个长到 135 个,core 的生产代码却还是涨了。挡的是默认往核心扔,挡不住核心继续长。
清单是一份显式数组。codex-rs/Cargo.toml 的 members 数一遍是 135。目录名和 crate 名要分开看:目录叫 core,包名叫 codex-core,use 的时候写成 codex_core。
出处:codex-rs/Cargo.toml 第 1 至 20 行;AGENTS.md 第 1 至 5 行;codex-rs/core/Cargo.toml 第 1 至 9 行
按 .rs 行数,少数几个吃掉大半体积。core 33 万行,tui 27 万,app-server 14.8 万。大于等于 1 万行的 24 个,小于 2 千的 77 个。core 去掉测试后剩约 10.3 万行。25 个 workspace 成员直接依赖它。tui 的 Cargo.toml 没有这一行,它依赖 codex-app-server-client,再由 client 拉 core。往 core 加一行,直接下游 25 个要重编,tui 仍会被带上。
出处:codex-rs/cli/Cargo.toml 第 41 至 42 行;codex-rs/tui/Cargo.toml 第 30 至 31 行
上帝包的失败模式是这次很小、先放核心。把默认落点写成明文,让评审有权挡,不依赖某种语言。换个语言重写,最小形态仍是这三问:现有包能不能住,该不该新建包,进核心凭什么拆不出去。
依赖方向反了,编译图会从两边一起胀。core 自己已经依赖 61 个 codex-* crate,包括拆出去的 context-fragments 和 features。如果新的上下文类型再写回 core,想复用它的人必须把沙箱和 Guardian 一起拉进来。叶子依赖核心,核心再依赖叶子,边界就没了。
拆出去的 crate 只带自己需要的那一点。context-fragments 的包清单几乎没有业务依赖,只碰 protocol 和一段字符串工具,对外 re-export 两个片段类型和一个 trait。core 可以依赖它,它不依赖 core。git 分支名这种片段走这条路:类型落在 fragments,core 当调用方。模型供应商适配已经抽到 codex-model-provider。必须摸 session 内部状态的东西,才走到最后一档,评审仍要问为什么拆不出去。
出处:codex-rs/context-fragments/src/lib.rs 第 1 至 6 行;codex-rs/core/Cargo.toml 第 26 至 42 行
旁边还有两把尺子。文件目标 500 行,大约超过 800 行就开新模块。非机械改动一次不超过 800 行,复杂逻辑压到 500。三层一起看,针对的是同一件事:人和 AI 都倾向于把改动写大,写进已经很大的文件。
出处:AGENTS.md 第 49 至 61 行;AGENTS.md 第 125 至 131 行
这条禁令本身没有 lint,没有 CI job。仓库里检索 resist adding code to codex-core,只命中 AGENTS.md 这一处。挡得住的是旁边那几条:依赖没刷 Bazel lock,CI 红;include_str! 没改 BUILD.bazel,Bazel 红。core 禁令挡得住习惯,挡不住有理由的例外,也挡不住漏看。
出处:AGENTS.md 第 37 至 43 行
编译图的方向是物理约束。叶子可以独立编译,被多个中心复用。中心一旦吞下叶子类型,复用成本变成拉进整个中心。这个形状换语言也成立。
DSH:能力全是插件,没有特权内核
DSH 根 AGENTS.md 把原则写成加粗英文:everything is a plugin。Cordis 只收服务、类型化事件和可逆副作用。模型适配器、工具注册表、会话日志、agent loop 都是插件。没有需要打补丁的特权内核。新包落进现有分组时,根 package.json 不用改,glob 会发现它。
Codex 用编译期 crate 换掉了这套装卸器,新能力必须改 members 数组、写 BUILD.bazel、重新编译。能下手的位置只剩评审和 CI。评审管该不该进 core,CI 管两份锁有没有一起改。
Grok Build:清单自动生成,靠目录分层
Grok 根 Cargo.toml 第一行写明这份 workspace 是生成的,人应该改各 crate 自己的清单。members 按同一口径数是 79。组织方式写在根 README:pager 是 TUI,shell 是运行时,tools 和 workspace 是领域能力,common / build 是叶子。
它没有写成给评审看的 core 禁令。切分本身被当成地图,膨胀靠组合入口和抽 crate 消化。Codex 多付的是评审文本和双构建锁。
两侧均已核对源码 · 2026-08-22 · Grok · 79 个 Workspace 成员截断工具输出,该落在哪
又来一个小功能:工具返回太长时先截断,再交给模型。它看起来像 helper,放进 core 的 tools 旁边最省事。按刚才的三问推演:现有非 core crate 有没有更合适的家,该不该新建一个只要字符串工具的小包,进 core 会挡住哪一条依赖边。
写下你会挡哪一条边,以及挡住之后正确的落点。如果选最后一档,补一句评审会问什么。
三层 Turn Loop:谁有资格决定继续
你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。
任务壳 RegularTask
值班班长。问这一趟还活着吗。
轮次 run_turn
当班司机。问这一轮还要再采吗。
采样 sampling
检票口。问这一条流结束了吗。
空。插话先坐这里,当前采样看不见。
- 空闲则 spawn RegularTaskturn_input.rs L242
- 任务壳发 TurnStarted 后进 loopregular.rs L76
- 轮次开头按开关排 pendingturn.rs L305
- 采样 run_sampling_requestturn.rs L381
- 工具当时挂上 futurestream_events_utils.rs L326
- Completed 之后再 drainturn.rs L2539
- 重算 needs_follow_upturn.rs L423
- stop hook 带 prompt 则 continueturn.rs L525
- 任务壳再问 has_pending_inputregular.rs L86
- 忙碌则 Steered 写入 pendingturn_input.rs L207
你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」。
忙的时候其实叠了几件事。模型调了工具。你中途补了一句「测试用 pytest」。它写完助手消息后,Stop hook 说还没跑 linter,于是又采了一次样。
这几件事如果塞进同一个 while,就只能靠几个布尔抢出口。谁先检查、谁能打断谁,会变成口头约定。少一层,就少一个干净插口:插话、续跑和流重试会搅在一起。
Codex 拆成三层。任务壳 RegularTask::run 决定这一趟还要不要再开一轮。轮次 run_turn 决定工具续跑、插话和 hook 要不要继续。采样层只把一次模型流收到 Completed。
对外入口 start_or_steer_turn 自己不看会话空不空闲。返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。空闲就 spawn RegularTask,忙碌就 Steered 写入 pending。三层循环从任务壳才开始转。
出处:codex-rs/core/src/codex_thread.rs 第 333 至 344 行 · codex-rs/core/src/session/turn_input.rs 第 1 至 9 行
任务壳发一次 TurnStarted,然后只要队列里还有待处理输入,就再调一次 run_turn。第二次进去时 next_input 为空,新消息从 input_queue 取。turn_id 钉死,界面不会再闪一次「新的一轮开始了」。
出处:codex-rs/core/src/tasks/regular.rs 第 76 至 90 行
轮次层把采样回来的两件事合成一个布尔:model_needs_follow_up || has_pending_input。为真就自己 continue。为假才跑 stop hook。hook 带 prompt 拦收工,这一层自己再转,任务壳和采样层都还没退。
出处:codex-rs/core/src/session/turn.rs 第 423 行 · 第 500 至 525 行
采样层自己还有两圈。外圈处理可重试错误。内圈消费一条 SSE 流。工具调用不等 Completed:OutputItemDone 当时就会挂上 future。流收到 Completed,先 drain_in_flight,再把结果交回 run_turn。
出处:codex-rs/core/src/stream_events_utils.rs 第 326 至 327 行 · codex-rs/core/src/session/turn.rs 第 2539 至 2584 行 · 第 2749 行
三层按「谁有资格决定继续」切开。采样层只看见这一次流,能重试,不能收整轮。轮次层看见工具、pending、预算和 hook,能续采样,不能重发 TurnStarted。任务壳看见任务还在、队列里是否还有活。
这不随文件怎么拆而变。换个语言重写,该问的还是三个问题:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。
RegularTask 和 run_turn 都看 pending。同一句用户话如果两处都取,会转两圈。如果只留一处,就会把 hook 和后到消息挤进同一个出口。
两处问的是两件不同的事。
轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。
去掉轮次层那一问:模型写出最终答案后,run_turn 会去跑 stop hook 并 break,中途那句「测试用 pytest」只能等任务壳再进一次 run_turn。功能上还能补上,只是多一次函数返回,stop hook 会在插话进模型之前先跑一轮。
去掉任务壳那一问:run_turn 因 should_stop 返回后,任务直接结束。队列里后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务。TurnStarted 会再闪一次,回放里变成两个 turn 桶。
stop hook 返回 block 且带 prompt,控制权留在轮次层。采样层早已返回。任务壳还在等这次 run_turn。block 是轮次层内部续跑。stop 是轮次层把控制权交回任务壳。
出处:codex-rs/core/src/session/turn.rs 第 509 至 537 行 · codex-rs/hooks/src/events/stop.rs 第 67 至 74 行
源码没有单独写「为什么要问两次」,下面从实现反推。hook 续跑时控制权留在轮次层,任务壳看不见。hook 放行后,任务壳才有机会接手「收工瞬间又来的那一句」。两个问题发生在不同时刻,所以要问两次。这跟具体语言、具体 hook 协议无关。
DSH:按语义切成 Turn、Step、Inbox
DSH 的外圈是 kick:while (await this.turn()) {}。turn() 自己再套一层 while (true),每一圈先 preStep,再 step()。第三层不是第三条 while。Inbox 是两条数组:next-turn 和 next-step。调用方在入队时选 followup、steer 还是 inject。
两边都叫三层,切分维度不同。DSH 按语义:Turn 是一轮完整工作,Step 是一次模型请求加工具,Inbox 是说话时机。Codex 按生命周期:任务壳问这一趟还活着吗,轮次问这一轮还要再采吗,采样问这一条流结束了吗。DSH 因此能从 session 事件重放两条队列。Codex 因此能把流重试、取消、end_turn 各自关在采样层,TurnStarted 只闪一次。
Claude Code:单层 while 加状态袋
主循环在 query.ts。可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋。needsFollowUp 只由助手消息里的 tool_use 块点亮。没有 follow-up 时,同一层接着做压缩、stop hook、进入下一 turn。turnCount 加一,transition 写成 next_turn,回到 while (true) 顶部。
续跑、压缩、stop hook 收成同一袋状态。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。Codex 把这三件事分给三层,DSH 把插话分给 Inbox,两边都不必在同一个布尔上抢门。
把任务壳那一问改成恒为假
模型开始调工具时,你补了一句「顺便列出当前目录」。先按三层推演:任务壳、轮次、采样各转几圈,这句后续由哪一层取走。
然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id。
while 加三个布尔。
流还在走,工具已经开工
模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。
- SSE 帧先解成通用事件,还没有业务含义responses.rs L164
- kind 是 output_item.done,就产出 OutputItemDoneresponses.rs L352
- 采样循环一到就交给 handle_output_item_doneturn.rs L2384
- 先把 function_call 写入历史和 rolloutstream_events_utils.rs L316
- 再 pin 工具,推进有序队列stream_events_utils.rs L320
- 流结束、断流或取消,都只是离开收流循环turn.rs L2282
- drain 按插入顺序把结果写入历史turn.rs L2135
- 然后才看取消令牌;Stream 可重试turn.rs L2760
你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账。
另一头更常见:模型已经发出两个 function_call,第三个还在路上,SSE 在 response.completed 到来之前关掉。下一次重试该看见空历史,还是已经落盘的调用和结果?
若等 Completed 再写入,提前关流会把已经完整的调用一起扔掉。重试让模型再发一遍同样的调用。若取消时跳过写入,历史只剩半截请求,模型和界面都看见一个没闭合的调用。
OutputItemDone 是解析层把一帧 response.output_item.done 收成的业务事件。它一到,采样循环先把这一条写入会话历史和 rollout,再把工具执行包成 future 挂到有序队列上。取消令牌用子令牌,父令牌一亮,这个工具跟着停。取消来得再快,这一条 function_call 已经进历史。最多再多写一条 aborted by user。
出处:codex-rs/core/src/stream_events_utils.rs 第 190 至 192 行;第 316 至 327 行。类型别名上方的注释把合同写死:完成的模型输出要立刻记下来,后面 turn 被取消,历史和 rollout 也保持同步。
历史只能往上加,不能改写。工具已经读了磁盘,这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。这条合同不依赖 Rust,换 TypeScript 也是先 append 再 push 一个 promise。
出处:AGENTS.md 第 91 至 100 行,Model visible context 第一条:No history rewrite。
模型常常先发出读文件,再继续写一段说明。等收工哨再开工,等于把读文件的延迟和打字的延迟串起来。流内开工能让这两段时间重叠。代价是取消和断流必须认领已经开工的 future。没有认领人,就会出现孤儿任务:工具还在跑,历史对不上。
收流循环无论正常 Completed、提前关流还是 or_cancel,都只是离开 loop。函数还没返回。随后固定调用 drain_in_flight,按插入顺序等到每条 future 给出结果,再写入历史。然后才检查取消令牌。
断流走 Stream,可重试。重试从 clone_history 重建 prompt,已经写下的调用和结果都在。Esc 走 TurnAborted,不可重试。正在跑的工具写出中止文案,drain 把它当普通输出写入。
出处:codex-rs/core/src/session/turn.rs 第 2282 至 2284 行;第 2744 至 2762 行。codex-rs/protocol/src/error.rs 第 88 至 93 行、第 364 至 390 行。
中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。
三个工具可以同时跑。谁先跑完,历史仍按模型发出的顺序写结果。按完成顺序写,同一段会话重放两次可能对不上,prompt cache 也会更脆。有序队列把观测顺序和执行顺序拆开。并发闸门在别的一层,这里只记:挂起顺序就是日后 drain 的顺序。
出处:codex-rs/core/src/session/turn.rs 第 2130 至 2154 行;第 2391 至 2397 行。
Claude Code:默认等流结束,另有一扇流内闸门
默认路径里,流式循环只收集 tool_use,for await 结束后才进入 runTools。流断了只需丢掉已经收集的 block,省掉每个出口都 drain 的局部所有权。代价是工具延迟和打字延迟串行。
streamingToolExecution 打开时,行为靠近 Codex:流内 addTool,立刻开工。失败回退要 discard 已经开工的工具,避免旧 id 漏进重试。Codex 没有对等的 discard,因为它选择先 persist,重试读历史。
DSH:三段瀑布加单调 Guard,管的是谁能拒绝
DSH 的入口是一条已经成型的工具调用。pre / guard / around / post 回答谁能拒绝,拒绝之后结果还在不在。Guard 只有拒绝理由或弃权,没有放行这个选项。它的 drained 是单次 execute 内部的收尾。SSE 还在飞的时候,这套瀑布还没开始。
两边词面相近,出口不同。一边护权限单调,一边护流式 transcript 闭合。把 Guard 搬进 Codex,挡不住断流丢 transcript。把 persist-then-drain 搬进 DSH,也回答不了插件能不能把拒绝改成放行。
两侧均已核对源码 · tools/src/index.ts 第 1 至 4 行、第 703 至 711 行、第 1328 至 1337 行 · DSH · 三段瀑布与单调 Guard把写入和挂起对调
在 handle_output_item_done 的工具分支里,把 record_completed_response_item 和 Box::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?
把答案落到历史只能增量追加,以及 drain_in_flight 的写入时机上。
OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。
中途插话:这句话进本轮、下一轮,还是被拒
Agent 正在改第三个文件,你看见方向偏了,补了一句:配置用 YAML。回车之后,这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。
当前轮
下一轮
门口拒收
- 按 TurnInputMode 分发,默认走 StartOrSteerturn_input.rs L141
- 先试 steer_input,只有 NoActiveTurn 才开工turn_input.rs L195
- Regular 才收,Review 和 Compact 当场拒turn_input.rs L507
- 空闲则 apply_started,再 spawn_taskturn_input.rs L242
- 插话写入 pending,并把相位打回 CurrentTurnturn_input.rs L558
- 最终答案把投递相位 defer 到 NextTurninput_queue.rs L206
- 工具项把相位 accept 回 CurrentTurnstream_events_utils.rs L302
- get_pending_input 看相位,再决定掏不掏信箱input_queue.rs L297
三种日常结局都不好受。立刻打断,前面两个文件的改动可能半成品留在磁盘上。排到下一轮,你只能看着它把剩下的文件按旧方向改完。塞进当前上下文却不叫醒循环,模型要到下一次自己开口才看得到,你补的约束等于迟到。
Codex 把这件事收成一个入口、三种模式。调用方选 StartOrSteer、StartIfIdle 或 Steer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 Started、Steered 或 NotSubmitted。回完决定就结束,不等 user-prompt hook,不等历史落盘,不等模型开始采样。
出处:codex-rs/core/src/session/turn_input.rs 第 1 至 9 行;codex-rs/protocol/src/turn_input.rs 第 127 至 136 行
StartOrSteer 的顺序和函数名一致。先试插话。只有返回 NoActiveTurn,才 apply_started 再 spawn_task。其他拒绝原因原样包装成 NotSubmitted,不会偷偷开工。TUI 实时语音也走这条,和默认入口共用同一套判定。
出处:codex-rs/core/src/session/turn_input.rs 第 141 至 156 行、第 195 至 249 行
设置不能先改再判定。prepare 先预览线程设置,预览失败直接 InvalidRequest。真正写入发生在 apply_started 或 apply_steered。被拒绝的输入连设置都不改。插话成功后只落持久设置,当前轮的 TurnContext 不换。开工专用的选项,比如结构化输出 schema,只在 Started 上用。
出处:codex-rs/core/src/session/turn_input.rs 第 58 至 80 行
开工和插话必须在同一把锁里做完。先看有没有活动轮,再看 kind,再写入 pending,中间不能让另一次提交把轮次换掉。设置却要先预览后写入,因为拒绝路径必须保证线程不变。判定和入队拆成两次加锁,用户回车和子邮件同时到达时,可能出现判定时还空闲、入队时已经有人占坑的窗口。
审查任务自己再开一条 one-shot 子对话,压缩任务在换窗口。用户在这时候补一句「用 YAML」,没有当前轮的工具循环可以接住它。如果因此新开一轮普通对话,审查结果和压缩摘要会跟新对话抢同一个 active_turn。
steer_input 拿着 active_turn 锁做完全部检查。没有活动轮,或者有槽没有 task,都算 NoActiveTurn。TaskKind 只有 Regular、Review、Compact 三个变体。审查和压缩返回 ActiveTurnNotSteerable。StartOrSteer 也不会因此开工。
出处:codex-rs/core/src/session/turn_input.rs 第 507 至 519 行;codex-rs/core/src/state/turn.rs 第 67 至 72 行
检查过关之后,用户输入被推进 pending_input,同时把信箱相位打回 CurrentTurn。穷尽 match 在这里有业务后果:新加一种任务类型,编译器会逼你回答能不能插话。
出处:codex-rs/core/src/session/turn_input.rs 第 546 至 564 行
内部任务有自己的生命周期。把用户插话焊进审查轮,等于让两种工作抢同一条执行槽。调用方收到拒绝,这条输入不会被默默排进一条新对话。换个语言重写,该守的仍是:能接住插话的任务和不能接住的任务,必须在类型上分开。
主 agent 已经在屏幕上打出一段看起来像最终答案的话,子 agent 同时发回一条进度。并进去,用户已经看见的答案会被续写。一律等到下一轮,子 agent 的结果可能要隔一次采样才进模型。
用户插话进的是 turn 内的 pending_input。子 agent 的信走会话级 mailbox_pending_mails。两套存货能不能并进当前轮,由 MailboxDeliveryPhase 决定。相位从 CurrentTurn 起步。用户已经看见最终答案之后切到 NextTurn。用户再插一句,或者模型又发出工具调用,相位会重开。
出处:codex-rs/core/src/state/turn.rs 第 37 至 56 行
切到 NextTurn 有一条例外:pending_input 里只要还有一条不是排队不叫醒的子邮件,就保持当前相位。取信时也看这面牌。NextTurn 时 turn 内 pending 也不拿,会话信箱更不掏。CurrentTurn 时先拿走 turn 内 pending,再掏会话信箱,拼在后面。所以最终答案落地之后,子邮件可以躺在信箱里,循环却认为没有待处理输入,本轮会收束。
出处:codex-rs/core/src/session/input_queue.rs 第 206 至 227 行、第 284 至 336 行
什么算出用户可见的最终答案?助手正文,phase 是 Commentary 的不算,trim 之后为空的也不算。未打标的助手消息按最终答案处理。未打标的提供方默认走更安全的那条:先把信箱关到下一轮。审批、权限、提问、elicitation、动态工具是五张独立的 oneshot 表,不进这套信箱,各等各的回执。
出处:codex-rs/core/src/stream_events_utils.rs 第 486 至 501 行;codex-rs/core/src/state/turn.rs 第 87 至 103 行
「答案已经给用户看过」是一条产品边界,不依赖 Rust 或某个信箱实现。换语言重写,仍然需要一面翻牌:迟到的附属消息默认不续写已经上屏的答案;明确的同轮工作,比如用户再插一句或模型再调工具,再把门打开。
DSH:两个参数,两条轨道
DSH 对外是三个别名,底层共用 send。目标队列和唤不唤醒是两个正交参数。followup 自己独占一轮并叫醒,steer 插进下一站并叫醒,inject 上车不催司机。Inbox 是 next-turn 和 next-step 两条持久列表,claim 先掏空 next-step,目标是 next-turn 时再多取一条。
Codex 没有这三个公开函数。StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌,DSH 可以没有,因为它把等整轮和等下一站写成两条列表。代价是答案已经上屏之后,next-step 非空仍会续命当前 Turn。
Claude Code:一条队列,用优先级补时机
还原源码里,用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级是 now 大于 next 大于 later,同级 FIFO。用户命令默认 next,任务通知默认 later,用户输入不会被系统消息饿死。消费发生在当前流结束之后,没有步级插话。你补的那句 YAML 约束,要等当前生成器收尾才进模型。
答案上屏之后,这封子邮件什么时候被看见
最终答案落地之后,先入队一封 trigger_turn: false 的子邮件,再喂一条 FunctionCall。先在纸上推演 get_pending_input 该返回什么。
对照路径:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能把它掏出来,并进同轮的下一次模型请求。
按下取消之后,各层怎么收手
工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。
- 协议入口是 Interrupt,不杀后台 terminalprotocol.rs L546
- 任务令牌先 cancel,这是信号还不是硬拆tasks/mod.rs L887
- 采样用子令牌盯着流,or_cancel 变成 TurnAbortedturn.rs L2273
- 工具派发再 child 一次,父令牌取消则一起取消stream_events_utils.rs L319
- handler 已走完就保留真实结果,没走完才 abortparallel.rs L182
- 等 100 毫秒,没收完就 task.handle.aborttasks/mod.rs L913
- 追加 turn_aborted 片段,立刻 flush_rollouttasks/mod.rs L927
- 最后才发 EventMsg::TurnAbortedtasks/mod.rs L955
task.handle.abort 没有回切。已经完成的工具回执也不会被改写成 aborted。后台 terminal 继续跑,要杀它走另一条操作。你让 Codex 改一个测试文件。模型先跑 cargo test,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted。很多人第一次写 agent 会这么干:工具返回 Err,整轮跟着死。模型还没看见 stderr,会话已经结束。
分诊发生在工具回到对话的那道门上,一共三层门槛。
最浅一层:进程已经跑过。退出码非零、命令超时、沙箱拒绝,都收成工具回执。success 写成 true,意思是 handler 跑完了,回执可以喂给模型。命令成不成功写在正文里。
出处:codex-rs/core/src/tools/context.rs 第 344 至 353 行
中间一层:调用没做成。参数坏了、进程没拉起来、apply_patch 上下文对不上,走 RespondToModel。回执 success 才是 false。模型读到文案,自己改再试。
最深一层:payload 对不上、任务 join 失败,才写 Fatal,升成 CodexErr,停 turn。
工具层自己的枚举只有两档。RespondToModel 把字符串喂回模型。Fatal 才升成引擎错误。默认把非 Fatal 折成 Ok。想停对话,得写出 Fatal 或 CodexErr。
use thiserror::Error;
/// Error returned while executing a model-visible tool invocation.
#[derive(Debug, Error, PartialEq)]
pub enum FunctionCallError {
#[error("{0}")]
RespondToModel(String),
#[error("Fatal error: {0}")]
Fatal(String),
}
openai/codex,核对文件 codex-rs/tools/src/function_call_error.rs,commit 4f39251a01,核对日期 2026-08-22。这段枚举本身就是分诊合同:两档,没有第三档警告或重试。cargo test 退出码 1 停在最浅一层,连错误枚举的门槛都没迈过。沙箱拒绝同样走 Ok。代理拦请求时给命令进程回 HTTP 403,也停在最浅一层。模型 API 的 403 才是引擎错误。两处 403 不要并成一档。
出处:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 第 384 至 411 行;codex-rs/network-proxy/src/responses.rs 第 76 至 83 行
handler 里一个 IO 错误如果直接冒到 turn 循环,整轮对话跟着死。默认回灌,想停必须显式写出。这条线换语言重写也用得上。自己做内部 agent,先抄这一档:工具失败回灌,引擎失败停循环。
用户按 Esc,要停的不只是当前那次采样。采样可能还在读流,工具可能正在写文件,后台 terminal 可能已经拉起来了。只杀采样,工具会继续改磁盘。一刀切杀掉所有进程,unified exec 的后台任务也会被误杀。
任务启动时现造一张取消令牌。采样请求用 child_token(),工具派发再 child 一次。父令牌一取消,子令牌一起取消。子令牌自己取消,不影响父令牌。这就是取消树。
出处:codex-rs/core/src/session/turn.rs 第 1399 行;codex-rs/core/src/stream_events_utils.rs 第 319 至 324 行
or_cancel 把谁先到写成固定形状。任意 future 配一张令牌。取消先到,返回 CancelErr,一律变成 TurnAborted。
出处:codex-rs/async-utils/src/lib.rs 第 4 至 31 行;codex-rs/protocol/src/error.rs 第 269 至 273 行
用户按 Esc,协议入口是 Op::Interrupt。合同写死:中止当前任务,不杀后台 terminal。handle_task_abort 先 cancel(),再等 100 毫秒。超时就 task.handle.abort()。令牌先发信号,任务有机会自己收尾。收不完,再硬拆。硬拆这一步不可逆。
出处:codex-rs/protocol/src/protocol.rs 第 546 至 548 行;codex-rs/core/src/tasks/mod.rs 第 66 行、第 880 至 913 行
一份用户意图,多层各自收尾。合作式取消加硬期限,是并发系统的通用形状。Python 里用 asyncio.Event 就能做最小版。不必抄 37 个变体的 CodexErr。
取消发生在工具已经改了文件之后,历史里怎么记。如果把已经完成的输出改写成 aborted by user,模型下一轮会以为写入没做成,可能再打一遍补丁。
工具 future 同时等派发结果和令牌。令牌先到,还要看 handler 是否已经走到终态。已经走完,就保留真实结果。没走完,才 abort,再造一条 AbortedToolOutput,正文是 aborted by user after Xs。
出处:codex-rs/core/src/tools/parallel.rs 第 177 至 206 行、第 243 至 260 行
然后追加一段模型可见标记,包在 <turn_aborted> 里。文案承认两件事:unified exec 可能还在后台跑,被中止的工具可能已经执行了一部分。标记写进历史之后立刻 flush_rollout()。有的客户端收到 TurnAborted 会同步重读 rollout,标记必须先落盘。
出处:codex-rs/core/src/context/turn_aborted.rs 第 1 至 35 行;codex-rs/core/src/tasks/mod.rs 第 920 至 962 行
历史只追加、不改写。中止只往后面加片段,已落盘的工具输出不动。下一轮模型能同时看见完成的输出、被中止工具的回执、以及中止标记。上下文治理的第一条就是这个形状。
出处:AGENTS.md 第 91 至 100 行
DSH:throw 折成 isError,循环 throw 才停 turn
DSH 每轮新建一个 AbortController。工具 body 的 throw 不会穿过循环。执行器把它收成 isError: true 回执,轮次继续。bash 的注释写成产品合同:Non-zero exits are reported, not errored。循环自己的失败才停 turn。用户取消写成 turn/end aborted。
DSH 省掉两档枚举,因为执行器 catch 已经分了模型能看见和引擎必须终止。代价是约定:漏过 dispatchToolBody 的 throw 仍会变成 turn/end error。Codex 用类型把这条路收窄。
Grok:整份 ToolError 回模型,引擎停靠 SamplingError
Grok 的 ToolError 模块头把 detail 写成必须回给模型的说明。Cancelled、Timeout、Execution 都是同一种回灌。工具层没有 Fatal 档。会话层把执行失败收成 tool_result,turn 继续。
引擎停靠另一套类型。SamplingError 不可重试才停采样。Actor 上的 CancellationToken 触发是关机,单次工具取消走 CancelRegistry。令牌不承担 Codex 那种工具分诊。
Esc 落在两种时刻,历史里各留下什么
apply_patch 已经写入文件,成功回执还在飞。这时用户按 Esc。下一轮模型会在历史里看见什么:成功回执、aborted by user、<turn_aborted> 片段,这三样各会不会出现?后台 terminal 还在不在?
再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。
往模型上下文里塞东西,先给它造一个类型
批准前缀、工作区、AGENTS.md、被中断的 turn,全是告诉模型一件它自己看不到的事实。Codex 先给每一种注入造一个类型,类型自己知道 role、marker 和 body。
- 取 role,决定进 user 还是 developerfragment.rs L15
- 问要不要单独成条fragment.rs L18
- 实例要 marker,拿去 renderfragment.rs L22
- 空 marker 只输出 body,且永不匹配fragment.rs L41
- 窗口身份在循环前推进单独组session/mod.rs L3661
- 其余 fragment 按 role 和 marker 分拣session/mod.rs L3677
- user 侧按注册表 .any() 认回contextual_user_message.rs L18
- developer 侧按前缀表认回event_mapping.rs L40
build_initial_context_with_world_state。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。你给 agent 加过这类功能:用户刚批准了 npm *,下一轮模型还在问要不要跑 npm test。或者压缩刚结束,模型突然忘了工作区在哪、沙箱是只读还是可写。再或者 fork 一条会话,旧的 AGENTS.md 还在,新的目录提示叠上去,模型同时看见两套互相打架的规则。
这三件事的共同来源是同一类操作:运行时往模型上下文里塞了一段文字。批准前缀、工作区、权限档案、被中断的 turn、当前 UTC 时间,全是告诉模型一件它自己看不到的事实。一段 format! 就能写出来。
问题出在事后。这段文字进了历史之后,压缩要不要留它、会话恢复要不要认它、UI 要不要把它藏起来、world state 要不要拿它当模型已经知道了的证据,全都依赖一件事:你还能从纯文本里把它认回来。各处再写一遍 startsWith,过几周四处会漂。未知标签还会漏进用户消息解析。
Codex 把每一种注入收成一个实现 ContextualUserFragment 的类型。trait 不在 codex-core 里,而在独立 crate codex-context-fragments。每个实现必须同时回答:这段以 user 还是 developer 进 Responses API,头尾 marker 是什么,中间 body 怎么写,要不要单独成一条消息。
render() 把头尾 marker 和 body 直接首尾相接,中间不加分隔符。空白和换行算 body 的。空 marker 只输出 body。into() 把渲染结果收成一条 ResponseItem::Message,content 只有一个 InputText。注入的终点是协议对象。
fn render(&self) -> String {
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{start_marker}{body}{end_marker}")
}
openai/codex,核对文件 codex-rs/context-fragments/src/fragment.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这段就是类型把自己收成一段可注入文本的全部规则。markers() 带 self,渲染走实例。type_markers() 不带 self,识别走类型。手里只有历史文本、没有原对象时,仍然能问这段像不像某某 fragment。dyn ContextualUserFragment 调不了 matches_text,反向识别必须点到具体类型。
加一种注入要新建文件、实现 trait、挂进 core/src/context/mod.rs。目录里 39 个模块。这个摩擦力本身就是治理:临时 format! 走不通,因为组装函数收的是 Box<dyn ContextualUserFragment>。出处:codex-rs/core/src/context/mod.rs 第 3 至 41 行;codex-rs/core/src/session/mod.rs 第 3677 至 3707 行
渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 marker。组装函数的参数写成 fragment 类型,业务侧就失去了随手拼 XML 的调用点。识别函数只从注册表做匹配,不许再写一套 text.startsWith。
所有注入都带标,一次性通知也会在压缩后被重认、再喂一遍。所有注入都不带标,压缩后的历史里分不清用户原话和运行时塞进去的说明。fork 会把过期的 <environment_context> 当成用户输入重新提交。
窗口身份、环境、中断、图片缩放、管理侧开发者指令,事后要认、要 diff、要在压缩后决定是否重注,所以带 begin 和 end。一次性通知,比如批准前缀、网络规则入册、剩余 token 一句提醒,type_markers() 返回两个空串。默认 matches_text 恒为 false。
匹配规则只看整段文本的首尾。先 trim 开头比前缀,再 trim 结尾比后缀,ASCII 大小写不敏感,两个都中才算命中。中间夹什么不管。无标 fragment 主动放弃可逆性。出处:codex-rs/context-fragments/src/fragment.rs 第 89 至 103 行
developer 侧另有一张前缀表,在 event_mapping.rs,用来补一部分无标识别。覆盖面比 user 侧 matcher 列表窄。前缀表里至今留着 <token_budget> 旧标签,注释写明是为了认旧版本持久化下来的包装。marker 一旦写进 rollout,就变成恢复合同的一部分。改标签等于改协议。
UserInstructions 有一处容易看走眼。它的 begin marker 是 Markdown 标题 # AGENTS.md instructions,end marker 是 </INSTRUCTIONS>。协议里另有 <user_instructions> 那对标签,这个 struct 没用。按协议常量去写 matches_text,会认漏仓库里真实渲染出来的文本。出处:codex-rs/core/src/context/user_instructions.rs 第 18 至 19 行;codex-rs/protocol/src/protocol.rs 第 112 至 113 行
需要事后认的做成协议,一次性的主动放弃,并且在类型上写清楚。压缩后要重认的强制 begin 和 end,一次性通知可以无标,但要在注释里写放弃可逆。
各处随手 push 字符串,顺序靠约定,单独成条靠注释。TokenBudgetContext 会和权限说明挤在同一条 developer 消息里。压缩滤网没法按条处理。混装之后回滚也拆不开。event_mapping.rs 的注释承认:build_initial_context 可能把 contextual fragment 和持久 developer 文本捆在一起。出处:codex-rs/core/src/event_mapping.rs 第 69 至 71 行
第一次组装发生在 Session::build_initial_context_with_world_state。它把 fragment 按 role()、markers() 的开头、requires_separate_message() 分进几组:可合并的 developer 段、必须单独成条的 developer 段、user 段,以及要置顶或置底的特殊段。
窗口身份这一条有点特别。Feature::TokenBudget 打开且模型有 context window 时,它在 world_state 循环之前就被推进单独组。输出顺序是:先一条合并好的 developer 消息,再一条条单独的 developer 消息,再一条 contextual user 消息。分拣依据是类型字段。出处:codex-rs/core/src/session/mod.rs 第 3630 至 3728 行
requires_separate_message() 把能不能和别人挤在同一条 developer 消息里也收进类型。TokenBudgetContext、ImageResizeNotice、ManagedDeveloperInstructions 选择单独成条。单独成条的代价是多占一条消息、多一次 role 切换。好处是压缩滤网可以按条处理。
组装入口只收已登记类型。这是把没登记的注入挡在门外的通用形状。类型挡得住没登记,挡不住登记了但每轮塞 40KB。那一层是评审规则,下一章展开。
闸开在哪:DeepSeek Harness 选择发请求时对账
DSH 写在仓库根 AGENTS.md 第 107 行,中文原则收成「模型可见即已记录」。抵达模型请求的一切都必须能从会话日志重建,新增一项模型可见输入就需要新增一个会话事件。
执行面是 invariant.ts。它在 llm/stream 上挂监听,从 session 日志 deriveMessages() 得到期望值,再和即将发出的 options.messages 做 JSON.stringify 比较,对不上就 fail。闸开在崩溃点,能抓住组装之后又改了 messages 这类只有运行时才出现的漂移。关掉 invariants 服务,这道闸就没了。
真源不同:事件日志,还是封闭的类型集合
DSH 可以没有 fragment trait,因为它的真源是事件日志,messages 是投影。Codex 把能出现在上下文里的东西先收成封闭的类型集合,再用 marker 做事后识别。
代价也不同。Codex 的类型挡得住没实现 trait 就塞进 render_full,挡不住 impl 里面 format! 出来的动态字符串。matches_text 认的是文本形状。两边都为可追溯付钱,一个付在每次请求的断言,一个付在每次加注入的类型摩擦。
认漏的会是哪一段
UserInstructions 的 begin marker 是 # AGENTS.md instructions,协议常量却是 <user_instructions>。如果有人按协议常量去写 matches_text,会认漏现在仓库里真实渲染出来的哪一种文本?
再推一步:把上面演示里的批准前缀打开,压缩之后默认 matches_text 还能不能把它认回来。developer 侧要靠哪张表补这一刀,改错常量时编译器会不会响。
把上下文治理写进 code review
上一章把往上下文里塞东西收成类型。类型过了,这段文字仍然可以合法地又长、又勤、又无界。挡这些后果的,是仓库根十行禁令,外加一份会把同一节再读一遍的评审 skill。
ContextualUserFragment 的改动,编译器仍会放行一份会打掉 cache、撑满窗口或让旧会话恢复失败的 PR;以及六条禁令里,哪几条落在代码里,哪几条只能靠人和 skill 守。
- 注入有没有登记成 ContextualUserFragmentAGENTS.md L100
- 这一条有没有硬上限AGENTS.md L97 · protocol.rs L3112
- 单条是否可能超过 10K tokenAGENTS.md L98 · model_info.rs L167
- 新种类若可能过 1K,标 P0 另审AGENTS.md L99 · additional_context.rs L5
- 会不会每轮改已经发出去的前缀AGENTS.md L96 · client.rs L272
- 是追加新行,还是改历史里的旧行AGENTS.md L95 · session/mod.rs L3383
- 改旧行会不会让已有 rollout 恢复失败AGENTS.md L110
想象四份看起来很负责的 PR。甲给 environment 上下文加一个 git_status 字段,每轮写入完整工作区状态。乙在 turn.rs 里 format 一句 hint,提醒模型跑测试。丙新建一个 fragment,把整个源文件塞进模型可见文本,不写截断。丁在 AGENTS.md 一改时,就地改写历史里那一条说明。
四份都能写出干净的 struct、干净的测试。类型系统会放行。下一轮推理的 cache 前缀会被打掉,token 账单会按轮翻倍,从旧 rollout 恢复时会读到被改过的历史。
ContextualUserFragment 是往模型上下文里塞一段带标记文字的登记口。上一章用它把注入收成类型。编译器从此只接受实现了这个 trait 的 struct。形状对了,成本还可以不合法:又长、又勤、又无界。
Codex 把成本写成六条禁令,放在仓库根 AGENTS.md,标题就叫 Model visible context。同一段原文被抄进 .codex/skills/code-review-context/SKILL.md。评审机器人读到的就是这六条,没有第二套解释。
### Model visible context
Codex maintains a context (history of messages) that is sent to the model in inference requests.
1. No history rewrite - the context must be built up incrementally.
2. Avoid frequent changes to context that cause cache misses.
3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap.
4. No items larger than 10K tokens.
5. Highlight new individual items that can cross >1k tokens as P0. These need an additional manual review.
6. All injected fragments must be defined as structs in `core/context` and implement ContextualUserFragment trait
openai/codex,核对文件 AGENTS.md,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文。同一段六条还出现在 .codex/skills/code-review-context/SKILL.md 第 7 到 13 行。第 6 条和第 3、4 条先问入口和上界。没走 trait,handler 里直接 format! 一段 Message,编译能过,评审打回。走了 trait 但没写硬 cap,同样打回。工具输出侧默认按字节 10_000 截断,超了从中间砍,前面加一行警告,告诉模型原 token 数和总行数。出处:codex-rs/protocol/src/protocol.rs 第 3112 行;codex-rs/models-manager/src/model_info.rs 第 167 行;codex-rs/utils/output-truncation/src/lib.rs 第 12 至 24 行
通用附加上下文另卡在 1_000 token。1K 正好是第 5 条的门槛:已有种类被代码截到 1K,新的可能超过 1K 的种类才需要人看。源码里没有 P0 枚举,也没有 lint 去估一个新 struct 的 body() 会不会超过 1K。出处:codex-rs/context-fragments/src/additional_context.rs 第 5 行
第 2 条盯的是时机。能追加就追加。每轮重写环境 XML、每轮换工具清单,前缀对不上,cache 从第一层作废。会话级客户端把跨 turn 稳定和 turn 内粘滞拆开,sticky token 不准跨 turn 重放。Guardian 审查会话故意复用同一条 trunk,好保住 prompt_cache_key。集成测试 prompt_caching.rs 盯的就是连续两轮 instructions 和 tools 必须一致。出处:codex-rs/core/src/client.rs 第 262 至 274 行;codex-rs/core/src/guardian/review.rs 第 932 至 934 行
类型系统证明的是形状。它证明不了这条文字会不会每轮变、有没有上界、会不会改已经发出去的前缀。这些是过程属性,换语言重写也得另开一扇门。
日常命令也补不上这扇门。just fmt 和 just test 挡住格式、锁文件漂移和一部分 API 破坏。它们读不懂你是不是每轮注入了 git status。六条里零条有专用 lint。第 1、2 条有集成测试影子,第 3、4 条靠局部 cap,第 5 条纯靠人。第 6 条拦得住没实现 trait 就走 render_full 的路,拦不住在 handler 里直接拼一段 Message。skill 存在,说明执行主体是评审。
AGENTS.md 改了,最省事的做法是找到历史里那一条 UserInstructions,把正文换掉。当前轮少占一条消息,token 看起来还降了。从旧 rollout 恢复时,读到的是被改过的文本,会话对不上。
压缩看起来也像在改历史:旧窗口从 live history 里消失。若有人把压缩做成打开历史文件改一行,第 1 条和 breaking changes 第五项会一起被踩中。第五项点名的就是从已有 rollout 恢复会话。
replace_compacted_history 把新表整表装进 live history,旧内容以带 replacement_history 的 CompactedItem 追加到 rollout,不回改旧行。注释写明 「Compaction starts a new history window」。第 1 条和压缩能共存,因为压缩被定义成开新窗口。生产路径里那条就地换表的函数叫 replace_history,上面标了 #[cfg(test)]。出处:codex-rs/core/src/session/mod.rs 第 3373 至 3418 行;AGENTS.md 第 95 行、第 110 行
远端压缩还有一层滤网。服务端送回来的 transcript 不可信,developer 消息直接丢掉,再由本地按当前 world state 把带 marker 的 fragment 重新渲染进去。历史继续增量构建,压缩继续换窗口。出处:codex-rs/core/src/compact_remote.rs 第 354 至 372 行
追加写、用快照换窗口,是日志系统的通用形状。事件溯源换的是投影,LSM 树换的是 SSTable,都不回改已经写下的旧行。禁止就地更新,恢复才有得对。
总量谁来管,六条没写数字。代码用两层补上:模型窗口的 full_context_window_limit 是硬顶,会话树的 RolloutBudget 按加权 token 记账,用尽对整棵 thread 停写。40 条都合法且每条 9K,总量仍会被满窗或会话预算拦住。出处:codex-rs/core/src/session/context_window.rs 第 53 至 54 行、第 74 至 79 行;codex-rs/core/src/rollout_budget.rs 第 45 至 65 行
DeepSeek Harness:原则加笔记,少写禁令
DSH 仓库根 AGENTS.md 第 107 行写的是 「Model-visible ⟺ logged」:送到模型请求里的东西,必须能从会话日志重建;新的模型可见输入,必须对应一条 session 事件。它管的是可见与落盘对齐,不管这一条是不是无界、是不是每轮改、单条是不是超过 10K。
否决过的路会立档。一篇讨论要不要把 compaction 的定义包和唯一实现折在一起的笔记,Status 写明 rejected,还单独留下 Alternatives considered:将来可能有远端或 recall 后端,不够成为现在就拆包的理由。Codex 这一侧没有 rejected/ 目录告诉后来者,某次每轮注入 git status 为何撤回。后来者只能从 prompt_caching.rs 和 Guardian 注释反推。
Claude Code:产品审查和运行时红线不是一回事
用 Model visible context、ContextualUserFragment、unbounded context 检索还原源码,找不到公开的上下文注入评审规范。REVIEW.md 是给审查模型看的产品化 PR 规则,用来标记该不该在审查里指出某类问题,和运行时往模型上下文塞东西的工程红线不是同一层。
这一格空着。后续如果有新的还原源码,可以按这几个词复查。
已检索 · 未找到对应规范 · 2026-08-22这句 format 能留吗
有人要在 session/turn.rs 里 format 一段 <workspace_map>,把当前目录树塞进去,声称只有调试时才开。按六条逐条过:哪几条亮红,改成什么样才能留。
进阶一问:若目录树最坏超过 1K token,PR 标题要不要标 P0,源码里有没有对应的属性宏替你标。
满窗之后,砍哪一段留哪一段
三个时机共用一个分发器,三种实现由开关选中。同一份摘要在中途必须停在历史最后一项。
- 看 token_limit_reached,或用户提交 Compactcontext_window.rs L77
- 采样前走 run_pre_sampling_compactturn.rs L1012
- 中途走 should_roll_over,还要有 follow-upturn.rs L458
- 手动拉起 CompactTask,打断当前 turntasks/compact.rs L19
- TokenBudget 开则换空窗,远端和本地都被跳过turn.rs L1189
- 否则按远端 v2 或本地分发turn.rs L1201
- 远端结果过 should_keep 滤网compact_remote.rs L370
- 按 InitialContextInjection 决定插不插compact.rs L68
- replace_annotated 才把 history_version 加一history.rs L298
你让 coding agent 在同一个线程里改了三十轮。前二十轮还记得工作区在哪、哪几个文件不能动。到第三十轮,它突然问你「当前目录是什么」。再过几轮,已经批准过的 npm test 又被问了一遍。
用量顶到窗口边上之后,系统必须压历史。如果把什么时候压、怎么压写进同一段判断,换提供方或加一种不叫模型的空窗,判定入口都要跟着改。中途压完还要继续采样,发消息前压完则下一轮才会重注,这两件事对摘要位置的要求也不一样。
Codex 把时机写成 CompactionPhase:发消息前的 PreTurn、工具跑完还要续跑的 MidTurn、用户手动的 StandaloneTurn。实现写成 CompactionImplementation:本地再采一次样、旧的 /responses/compact、新的 compaction_trigger。分析事件另有 Trigger 和 Reason 两套标签。自动路径共用 run_auto_compact,手动路径共用 CompactTask。九宫格是乘法表,源码里没有九套并列函数。
三个自动时机都先问同一个函数。token_limit_reached 在缓冲后的 compact 限额或满窗任一触发时为真。中途还要多一道闸:后面还要继续,并且模型刚请求了新窗口或 token 已经到顶。只满不续,这一轮自然结束,下一轮用户消息到来时走预采样。
出处:codex-rs/core/src/session/context_window.rs 第 74 至 91 行;codex-rs/core/src/session/turn.rs 第 458 至 483 行
分发器第一句看 TokenBudget。开了就换空窗,远端和本地都被跳过。关了再按提供方能力选:认 compaction_trigger 且特性开,走远端 v2;认 V2 但特性关,走旧远端接口;Unsupported 走本地。TokenBudget 默认关,RemoteCompactionV2 默认开。默认 OpenAI 会话走远端 v2。
出处:codex-rs/core/src/session/turn.rs 第 1178 至 1201 行;codex-rs/features/src/lib.rs 第 1428 至 1433 行、第 1542 至 1547 行
什么时候该换窗,和窗里装什么,是两件独立的事。换语言重写,仍要先选相位再选管道。提供方只有一个时,也可以先把判定和安装拆开。
服务端带回的 transcript 可能夹着过期 developer 指令。不滤就和本地按当前 world state 新渲染的环境叠在一起,效果等价于有人改了旧行。中途压完还要在同一轮继续采样,模型被训练成摘要是历史上最后一项。上下文如果插到摘要后面,训练约束就破了。
滤网是完整的穷尽 match。丢掉 developer、非用户内容的 user 包装、工具调用和压缩触发项。留下真实用户消息、持久化 hook prompt、assistant、压缩项。v2 复用同一函数。
出处:codex-rs/core/src/compact_remote.rs 第 370 至 397 行
然后按 InitialContextInjection 决定插不插。预采样和手动用 DoNotInject:替换历史里没有初始上下文,并清掉 reference_context_item,下一轮普通 turn 会走完整重注。中途用 BeforeLastUserMessage:当前环境和权限插到最后一条真实用户消息之上,摘要仍在队尾。插入函数还有兜底:没有真实用户就插在摘要前,再没有就插在最后一条 compaction 项前。
出处:codex-rs/core/src/compact.rs 第 59 至 74 行
三种实现最后都进 replace_compacted_history。live history 整表替换。history_version 只在这时加一,追加不碰版本号。Guardian 复用 transcript 时会核对 parent_history_version,版本变了就不能复用旧的审查前缀。
出处:codex-rs/core/src/context_manager/history.rs 第 298 至 302 行
远端结果只要不是本地函数吐出来的,进门先丢掉指令包装。插入点挡的是训练约束和重注时机,这两件事本来就分开。版本号只在重写时前进,因为追加每轮都发生。
有时用户只想换一扇干净的窗,不要摘要。空窗如果走另一套生命周期,hook 和 ContextCompaction 条目会看不见这件事。这个开关默认关,避免用户在没意识到时丢掉整段对话。
TokenBudget 跳过模型和服务端摘要,安装一扇新窗口,摘要字段是空字符串。模型在新窗口里看不到旧对话,只看到此刻的环境和权限。这和 new_context 工具那句合同一致:换窗,不摘要。它仍然走压缩生命周期,pre-compact hook 若停下,窗口还没换。
出处:codex-rs/core/src/compact_token_budget.rs 第 21 至 25 行
本地路径自己碰上满窗,不递归调用自动压缩。它删最老的一条再打一次。只剩一条还超,就标满窗并返回错误。远端失败也不会改走本地:普通满窗触发的预压缩和中途压缩都把 fallback 传成 None,第一次远端失败直接返回。超时不在可重试名单里。
出处:codex-rs/core/src/compact_model_fallback.rs 第 8 至 20 行
压缩是一次换窗的生命周期,产物可以是摘要,也可以是空房间。递归要自己挡住:手动任务不进 turn 循环,本地超窗靠修剪,中途路径靠压成功就会低于限额。假设不成立时,循环可以再次进入。
DSH:不叫模型的那一刀可以先落地
DSH 的压缩家族在 packages/compaction/。压力触发时先可选地 prune,再量一次。prune 之后如果已经回到阈值以下,摘要不跑。两条路径都在本地,没有 Codex 那种 compact 客户端。
溢出恢复把 replaceGeneration 当重试许可。prune 先落地、随后摘要抛错,只要 generation 前进了,就允许从新表层再试。generation 只在 replace 计划提交时加一,append 不加。Codex 用 history_version 回答同一问题,远端要么整窗安装,要么完全不装。
Claude Code:递归靠标签拦住,失败三次就停手
默认自动路径仍是再叫一次模型写摘要。自动判定先挡递归:session_memory 和 compact 这两种 querySource 直接返回 false,注释写明它们是 fork 出来的 agent,再触发会死锁。连续失败 3 次之后电路熔断,注释记录过单会话连续失败上千次的事故。
Codex 的 Compact 任务不进 turn 循环,这种标签可以不存在。换来的是中途路径没有对等的连续失败计数器。作者把赌注写在注释里:压缩只要把用量压到限额以下,就不必担心死循环,于是计数器被省掉了。
两侧均已核对源码 · 2026-08-22三张替换表各留下什么
上面那一段混杂历史,分别走中途远端、走空窗、走采样前远端。写出三张替换表:各砍掉哪几块、保住哪几块、摘要在不在最后、初始上下文此时在不在表里。
进阶一问:远端第一次失败时,为什么普通满窗路径不会改走本地,换模型预压缩却可以再打一次远端。
JSONL 是真相,SQLite 是镜像
昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。
- Session 把 item 交给 LiveThread,失败只记日志session/mod.rs L3753
- LiveThread 把原文切片交给 storelive_thread.rs L203
- 白名单丢掉瞬时 EventMsg,执行标记一律留下policy.rs L9
- 一行 JSON 加换行,write_all 后再 flushrecorder.rs L1968
- 闸门先赢,Paginated 才投影 thread_historylive_writer.rs L337
- 投影失败只 warn,下次按字节偏移续live_writer.rs L345
- 观察过滤结果,再打字面 metadata patchlive_thread.rs L212
- 恢复从文件逐行 decode,不从 threads 表拼历史recorder.rs L1009
- 列表无库或出错,退回扫 sessions 目录recorder.rs L547
列表要快,恢复要对。一份文件很难同时满足。JSONL 追加便宜,用 jq 就能读。按工作区、置顶、归档去筛,它就不合适。SQLite 擅长这些过滤,却不该成为恢复时拼模型输入的地方。
两件看起来相反的事故,其实指向同一条规则。state_5.sqlite 被删掉,列表空一阵又长回来,对话还在。你用手改库里的标题和 cwd,刷新后有时跟着变,有时又变回去。镜像可以重建,也可以被原文覆盖。原文丢了,镜像救不回来。
Codex 拆成两轨。Session 不直接碰文件,它把已经构造好的 item 交给当前的 LiveThread。没有 live handle,或者 append 失败,turn 本身不因此中断,错误只进日志。
出处:codex-rs/core/src/session/mod.rs 第 3753 至 3759 行
LiveThread 先按策略过滤一份观察用的副本,交给 store 的仍是原始切片。store 自己再跑一遍白名单。流式增量、审批、警告这些瞬时 EventMsg 进不了 JSONL。Compacted、TurnContext、WorldState、SessionMeta 一律留下。
写的顺序固定。先让 JSONL 落盘,再投影到 SQLite。投影失败可以下次重做。JSONL 失败,SQLite 不能顶上去。
追加写和随机查要的物理形状不一样。绑在同一份格式上,要么列表变慢,要么每次追加都改整份文档。拆开之后,写路径可以先保证原文,再修镜像。换语言重写,合同还是这句。
如果先写 SQLite 再补 JSONL,进程死在两步中间,列表里会出现点不开的会话。用户看见标题,点进去没有对应行。这种不一致比列表暂时为空更难查。
Paginated 模式下,注释把 SQLite 写成可重建视图。flush 屏障必须先赢,投影可以落后,不能超前。durable_write 返回 Ok 之后,materialize_to_sqlite 才许开始。投影出错只打 warn。
出处:codex-rs/thread-store/src/local/live_writer.rs 第 335 至 347 行
落到字节的那一步,一行 JSON 加一个换行,write_all 后再 flush。这里的 flush 是 tokio 文件缓冲,源码没有再调 sync_all。进程被立刻杀掉时,最后几行可能停在内核页缓存里。下次打开会补换行,坏掉的半行计进 parse_errors。
出处:codex-rs/rollout/src/recorder.rs 第 1968 至 1974 行
恢复、fork、压缩回放只读 JSONL。列表优先走 SQLite。库不存在、打开失败、回填未完成,一律退回扫 ~/.codex/sessions/ 目录,并打稳定指标 codex.sqlite.fallback.count。
出处:codex-rs/rollout/src/recorder.rs 第 547 至 559 行
可重建的东西允许丢,不允许抢跑。两边绑进同一个事务,镜像一慢,原文也写不进去。允许落后、禁止超前,是日志加派生表的通用合同。
压缩会换掉一段历史。如果 Compacted、WorldState、TurnContext 只活在内存或只活在 SQLite,删库之后窗口号和基线一起丢。用户以为模型忘了,加载器其实只是没读到那三行。
压缩先改内存历史,再按 Compacted、WorldState、TurnContext 的顺序落盘。WorldState 必须跟在 replacement history 后面,因为它是这份新历史的基线。
出处:codex-rs/core/src/session/mod.rs 第 3417 至 3427 行
恢复时反过来读。从后往前扫,碰到带 replacement_history 的 Compacted 就切断更早的后缀,并清掉更早的 TurnContext 基线。再正序重放 WorldState,full 快照重置基线,patch 往上合并。
出处:codex-rs/core/src/session/rollout_reconstruction.rs 第 155 至 188 行
这三类对列表几乎无用。apply_rollout_item 碰到 Compacted 和 WorldState 是空操作。镜像不是全文索引,是列表和筛选要用的字段。标题来自 UserMessage,不从模型的 ResponseItem 猜。
出处:codex-rs/state/src/extract.rs 第 14 至 34 行
恢复合同写在文件里。改持久化形状等于改恢复合同。仓库根的 AGENTS.md 把从已有 rollout 恢复会话列进破坏性变更检查清单。文件还在,会话就能按合同重放。
DSH:同一种日志,两种后端
DeepSeek Harness 的持久化单元就是内存里的 SessionEvent。JSONL 和 SQLite 实现同一份 SessionPersistence seam,换后端换的是存储原语,不换日志语义。header 带 SESSION_FORMAT_VERSION = 0。版本不对,或出现未知且未标 ignorable 的事件,直接拒绝解读,错误叫 SessionFormatUnsupportedError。
静默残缺比报错更难查,DSH 选择报错。Codex 选择尽量打开,未知形状靠 serde 失败计入 parse_errors,有 item 时仍会尽量建 builder。
Claude Code:一份 JSONL,没有会话镜像库
当前会话路径是 projects/<project>/<sessionId>.jsonl。追加是同步 appendFileSync,一行 JSON 加换行,权限 0o600。列表走 getSessionFilesLite,读文件头尾,不经过 SQLite。
单文件读取有 50 MB 上限,注释写明会话 JSONL 可以长到数 GB,调用方必须先退出以免撑爆内存。Codex 把这个扫盘代价挪到启动回填。两边都承认 JSONL 会涨,一个读到上限就拒绝整文件读入,一个靠回填工人把封面重新抄进库。
两侧均已核对源码 · 2026-08-22压缩写到一半时拔电
一次会话已经写下 SessionMeta、一条用户消息、一条助手回复。接着压缩开始,Compacted 已经 flush,WorldState 还没写,这时拔电。
推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么。
模型看见的工具清单,是一次采样算出来的
同一段对话、同一份配置,下一轮采样却可能多出 MCP 名字、协作入口或 tool_search。变的是这一次允许广告哪些 handler。
tool_search。
- 解析这一步的 MCP 绑定mcp.rs L310
- 采集 MCP 目录,交给规划函数turn.rs L1494
- 按身份决定是否走完整来源spec_plan.rs L889
- 登记 shell、资源、实用、协作spec_plan.rs L930
- 追加 MCP 并套上暴露策略spec_plan.rs L148
- 有 deferred 才挂 tool_searchspec_plan.rs L335
- 只把 is_direct 的 spec 发给模型spec_plan.rs L484
- 冻进 StepContext.tool_routerstep_context.rs L44
- build_prompt 读取可见表turn.rs L1320
build_tool_router。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。你刚接上 MCP filesystem,模型这一轮还在跑。要是按整轮用户对话冻工具表,新连上的服务器得等下一条用户消息才进菜单。要是每调一次工具冻一次,同一次采样里并行的两次调用可能看见两份不同的表。
广告一份、执行另一份,或者执行到一半表变了,是工具表最常见的翻车。
Codex 把这一次采样的模型、审批、环境、MCP 绑定和已经算完的 tool_router 一起放进 StepContext。注释写明它是 request-scoped:采样请求之间可以变,同一次采样里不变。tool_router 是 this exact sampling request 的计划。
采样进行期间,模型看见的名字和能 dispatch 的 runtime 都读这份快照。MCP 服务器在这次采样中途掉线,改不了已经算完的表。下一次采样会再走 mcp_runtime_for_step,绑定可能复用,也可能换成空绑定。出处:codex-rs/core/src/session/step_context.rs 第 17 至 47 行;codex-rs/core/src/session/mcp.rs 第 348 至 357 行
所以冻结单位是 step,不是 turn。一个 turn 里可以有多次采样,每次重建一份 StepContext。出处:codex-rs/core/src/session/turn.rs 第 1318 至 1320 行
可见和可执行共用一份快照,下一轮再吸收新连接。换个语言重写,最小形态仍是一个函数:输入开关、MCP 目录、身份,输出可见表和可执行表,请求开始时算一次,算完就冻。
审批模型如果看见工作模型那整张 MCP 和协作工具表,它就能去 spawn_agent、读外部资源,审批本身被绕开。先注册全表再过滤,漏一条分支就会把不该给的名字送出去。
规划函数按身份决定走哪些来源。Guardian 评审员的标签必须恰好是 guardian。权限档案不是 Managed,函数直接返回,注册表是空的。Managed 且有环境时,最多三件:exec_command、write_stdin、可选 view_image。普通会话才走 shell、MCP 资源、实用工具、协作这四组,再追加 MCP、扩展和动态工具。出处:codex-rs/core/src/guardian/review.rs 第 212 至 220 行;codex-rs/core/src/tools/spec_plan.rs 第 144 至 164 行、第 889 至 934 行
读盘也没有一等入口。handlers 目录里没有 ReadFileHandler。模型要读文件,走 exec_command、MCP filesystem,或 view_image 内部的 exec-server API。
身份一变,整组来源都不走。默认关上门发生在评审员、环境缺失、特性关闭这几处,换语言也用得上。
MCP 工具的 schema 很贵。全挂上,初始请求的 token 会被描述文本吃掉。两个 server 都提供 read_file,不消歧就会撞名。
暴露态把注册表拆成几条可见面。Direct 进初始表。Deferred 只留给 tool_search。Hidden 只留给 dispatch。搜索开着时,MCP 工具默认 Deferred,不进初始可见表。出处:codex-rs/tools/src/tool_executor.rs 第 51 至 80 行;codex-rs/core/src/mcp_tool_exposure.rs 第 90 至 94 行
finalize_tool_router 只在两件事同时成立时才挂 tool_search:模型支持 search tool,并且注册表里还有至少一个 deferred 且带 search_info 的工具。没有 deferred,它不会进表。出处:codex-rs/core/src/tools/spec_plan.rs 第 335 至 370 行、第 578 至 580 行
两个 MCP server 的同名工具,在规范化阶段消歧:默认加 mcp__ 前缀,sanitize 后仍撞车再加 12 位哈希后缀。外部工具撞到已占用的核心名,直接跳过。仓库根 AGENTS.md 仍写着 mcp_connection_manager.rs,当前树里没有这个文件,消歧在 tools.rs。出处:codex-rs/codex-mcp/src/tools.rs 第 1 至 5 行、第 113 至 151 行;AGENTS.md 第 35 行
可见、可检索、可执行是三份集合。token 预算紧就推迟发现,核心名保留、外部让路。这些不依赖 Rust。
DeepSeek Harness:插件往装配器里投 schema
DSH 没有集中规划函数。每个工具包在 assemble 时通过 systemPrompt.tools() 投递 schema,装配器再用部署配置的 toolOrder 排座位。未点名的工具插在保留标记 <unlisted-tools> 处,按名字字典序。漏写这个标记,装配期直接失败。
装上 @deepseek-ai/dsh-tool-fs,模型就看见一等工具 read。描述要求用它读文本。Codex 把读盘留给 shell、MCP 和内部 API,规划函数里没有 read_file 这个入口。
Claude Code:静态基表加 MCP,内置优先
getAllBaseTools 是一张写死的数组,FileReadTool 是一等成员。请求时 assembleToolPool 先过滤 deny 规则,再把内置和 MCP 各自按名字排序后拼接。内置在前,撞名时内置赢。注释写明这样排是为了 prompt cache:MCP 插到内置中间,后面的 cache key 会全失效。
Claude Code 的基表打开一个文件就能数完。Codex 要数清单必须走完 add_core_tool_sources,同一套规划能按身份和预算裁剪。
这一轮菜单上还剩什么
先开 MCP 和搜索,确认 mcp__filesystem__read_file 在灰色区、tool_search 在菜单上。然后推演三步:搜索仍开着,但 MCP 全部变成 Hidden;采样中途掉线;再走下一轮采样。每一拍写下可见表、灰色区和 tool_search 在不在。
进阶一问:把身份改成 Guardian 评审员,权限先 Managed 再改成其他,菜单分别是什么,为什么不是少挂几件工具。
StepContext 把它们冻在采样边界。模型仍可用 exec_command 读文件,即使表上没有 read_file。
对模型说随便并行,底下用锁管住
模型看见的、实际执行的、历史记录的,是三套顺序。一面旗允许一次发多个调用,一把读写锁决定谁能叠着进。
- build_prompt 把并行旗写成 trueturn.rs L1321
- 编请求时和 Lite 标记做与client.rs L952
- 路由查注册表,没有就当 falserouter.rs L137
- Hidden 即使自称并行也当串行registry.rs L472
- 先 spawn,再等就绪,最后拿锁parallel.rs L144
- 能并行就 read,否则 writeparallel.rs L152
- 结果按 FuturesOrdered 插入顺序入账turn.rs L2135
你让模型同时读 src/main.rs、读 src/lib.rs,再打一处补丁。屏幕上两份读文件几乎同时出进度,打补丁那一下却停了一拍。
如果运行时把可以并行理解成这些调用叠着跑,两个 apply_patch 会一起改共享的 diff 记账,账会乱。如果这些调用首尾相接,两个读文件也要排队,多耗一轮墙钟。请求侧那面旗只回答模型愿不愿意一次发多个盒子,回答不了盒子落地时谁能重叠。
发给模型的旗写在 Prompt 上。字段默认是 false,采样主路径走 build_prompt,把旗写成 true。编成 Responses 请求体时,再和模型是不是 Responses Lite 做一次与。Lite 上这面旗关掉。压缩那两条组包路径也写死 true,为的是和主采样请求的形状对齐,websocket 复用会逐字段对比,其中就包括这面旗。
出处:codex-rs/core/src/session/turn.rs 第 1312 至 1328 行 · codex-rs/core/src/client.rs 第 946 至 953 行
执行侧另有一张表。ToolExecutor 默认 supports_parallel_tool_calls 返回 false。漏写覆盖就走写锁。exec_command、view_image、tool_search 覆盖成 true。apply_patch 不覆盖。路由先查注册表,查不到当 false。曝光是 Hidden 的,handler 自己返回 true 也没用。MCP 还要服务器开关或只读提示,缺省仍是串行。
出处:codex-rs/tools/src/tool_executor.rs 第 122 至 124 行 · codex-rs/core/src/tools/registry.rs 第 470 至 473 行
模型只看见 parallel_tool_calls 为 true 或 Lite 下的 false。它看不见谁能并行。工具清单也不会因为某个工具其实要拿写锁而少掉一项。
对模型的许可以和宿主调度拆开,换语言重写也用得上。请求侧只回答愿不愿意一次发多个调用。执行侧只回答这一次能不能和别人重叠。Lite 测试把旗关掉,说明作者接受某些模型路径上失去这层提示。闸门始终在。模型如果仍在一条响应里发出两个调用,两个任务照样 spawn,照样按本地表拿锁。
分类对了,还要有人看门。两个读可以共存,一个写必须独占。若先握住锁再等 MCP 服务器连上,一次冷启动会让旁边已经就绪的 exec_command 也卡住。
还有公平性。若后来的读者能从写者头顶上翻进去,补丁可能一直拿不到锁。换成标准库那把 RwLock,优先级依赖操作系统,写者有机会被饿死。
一次采样共用一把 tokio::sync::RwLock<()>。锁保护的值是单元类型,里面没有业务数据,只当闸门。任务先 spawn,可选地等就绪,再拿锁。能并行就 read,否则 write。就绪等在锁外面。一个还没连上的 MCP 服务器,不会占着写锁让旁边的 shell 也卡住。
出处:codex-rs/core/src/tools/parallel.rs 第 144 至 156 行
这把锁的优先级是 fair,也叫 write-preferring。已经排队的写请求没拿到、没释放之前,后面的读锁不会发下去。一个 view_image 还在跑,apply_patch 已经在门外,再来的 exec_command 明明可以和 view_image 重叠,却必须排在补丁后面。fair 换来的是写者不会饿死,代价是后来的读者被写者隔开。
分类粒度是工具实例,看不到这一次的参数。exec_command 无论跑 ls 还是 rm,都走读锁。apply_patch 无论补丁多大,都走写锁。两个无关的串行工具也会互相挡住。检索业务代码,没有容量上限。十个 shell 可以一起进。容量交给进程、沙箱和操作系统。
问的是这一刻有没有独占者。餐厅可以多人同时看菜单,只准一人进后厨。公平策略写在锁的实现里。业务代码只问并行还是独占。换一把会让新读者插队的锁,写者就有机会被饿死。把就绪等待放在锁外面,也是同一类判断:还没准备好的人,不要占着门口。
两个 exec_command 叠着跑,后发出的可能先跑完。若按完成顺序写历史,模型下一轮看到的结果顺序会和它发出的调用对不上。流内每到一条 OutputItemDone 就建 future,那一套管的是何时开工。这里管的是开工之后谁能重叠,以及结果按什么顺序入账。
采样循环把 tool_future 按到达顺序推进 FuturesOrdered。drain 按插入顺序出队,再写入会话。读锁让两个 shell 叠着跑,入账仍按发出顺序。先发出的先入账,哪怕它其实更晚跑完。
出处:codex-rs/core/src/session/turn.rs 第 2130 至 2140 行
观测顺序和执行重叠从结构上分开。并行只改墙钟,不改账本。自己做 Agent 时,至少把这两条队列分开写。对模型说可以并行,跑工具时再看本地表,结果仍按调用列表的原顺序收下。
DeepSeek Harness:按参数分类,独占当屏障
DSH 让每个工具提供 isConcurrencySafe(args)。只有精确的 true 才加入并行。缺声明、参数不合法、分类器抛错,都是独占。bash 没有分类器,整条独占。调度器等完整消息到齐,连续的并行调用编成一组,每个独占调用单独成组当屏障。组内滚动池,上限默认 10。
Codex 可以没有这套分组,因为它把判断压成工具级布尔,再用一把锁模拟屏障。DSH 能让只读的 bash 仍然串行,少掉一部分并发。Codex 能让两个 ls 叠着跑,两个 rm 也可以叠着跑。
Claude Code:按参数分批,只读 bash 才并行
Claude 的 isConcurrencySafe 默认返回 false。BashTool 把并行交给 isReadOnly:命令通过只读约束才返回 true。ls 可以进并行批,带写副作用的命令进串行批。连续的安全调用收成一批走并发,不安全的每个自成一批,一批里也是一个接一个。上限来自环境变量,解析失败时是 10。
Codex 的 exec_command 省掉命令解析,写命令也进读锁。三边都把调度元数据藏在宿主,闭合的位置不同。Codex 闭合在默认值和 Hidden,对 shell 最放开。DSH 闭合在分类器,bash 全串行。Claude 夹在中间。
后到的读能不能插队
view_image 还在跑,apply_patch 已经在门外排队。这时模型又发出一个 exec_command。演示里切到后到的读,单步走完,对照下面三问。
exec_command 能不能和还在跑的 view_image 叠着跑。apply_patch 的窗口改成读牌,闸门还会不会把它单独拦住。统一入口:一条命令按特征分叉
模型只看见 exec_command 和 write_stdin。进门之后,tty、远程环境和 150 毫秒窗口会把同一条命令送到 PTY、pipe、exec-server,或者送进重试门。
- 构造 command 加 cwd 请求mod.rs L12
- 编排器审批:bypass、缓存或弹窗mod.rs L14
- 按档案选沙箱并 transformmod.rs L15
- 按 tty 和远程环境分到 PTY、pipe 或 exec-serverspawn.rs L97
- 150 毫秒内退出则检查沙箱拒绝process.rs L349
- 启发式或执行器旗标成立则标成 Deniedprocess.rs L307
- 编排器过 escalate 与策略门orchestrator.rs L356
- UnlessTrusted 且已批准则不再问orchestrator.rs L411
- 允许 unsandboxed 则第二次用 Noneorchestrator.rs L459
- 活过窗口则入库再 yieldprocess_manager.rs L535
- 迟到拒绝收成 Ok 回执exec_command.rs L384
- 用户 Esc 只取消 turnprotocol.rs L546
模型要装依赖,发出 npm install。同一轮里又开 vim 改 README。若每种执行各写一套审批和沙箱,Guardian、网络代理和审批缓存会复制三遍,改一处漏两处。
对外只有 exec_command 和 write_stdin。前者开进程,后者往已有进程写,空写就是一次 poll。内部跟踪的是 process_id,模型侧参数名叫 session_id。管理器只准备请求,审批、选沙箱、重试交给编排器。
出处:codex-rs/core/src/unified_exec/mod.rs 第 12 至 17 行
本地拉起按两个开关三选一。tty 为真走 PTY。tty 为假且 stdin 开着,走带 stdin 的 pipe。否则走不带 stdin 的 pipe。远程环境或带 shell snapshot 的请求不走本地 spawn,它们走 exec-server。Windows 受限令牌是单独一条后端。
出处:codex-rs/sandboxing/src/spawn.rs 第 97 至 127 行
默认工具调用常常是 pipe。模块注释里的拉起 PTY,只覆盖 tty=true 那一支。要完整终端能力,模型必须显式打开 tty。环境变量还会钉死 TERM=dumb、PAGER=cat 一批值,交互程序先被削一层。
政策逻辑集中,进程形态可以换。换语言重写,仍是入口统一、拉起方式按特征分。PTY 怎么实现,可以留在自己的仓库里。
沙箱拒了写 /etc/hosts,stderr 里是 Operation not permitted。运行时若把这条当成命令写错,模型会改源码、换路径、加 sudo。认出来,它才有机会按策略再跑一次,或者把拒绝正文喂给模型去申请权限。
本地进程接住之后,退出通道里已经有码、通道关了、或者 150 毫秒内退出,才会做拒绝检查。超过这个窗口,只挂一个后台任务等退出,把还活着的进程交回去。编排器重试依赖这里返回 SandboxDenied。进程活过 150 毫秒,编排器已经拿到 Ok,后面再死就进不了第二次 spawn。
出处:codex-rs/core/src/unified_exec/process.rs 第 38 行 · 第 349 至 367 行
exec-server 路径有同一段超时。检查函数自己还先等 20 毫秒,让输出通知有机会到达。判定还有三道短路:进程还没退出,直接放过;已经是 SandboxType::None 且执行器没报拒绝,也放过。其余情况才跑共享启发式。
出处:codex-rs/core/src/unified_exec/process.rs 第 290 至 324 行
活过窗口的进程先入库,再开始 yield。打断 turn 时,不能因为最后一个 Arc 被丢掉而把后台进程杀掉。yield 等到进程已经退出,管理器会再跑一次拒绝检查。这时编排器早已返回 Ok。这个错误直接回 handler,收成带正文的工具回执,process_id 置空。
出处:codex-rs/core/src/unified_exec/process_manager.rs 第 535 至 556 行 · codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 第 384 至 407 行
一次性命令可以等到结束再判定。持久进程必须有截止时间。漏掉截止,跑了几秒才失败的命令会被当成拒绝再裸跑。副作用已经写到磁盘上,第二次是另一份进程,源码里没有回滚。
模块头写:被拒之后按策略用 SandboxType::None 再试,并且靠缓存不再问一遍。Never、OnRequest、Guardian strict、带 deny-read 的档案,都会把不再问或无沙箱重试关掉。用户按 Esc 若把 vim 一起带走,下一轮找不到这个 session。
出处:codex-rs/core/src/unified_exec/mod.rs 第 7 至 8 行
编排器只认一种错误:SandboxErr::Denied。认出来之后过五道门。unified_exec 声明自己会升级。Never 和 OnRequest 默认不要无沙箱重试,把带原文的拒绝表面给调用方。档案里有 deny-read 时,绕过沙箱会把这些拒绝读静默放行,所以 unsandboxed 关掉。Guardian 的 strict auto-review 把第一次批准只覆盖沙箱内尝试。第二次允许 unsandboxed 时落到 None。
出处:codex-rs/core/src/tools/runtimes/unified_exec.rs 第 159 至 161 行 · codex-rs/core/src/tools/sandboxing.rs 第 330 至 337 行 · 第 269 至 278 行 · codex-rs/core/src/tools/orchestrator.rs 第 411 至 415 行 · 第 444 至 460 行
用户在 TUI 里按 Esc,协议入口是 Interrupt:中止当前任务,不杀后台 terminal。要杀全部后台,另有 CleanBackgroundTerminals。先入库再 yield,turn 令牌被取消时,进程的 Arc 还在。pipe 会话收不到普通按键,只有 \u{3} 会走 interrupt。
出处:codex-rs/protocol/src/protocol.rs 第 546 至 552 行
用户选中的隔离级别不能被运行时悄悄改掉。停思考和停终端是两件事。取消只停等待,不杀已登记的进程。
DeepSeek Harness:六个终端工具,外加 jobs
DSH 把持久 PTY 做成独立工具族:开、写、读、信号、关、列。后台发送复用 ctx.jobs,收集走 job_output,停止走 job_kill。系统提示写明:只有需要跨调用的终端状态或交互 stdin 时才用终端,一次性工作优先 shell 或读写工具。
Codex 把开和写收成两个工具,读合并进下一次 write_stdin 或空 poll。DSH 没有对位的沙箱拒绝后由编排器自动无沙箱重试。失败之后谁负责再跑一次,两边答案不同。
Grok:会话启动时选要不要持久
Grok 没有 unified_exec 这个模块。它把持久性做成会话级后端选择:复用父会话、ACP 客户端终端、本地持久、本地非持久。选完后端就按这个形态跑整场。
Codex 把同一问题放在单次 exec_command:进程活过 yield,就发回 process_id。Grok 整场会话共享一种后端,子 agent 复用父后端。两边都承认一次性 bash -c 保不住 cwd 和交互状态。落地位置不同。
出处:packages/terminal/tool-terminal/src/index.ts 第 156 至 160 行 · crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs 第 2048 至 2070 行
哪一次会第二次 spawn
同一条 npm install,先走 UnlessTrusted,再拨到 Never。然后把命令换成 sleep 2。推演:哪一次会第二次 spawn,哪一次回执里还有 process_id,为什么灯灭之后编排器看不见拒绝。
沙箱管理器:把权限档案编译成一行命令
工作区可写,网络收紧。同一条 git status,Mac 套上 sandbox-exec,Linux 套上 helper,Windows 可能原样出门。差别出在编译器。
environment_context。
还没渲染。
- 读入 PermissionProfile 三态models.rs L411
- 叠上 additional permissionspolicy_transforms.rs L525
- Auto 则跑 should_require_platform_sandboxpolicy_transforms.rs L541
- select_initial 再问 get_platform_sandboxmanager.rs L62
- None 则原样传出 argvmanager.rs L366
- macOS 前置 /usr/bin/sandbox-execmanager.rs L401
- Linux 序列化档案给 helperlandlock.rs L23
- Windows 第一拍不改 argvmanager.rs L447
- 同一份档案渲染进 environment_contextenvironment_context.rs L96
同一份仓库,三台机器,模型发出同一条 git status。配置里写的是同一份权限档案:工作区可写,网络收紧。
排障的人会以为沙箱没生效。日志里档案还在,模型上下文里那份 environment_context 也还在。只是平台这一层没有把档案编译成包装命令。Windows 上开关关着,编译器交出原 argv,策略层还在。
SandboxManager 先问要不要,再问有没有。should_sandbox 只返回一个布尔。Forbid 恒假,Require 恒真,Auto 看档案形状。有托管网络要求,必须上。网络收紧时,除了调用方自己管文件系统,都要上。网络放开且文件系统不受限,才跳过。
出处:codex-rs/sandboxing/src/manager.rs 第 310 至 329 行 · codex-rs/sandboxing/src/policy_transforms.rs 第 541 至 561 行
然后 get_platform_sandbox 按操作系统给类型。macOS 给 Seatbelt,Linux 给 seccomp helper。Windows 多一个开关,关着就是空。空的意思是这台主机没有可派发的实现。
出处:codex-rs/sandboxing/src/manager.rs 第 62 至 76 行
select_initial 先问前者,再把后者的空收成 SandboxType::None。档案说需要,主机给不出,类型仍是 None。
出处:codex-rs/sandboxing/src/manager.rs 第 293 至 306 行
配置里的意图和主机上的后端,本来就会分手。产品文案、状态栏、模型说明如果都去读配置里希望启用的那一侧,排障会从这里开始歪。两个函数,一个返回布尔,一个返回可选的后端名。换语言重写也用得上。
权限如果写成两份,改了一边忘了另一边,模型就会按旧边界规划下一步。模型看见没有外层沙箱,和 Windows 关沙箱时落到 None,应该是同一份意图的两个出口。
PermissionProfile 三个变体写的是谁负责建外层沙箱。Managed 由 Codex 自己拼包装命令。Disabled 不要外层。External 文件系统由调用方负责,网络仍可能归 Codex 管。
出处:codex-rs/protocol/src/models.rs 第 411 至 425 行
transform 按类型分派。None 原样传出用户 argv,连本机 cwd 都不准备,因为未沙箱的请求可能带着外机路径。macOS 只信任 /usr/bin/sandbox-exec,策略走 -p,路径走 -D,用户命令放在 -- 后面。Linux 把整份档案序列化进 --permission-profile,helper 缺失直接报错,不会按无沙箱降级。Windows 第一拍只校验,argv 不动;包装器就是当前 exe,套壳推迟到 transform_for_direct_spawn。
出处:codex-rs/sandboxing/src/manager.rs 第 365 至 459 行 · codex-rs/sandboxing/src/seatbelt.rs 第 52 至 56 行 · codex-rs/sandboxing/src/landlock.rs 第 15 至 23 行 · codex-rs/sandboxing/src/manager.rs 第 484 至 518 行
同一份档案由 FileSystemContext 收成内部枚举,渲染进 environment_context。Disabled 写成 type="disabled" 加 unrestricted 文件系统。模型可见文本挡不住越权,只减少无效尝试。
出处:codex-rs/core/src/context/environment_context.rs 第 96 至 116 行
运行时事实只应有一份。隔离发生在子进程入口,编排层继续拿路径和档案,到执行边界才落地。平台方言会变,这份同时喂给包装命令和模型的档案形状用得上。
单条命令还可以再带一份 overlay。如果人批的时候用并集,一次误批就能把请求里没有的路径写进会话授权。
合并走并集:任一侧打开网络,结果就是开网;文件系统条目首尾相接去重。求交走另一套:网络必须两侧都开才留下;文件系统只保留落在请求范围内的已批条目。求交结果为空,会话不记账,基座档案继续生效。空集的含义是这次额外开口没留下任何加宽。命令还能不能跑,要看后面的策略和沙箱。
出处:codex-rs/sandboxing/src/policy_transforms.rs 第 90 至 142 行 · codex-rs/sandboxing/src/policy_transforms.rs 第 144 至 214 行 · codex-rs/core/src/session/mod.rs 第 2833 至 2846 行
加宽和批准是两件事。并集让这条命令多开一条路径这件事说得清。求交保证人批下来的不宽过请求。空集表示开口失败,不要解释成改成最严,也不要解释成整条命令禁止。
DeepSeek Harness:缺后端就拒绝执行
DSH 也切在子进程入口。confine 按当前主机选 runner,再把政策编成 runner 参数,用户命令放在 -- 后面。档案词汇只有三个文件模式,danger-full-access 不进包装。
缺后端时 fail closed,不退回原 argv。文案写明 refusing to run the command unconfined。Codex 在 Unix helper 缺失时同样不跑;Windows 关沙箱时把给不出实现收成 None,交给后面的策略层。
Grok:一次 apply,锁住当前进程
Grok 也有一个叫 SandboxManager 的类型,职责是 apply() 再 install()。注释写明:作用对象是当前进程,不可逆。编译产物是 CapabilitySet,不包每条命令的 argv。
平台不支持,或 apply 失败,都打警告、记 apply_failed,然后继续跑。后面的工具调用共享同一份 capability。Grok 不用为每条命令再编 argv,也没法在同一进程里给两条命令两套边界。
出处:packages/sandbox/sandbox-local/src/index.ts 第 316 至 333 行 · crates/codegen/xai-grok-sandbox/src/lib.rs 第 117 至 136 行
四条返回,三组输入
打开 should_require_platform_sandbox,用纸画出四条返回。然后对下面三组给出预测:Disabled 档案加托管网络;根可写加一条 deny、网络 Enabled;External 档案、网络 Restricted。
进阶一问:同一份 Disabled 档案,工具声明 Require,Windows 档位仍关着。类型会是什么,模型看见的 XML 会不会改口。
macOS:把安全策略拼成一个字符串
Seatbelt 吃的是一段文本。文本由静态基线加现拼的读写网段组成。路径不进这段文本,走旁边的参数表。
sandbox-exec 的策略正文只留占位符,真实路径走 -D。挖空一条路径时,要同时挡住这个节点、它的子孙,以及把这个节点搬走。
- 权限档案拆成读写网络manager.rs L375
- 固定使用 /usr/bin/sandbox-execseatbelt.rs L56
- 装入四份静态 sbplseatbelt.rs L21
- 按根生成 allow 与 require-notseatbelt.rs L476
- 可写根钉上 file-write-unlinkseatbelt.rs L508
- 排除子路径同时写 literal 与 subpathseatbelt.rs L551
- 拼网络段,managed 无端口走受限seatbelt.rs L309
- sections join 成 -p 文本seatbelt.rs L1012
- 路径写成 -DKEY=valueseatbelt.rs L1024
- 双短横线后接用户命令seatbelt.rs L1029
param 代号。真实目录名落在 -D。带引号的路径污染不了 SBPL 语法。subpath,mkdir .codex 会成功。两条 require-not 加上最后的 unlink,才把节点、子孙和搬家一起钉住。同事把仓库设成可写根,让模型在里面改代码。模型执行 mkdir .codex,想在仓库里落一份自己的配置。命令立刻失败,stderr 写着 Operation not permitted。可写根听起来像整棵树,实际不是。
再试一条更绕的路。模型先在仓库里写一个普通文件,再 mv 整个工作区,想把里面的 .codex 一起挪到沙箱外面。这条也失败。错误还是 Operation not permitted。
macOS 上 get_platform_sandbox 直接返回 MacosSeatbelt。那个布尔开关只对 Windows 有意义。包装器不是 Codex 自己的 exe,是系统里固定路径的 /usr/bin/sandbox-exec。PATH 上放一个同名二进制没用。
出处:codex-rs/sandboxing/src/manager.rs 第 62 至 76 行 · codex-rs/sandboxing/src/seatbelt.rs 第 52 至 56 行
策略文本分两层。四份 .sbpl 用 include_str! 编进二进制,当静态基线。动态段按这一次的可读可写根现拼。拼接顺序固定:基线、读、写、网络在前。全盘可读才加 preferences。受限读才加平台默认路径。祖先的 file-write-unlink 放最后,避免前面更宽的 allow 把 rename 用的 unlink 重新打开。
出处:codex-rs/sandboxing/src/seatbelt.rs 第 21 至 27 行 · codex-rs/sandboxing/src/seatbelt.rs 第 985 至 1012 行
基线第一句业务规则是 (deny default)。没写明的事一律拒绝。子进程继承这份底稿,sandbox-exec 拉起的 bash 再 fork 出来的子孙,仍在同一套规则里。
Seatbelt 的内核接口就是吃一份 SBPL 文本。能控的是文本怎么生成。默认拒绝,再按这一次的根往上开门,换语言重写也用得上。片段的顺序是合同:更窄的 deny 必须能压住前面更宽的 allow。
有人把工程放在 ~/work/app (copy) 这种带括号和空格的目录里,或者路径里出现引号。SBPL 里括号、引号、分号都是语法。路径一旦插进策略正文,用户目录名就能变成语法。sandbox-exec 解析失败,模型看到的是包装器错误,不是沙箱拒绝。
动态读写规则只往正文里写 (param "WRITABLE_ROOT_0") 这类键名。真实路径写成 -DKEY=value,成为独立 argv。排除子路径同样走参数表。策略正文和 -D 必须同时出现,测试把这件事锁成合同。
出处:codex-rs/sandboxing/src/seatbelt.rs 第 1022 至 1028 行
安全策略如果最终是一段文本,用户可控的字符串不要进这段文本。模板只含占位符,真实路径、域名、端口走另一份参数表。和 Seatbelt 语法无关。Python 里同样是模板加 argv。
只写 subpath,第一次创建这个目录会漏,mkdir .codex 会成功。元数据保护变成空壳。不加 file-write-unlink,rename 能把只读子树挪出挖空区。下一次若仍按原路径授权,实际写的是链接对面。
排除子路径时,require-not 要写两条。literal 管目录自己,subpath 管下面的内容。两条一起放进 require-all,是并且关系。顺序不敏感,少写 literal 才是。
出处:codex-rs/sandboxing/src/seatbelt.rs 第 548 至 555 行
可写根默认保护三个顶层名字:.git、.agents、.codex。可写根目录本身再钉一条 deny file-write-unlink。可写根中间若有用户可控的符号链接,拼装直接报错,不会跟着链接走。
出处:codex-rs/protocol/src/permissions.rs 第 24 至 33 行 · codex-rs/sandboxing/src/seatbelt.rs 第 504 至 509 行 · codex-rs/sandboxing/src/seatbelt.rs 第 441 至 450 行
网络是另一段动态文本。要走代理却没有可用端口,返回受限网络段加网络基线,不给 (allow network-outbound) 这种整网放行。网络基线还在,只是没有 blanket outbound。
出处:codex-rs/sandboxing/src/seatbelt.rs 第 309 至 335 行
挖空一条路径,同时挡住节点本身和它的子孙。再挡住对这个节点的 rename。三条一起生成,用测试锁住形状。跨平台错误文案不同:macOS 常见 Operation not permitted,能被现有关键词表接住。不要假设另外两个平台也是这句话。
出处:codex-rs/sandboxing/src/denial.rs 第 50 至 58 行
Claude Code:先放开读,路径 stringify 后进文本
Claude 从 (allow file-read*) 往下加 deny,再在 deny 里 re-allow。后写规则优先。路径用 JSON.stringify 包一层,嵌进策略文本。包装命令从 PATH 里找 sandbox-exec,不写死 /usr/bin。两边都要防 rename,Claude 同样生成 deny file-write-unlink。
Codex 从 (deny default) 往上加 allow,路径留在 -D。付的是生成器复杂度,换来用户路径不进入 SBPL 语法。Claude 付的是后写顺序必须按对,换来配置面更接近 allowAllExcept。
DSH 与 Grok:路径进文本,靠转义或直接失败
DSH 默认 (allow default),再 (deny file-write*),只把写权收回来。路径经 sbplString 转义反斜杠和双引号后,直接嵌进 -p。没有 -D 参数表。sandbox-exec 从 PATH 取。本机 macOS 上做不到「可写根里挖掉 .codex」这一粒度。
Grok 不包一层 sandbox-exec。它把 SBPL 片段交给当前进程。路径照样进文本,控制字符直接失败,避免沙箱报 active、那条路径却没被挡住。内核 apply 失败时继续跑。Codex 在 Seatbelt 准备失败时返回错误,命令不会裸跑。
少一条 require-not,漏在哪里
演示里先打开写根和挖空 .codex,看 mkdir .codex 探针是红的。再打开「只留 subpath」,探针变绿。用自己的话解释:subpath 管的是目录下面,为什么挡不住第一次创建这个节点。对照 seatbelt.rs 第 548 至 555 行的注释即可,不要在真实仓库里执行破坏性命令。
-D,正文只留占位。挖空要同时挡住节点、子孙和搬家。Seatbelt 挡得住策略里拒绝的操作,挡不住被写宽的 allow,也挡不住模型在可写根里改业务代码。
Linux:先建视图,再上 seccomp,最后 exec
同一条读取,Mac 上回 Operation not permitted,Linux 上回 No such file or directory。差别在于 Linux 先换进程能看见的文件树,再收紧它能调用的系统接口。
- 读入权限档案并做 WSL1 预检manager.rs L413
- 拼出 helper argv,加上权限档案 JSONlandlock.rs L23
- 外层 run_main 决定走 bwrap 还是捷径linux_run_main.rs L152
- 拼内层命令,带上 --apply-seccomp-then-execlinux_run_main.rs L1520
- bwrap 按顺序 ro-bind、bind、unsharebwrap.rs L306
- exec bwrap 时插入 --as-pid-1launcher.rs L39
- 内层 capget,确认能力已清零linux_run_main.rs L216
- 打开 PR_SET_NO_NEW_PRIVS,然后装 seccomplandlock.rs L61
- fork 用户命令,父进程 waitpid 收孤儿linux_run_main.rs L243
- execvp 接管进程镜像linux_run_main.rs L1565
同事在 Mac 上跑 Codex,agent 去读 ~/.ssh/id_rsa,Seatbelt 立刻回 Operation not permitted。同一份仓库放到 Linux 笔记本上,常见文案变成 No such file or directory。路径常常根本不在挂载里。
如果先打开 PR_SET_NO_NEW_PRIVS,再去调系统自带的 bwrap,不少发行版上的 setuid 二进制会直接起不来。沙箱在装得最全的机器上反而失败。seccomp 是一套内核过滤器,用来规定进程还能调用哪些系统接口;no_new_privs 是它的前置条件,也会挡住 setuid 提权。
默认路径两次进入同一份 helper。外层只拼 bubblewrap,把文件系统换成默认只读,再叠可写根,.git、.agents、.codex 即使落在可写根里也保持只读。内层才打开 no_new_privs、装网络 seccomp,然后 fork、把进程镜像让给用户命令。
函数头把顺序写成三步:
/// Entry point for the Linux sandbox helper.
///
/// The sequence is:
/// 1. When needed, wrap the command with bubblewrap to construct the
/// filesystem view.
/// 2. Apply in-process restrictions (no_new_privs + seccomp).
/// 3. `execvp` into the final command.
openai/codex,核对文件 codex-rs/linux-sandbox/src/linux_run_main.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这三步就是默认路径的合同。字段注释把原因写在旗标上:bubblewrap 可能依赖 setuid,必须先建好视图,再收紧。出处:codex-rs/linux-sandbox/src/linux_run_main.rs 第 117 至 121 行;codex-rs/linux-sandbox/src/landlock.rs 第 57 至 65 行
能看见什么,和能调用哪些 syscall,是两道墙。先换世界,再收紧调用。换成别的语言重写,发行版上只要还有 setuid 包装器,这个顺序就还成立。
bubblewrap 和 seccomp 必须发生在即将变成用户命令的那个进程里。主 CLI 自己 unshare 再 exec,失败会把整次会话带走,也没法在 PID namespace 里留下一个收孤儿的 1 号进程。超时和取消会留下睡眠进程。
Linux 看 argv[0] 的 basename。文件名已经是 codex-linux-sandbox 就保留,否则改成这个别名,把同一份二进制拐进 helper。外层 exec 的不是 ls,是 helper 自己,带着 --apply-seccomp-then-exec。launcher 还会插进 --as-pid-1,让 bubblewrap 自己当命名空间里的 1 号进程。
内层先 capget。effective 或 permitted 任一非零,立刻 panic,命令不会跑。过了这道检查,才 fork。子进程 execvp,父进程 waitpid(-1) 把子孙都收掉,退出码原样传出。出处:codex-rs/linux-sandbox/src/linux_run_main.rs 第 216 至 221 行、第 242 至 243 行;codex-rs/linux-sandbox/src/launcher.rs 第 38 至 39 行
独立辅助进程加先包装、再收紧,是跨平台同一类自我调用。Windows 看隐藏参数,Linux 看 arg0。包装器失败时,固定退出码加固定 stderr 前缀更好自动归因。
类型名还叫 LandlockCommand,crate 目录也叫 linux-sandbox,网上不少资料会写成 Codex 在 Linux 上用 Landlock。当前默认文件系统沙箱已经是 bubblewrap。Landlock 是显式打开的 legacy 回退,特性开关标成 Deprecated,默认关。legacy 吃不下受限读,也吃不下拆分出来的嵌套只读。
如果 bwrap 失败后静默换 Landlock,模型会按「我被关在视图里」规划,实际却还看得见主机文件树。下一步就会分叉。
外层写明 never falls back。bubblewrap 失败就失败。WSL1 在需要 bubblewrap 时,transform 返回 Wsl1UnsupportedForBubblewrap,命令不会进 helper。全盘可写且不走代理时,根本不会进 bwrap,WSL1 可以过。user namespace 建不出来给启动警告,不会改走更弱的后端。出处:codex-rs/linux-sandbox/src/linux_run_main.rs 第 290 至 294 行;codex-rs/sandboxing/src/manager.rs 第 413 至 420 行、第 696 至 710 行;codex-rs/features/src/lib.rs 第 1064 至 1069 行
跨平台能力不对等时,最容易犯的错是用「我们有 Linux 沙箱」覆盖所有 Linux。本机有新 bwrap、CI 容器禁了 user namespace、同事还在 WSL1,三台机器不是同一道墙。产品文案和模型说明必须按实际启用的那一层来写。
DeepSeek Harness:探得过就换档,合成一拍
DSH 的 Linux 链写死为 bwrap 再 landlock。bwrap 档是视图加 exec,没有 seccomp。探不过才走 landlock-run:在自己身上装规则,再 exec 目标命令。文件系统限制和 exec 合在同一份 main 里。这条路径没有 setuid bwrap,所以不必把过滤器再拆到下一拍。
两边都走独立辅助进程。DSH 启动器失败是 125,还要同时看到 landlock-run: 致命行,更好自动归因。Codex helper 失败是 panic 或原样 wait status。两条链都探失败,DSH 拒绝裸跑。它可以换探测过的第二档,换完仍 fail closed。Codex 默认路径失败不回退 Landlock。出处:packages/sandbox/sandbox-local/src/index.ts 第 159 至 166 行;native/landlock-run/packages/entry/src/main.c 第 254 至 261 行
Grok:三拍打在 agent 上,失败可以回退
Grok 的顺序和 Codex 同一条工程理由:先让可能依赖 setuid 的 bwrap 跑完,再在已经进命名空间的进程里收紧。差别在主体。Grok 把三拍打在 agent 进程上,命令只继承结果。视图是补丁式的 --bind / / 再盖 deny 路径,主机树还在。子进程 spawn 时,若需要限网,才在 pre_exec 里装 seccomp。
bwrap exec 失败且不要求 read-deny 时,它警告一句,回退到 Landlock。内置 Profile 的 apply 失败记警告并继续,is_active() 才是真状态。Codex 外层写明 never falls back。出处:crates/codegen/xai-grok-shell/src/config/mod.rs 第 1293 至 1320 行;crates/codegen/xai-grok-sandbox/src/lib.rs 第 181 至 192 行
同一条读取,换开关之后卡在哪
把探针留在读 /etc/shadow,先打开 WSL1。写下预测:停在哪一层,文案是什么。再关掉 WSL1,打开 legacy Landlock,看受限读会不会变成 UnsupportedOperation。
进阶一问:探针换成 connect 外网。为什么默认路径会穿过视图、停在 seccomp,文案从 No such file 变成 EPERM。
Windows:受限令牌、防火墙过滤器与两个专用系统用户
没有 seatbelt,也没有 bubblewrap。Windows 上的沙箱是三道关卡叠起来的:令牌管写,专用账户管你是谁,WFP 按这个身份滤网。AppContainer 对不上这份权限模型,整套还默认关着。
- Windows 开关关着,平台沙箱返回空,后面不会包包装器manager.rs L62
- 需要沙箱时,用自己的 exe 加隐藏参数当包装器wrapper.rs L20
- 三个标志裁出受限令牌:削特权、LUA、写必须过交叉检查token.rs L480
- RestrictedToken 这一档,读检查不看 capability SIDwindows.rs L109
- Elevated 先用专用账户登录,再从那个令牌往下裁runner_client.rs L348
- 两个账户名写死:Offline 与 Onlinesetup.rs L50
- 网络策略未开或走代理时,选 Offline 身份setup.rs L706
- 只给离线账户装持久 WFP 过滤器wfp.rs L75
- 用户配置根默认挖空 .ssh 等目录setup.rs L56
- 没有后端时,只读档案加 Never,未匹配命令必须 Forbiddenexec_policy_windows_tests.rs L113
同事在 Mac 上跑 Codex,agent 去碰用户目录里的 SSH 私钥路径,立刻被 Seatbelt 拦住,错误写着 Operation not permitted。同一条读取放到 Windows 上,报错变成 Access is denied。再换一台没开 Windows 沙箱的机器,这条命令可能根本走不到 ACL,execpolicy 先把它判成 Forbidden 或 Prompt。
三台机器、同一份意图、三种失败。模型看到的下一句话不一样,下一步也会跟着变。如果 Windows 上假装有和 Unix 一样的默认沙箱,模型会按自己被关着去规划,实际命令裸跑。
macOS 限制这个进程能对哪些路径做哪些操作。Linux 限制这个进程能看见什么、能调哪些系统调用。Windows 没有 namespace,也没有 seccomp。Codex 换成另一套问题:这个身份能碰哪些资源。
令牌决定你是谁。RestrictedToken 档从当前用户令牌裁,Elevated 档先用专用账户登录,再从那个令牌裁。两边最后都进同一组标志:DISABLE_MAX_PRIVILEGE、LUA_TOKEN、WRITE_RESTRICTED。特权被削薄,写必须同时通过普通 ACL 和 restricting SID 的交叉检查。
读在 RestrictedToken 这一档仍然跟当前用户走。源码自己写了:WRITE_RESTRICTED 令牌上的 capability SID deny-read ACE 不参与读检查,所以读限制必须走 Elevated,裸跑会被直接拒绝。出处:codex-rs/windows-sandbox-rs/src/token.rs 第 480 行;codex-rs/sandboxing/src/windows.rs 第 109 至 117 行
主体能做什么,和客体能被谁碰,是两套隔离原语。换语言重写,Windows 上仍然没有 bwrap 可以调用。该问的还是:你有没有一份身份,这份身份被允许碰什么。
包装方式也是同一类自我调用。Windows 用自己的 exe 加上隐藏参数 --run-as-windows-sandbox,跟 Linux 改 arg0 是同一思路。出处:codex-rs/windows-sandbox-rs/src/wrapper.rs 第 20 行
WFP 是 Windows Filtering Platform,一套内核里的包过滤框架。它和防火墙规则都按 SID 匹配。一个 SID 只能对应一套网络策略。同一身份没法同时表示完全没网,也没法同时表示可以走代理或出网。
所以用两个本地用户当两个网络身份:CodexSandboxOffline 和 CodexSandboxOnline。from_permissions 在强制走代理,或网络策略未启用时,选 Offline。离线身份装出站阻断、代理端口白名单,再加 12 条 WFP,覆盖 ICMP、DNS 53、DNS-over-TLS 853 和 SMB。在线身份不装这套阻断。
Unix 的网络隔离是给进程换一张网卡视图。WFP 是进程还在主机网络栈上,只是这个 SID 发出的特定流量被挡住。进程仍然能看见网卡、解析失败、连别的端口。
过滤器用稳定 GUID 标识,带着持久标志,重启后还在。卸载和崩溃都不会自动拆掉它们。仓库里检索不到删除用户或拆除 WFP 的函数。WFP 失败被写成非致命,防火墙失败会让 setup 整段失败。出处:codex-rs/windows-sandbox-rs/src/setup.rs 第 50 至 51 行、第 706 至 714 行;codex-rs/windows-sandbox-rs/src/wfp.rs 第 69 至 95 行
按身份做访问控制,身份数量必须覆盖策略组合数。网络策略有两档,就需要两个身份。这和用两张门禁卡进两栋楼是同一件事,跟 WFP 这个具体 API 无关。
有人会问为什么不上 AppContainer。它的可读范围默认很窄,任意路径读取要改宿主 DACL。Codex 需要 workspace 可读、平台根可读,再按 deny 列表挖空。受限令牌加 ACL 更贴这份权限模型。在 codex-rs/ 里检索 AppContainer,业务代码零命中。对照仓库 DeepSeek Harness 把同一判断写成了明文:AppContainer 做不了任意路径读取。
Elevated 要把读和网络也管住,就要建账户、弹 UAC、改防火墙和 WFP。企业策略可能禁止本地建用户。把默认设成 Disabled,等于把这道产品摩擦留给第一次启用。
枚举自己把默认值写成关。没写 windows.sandbox、两个 legacy flag 也没有时,就落回 Disabled。legacy flag 已经被标成 Removed。
pub enum WindowsSandboxLevel {
#[default]
Disabled,
RestrictedToken,
Elevated,
}
openai/codex,核对文件 codex-rs/protocol/src/config_types.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,#[default] 钉在 Disabled 上。关掉之后,平台沙箱确实没了。get_platform_sandbox(false) 在 Windows 上返回空,select_initial 把它变成 SandboxType::None。命令仍可能被策略层拦住。Windows 测试锁住了这条:没有后端时,只读档案加 Never 审批,未匹配的 cmd.exe /c dir 必须是 Decision::Forbidden。出处:codex-rs/sandboxing/src/manager.rs 第 62 至 72 行、第 301 行;codex-rs/core/src/exec_policy_windows_tests.rs 第 113 至 130 行
隔离深度跟安装预算挂钩。只防模型误改仓库,当前用户受限令牌就够。要防读出密钥再从这台机器往外传,必须再做读限制和按身份的网络过滤。承诺了做不到的墙,模型和用户都会按那道墙做决策。
跨平台能力不对等时,对外说法必须按实际启用的那一层来写。macOS 默认就有 Seatbelt,Windows 默认可能是 Disabled。用户听到有沙箱,会以为三台机器同一道墙。
DeepSeek Harness:同一组标志,停在写限制
DSH 的 @deepseek-ai/dsh-sandbox-windows-acl 用同一组 CreateRestrictedToken 标志:DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED。README 写明读、网络、进程可见性不受限,enforcement 标成 partial。它不建专用账户,不装 WFP。
设计笔记把 AppContainer 否掉的原因写死了:AppContainer 令牌没有环境读权限,任意路径读取必须预先授权,对不上 harness 的读模型。mxc 被否是因为系统版本底线太新,任意路径读取还是要改宿主 DACL。Codex Elevated 用两个账户加 WFP 把读和网络身份也做进去,代价是安装摩擦和卸载残留。DSH 换到的是装得上、跑得动,读侧和网络侧留给别的机制。
已核对 DSH token.ts / win32-abi.ts / README / 设计笔记 · 2026-08-22 · DSH · 沙箱:从 seatbelt 到执行世界Claude 拒绝执行,Grok 把平台标成 unknown
Claude Code 在原生 Windows 上写明没有沙箱。企业策略要求沙箱且禁止无沙箱命令时,PowerShell 直接拒绝执行,文案是 sandboxing is not available on native Windows。它不假装有一层隔离。Grok Build 的沙箱 crate 把平台写成 linux/landlock 和 macos/seatbelt,其余标 unknown。在 xai-grok-sandbox 里检索 AppContainer、RestrictedToken、CreateRestrictedToken,没有对应实现。
答案分成三档。Claude 和 Grok 在 Windows 上不做文件系统沙箱。DSH 做了写限制,装得上,读和网络留给别的机制。Codex 把身份、ACL、WFP、专用账户全做进去,摩擦更大,所以默认关着。
已核对 Claude PowerShellTool.tsx、Grok types.rs · 2026-08-22 · Grok · 五种沙箱 Profile同一条读取,换档之后卡在哪
把演示里的动作留在读工作区外,档位从 Elevated 降到 RestrictedToken。先写下你的预测:哪一关会放行,哪一关会消失,最后是 Access is denied 还是穿过。再点播放核对。
进阶一问:把动作换成发网络。为什么 RestrictedToken 档第三关是跳过,Elevated 档才会亮起 WFP。
execpolicy:让策略文件自带测试用例
白名单写错,通常要等线上才知道。Codex 把正反例写进规则本身,加载时就地跑一遍。规则和例子打架,这份策略进不了会话。
这一步看见的 argv
策略自带的测试用例
- 读入策略文本,开始 parseparser.rs L57
- Starlark 求值 prefix_rule,缺 decision 时默认 allowparser.rs L348
- 规则和正反例先挂到待校验队列parser.rs L405
- 先跑反例,命中就报 ExampleDidMatchparser.rs L145
- 再跑正例,没命中就报 ExampleDidNotMatchparser.rs L147
- 包装命令先拆成内层 argvexec_policy.rs L831
- 按第一个 token 精确取规则桶policy.rs L334
- 多规则或组合命令取最严policy.rs L403
- 判定映射成 Skip、NeedsApproval 或 Forbiddenexec_policy.rs L375
你给团队写一条禁令,pattern 写成 git 加 reset。本意是拦 --hard。过了一周有人报,--keep 也被拦了。前缀一短,后面跟什么都命中同一条。
再过一周,模型用 bash -lc 包了一层。禁令按字面 argv 去匹 bash,第一条对不上,命令进了启发式通道。
白名单每条都在赌两件事。一是 pattern 刚好覆盖你想拦的。二是它不会误伤你想放的。这两件事通常要等线上才知道。下一次改 pattern 的人,也看不到作者当初怕误伤哪条命令。
Codex 把正反例写进规则本身。match 里是这条规则必须命中的命令,not_match 里是它必须放过的命令。prefix_rule 并不当场跑例子,它先把规则和例子推进待校验队列。整份 Starlark 求值完,parse 再统一校验。
顺序固定。先跑反例,再跑正例。反例误命中,报 ExampleDidMatch。正例一个都没命中,报 ExampleDidNotMatch。两种错都让 parse 返回失败,Policy 不会被 build 出去。出处:codex-rs/execpolicy/src/parser.rs 第 57 至 78 行,以及第 133 至 151 行
crate README 把这件事写成一句话:它们是加载期校验的示例调用,可以当成单元测试。字符串和 token 数组两种写法进解析器后都会变成 token 列表,字符串走 shlex 拆词。校验时启发式回调传空,正例必须靠真实前缀规则命中,不能靠启发式凑数。
白名单的典型失败,是作者以为 pattern 是这个意思。把以为写成例子,机器可以反对。这条不依赖 Starlark,换成 JSON 或 YAML 也能抄。
规则和例子写在同一处,策略文件被拷进用户目录或被 overlay 下发时,例子跟着走。加载器没有只读规则、不跑例子的分支。漏写例子等于漏写测试,加载器不会替你编例子,写了就会强制执行。
正则能写 git reset 后面跟任意参数,也能写出作者自己都读不懂的例外。叠加规则时如果取最宽,用户层一条宽松的 allow 就能盖住系统层的 forbidden。
规则本体是一段前缀。匹配是精确字符串相等,没有 glob,没有正则。git reset --hard 能吃后面再跟 origin/main 的命令,因为多出来的 token 不参与比较。它吃不了中间插了 --config 的写法,因为第二个 token 对不上。出处:codex-rs/execpolicy/src/rule.rs 第 46 至 59 行
规则按第一个 token 放进桶。查找时先按 argv 第零项精确取。取不到,再考虑把绝对路径收成 basename。命中多条时,对 decision 做 max。Decision 派生了 Ord,变体书写顺序就是严重程度:Allow 小于 Prompt 小于 Forbidden。
组合命令先拆段再摊平,再取一次 max。一段 git status 是 Prompt,一段 git commit 是 Forbidden,整条管道仍是 Forbidden。出处:codex-rs/execpolicy/src/policy.rs 第 265 至 287 行,以及第 402 至 411 行
叠加只能加严,不能放宽。这个序写在枚举变体上,运行时没有另一张优先级表可以写错。换语言重写,三个字符串做成有序枚举,聚合用一次 max 就够。
前缀强迫你把意图落成 token 序列。代价是插在中间的参数会让规则失效,作者必须用更短的前缀或另写一条。正反例就是用来接住这条代价的:写短了,反例会在加载期先崩。
禁令写的是 git reset --hard。模型写成 bash -lc 包一层。按字面 argv 去匹,第一条是 bash,禁令不亮。
判定前先走 parse_shell_lc_plain_commands。它要求脚本只由纯词命令加 &&、||、分号、管道组成,拒绝重定向、替换、括号、控制流。过关后按命令节点切成多段 argv。bash -lc 包着 git reset --hard,会被拆成内层那三段,再拿去匹前缀。出处:codex-rs/core/src/exec_policy.rs 第 831 至 858 行
拆不出来时,整段 argv 当一条命令,交给启发式和后续沙箱。空引号插在参数里还能还原。空引号插在命令名里,word-only 解析失败,前缀规则看不见 git,这条命令掉进启发式。
解析器承认自己吃不下的东西,就不假装已经看懂脚本。这是 fail closed 的通用形状。它挡得住解析成功但漏掉危险段。拆失败之后,启发式和沙箱还在。
DeepSeek Harness:两个旋钮加一个下拉框
DSH 不写命令级 pattern。权限预设把沙箱模式和审批策略捆成一包。默认表只有两行:workspace-write 配 ask,danger-full-access 配 never。审批策略本身只有 ask 和 never。
旋钮好懂。你无法在预设里写出只禁 git reset --hard、其他 git 照常。那种例外要么进沙箱拒绝后的升级审批,要么进自定义旋钮组合,界面上显示为保留名 custom。下拉框覆盖日常切换,点名禁止的长尾它盖不住。
Claude Code:工具名加可选内容的 allowlist
规则字符串长成 Bash,或 Bash(npm install),或 Bash(git *)。解析器按括号切开工具名和内容。shell 规则再分成精确、前缀、通配三类。
检索 match、not_match、example 作为规则字段,没有加载期正反例校验。git * 一旦写进 allow,git reset --hard 也会被这条通配吃掉,除非另写一条更具体的 deny。Codex 用更短的精确前缀加反例,把例外钉在加载期。Claude Code 把例外留给规则叠放顺序和运行时确认。
前缀写短,加载还是判定
把禁止 git reset --hard 的 pattern 改短成 git 加 reset,not_match 仍写 --keep。加载时会走 ExampleDidMatch 还是 ExampleDidNotMatch,命令还有没有机会进入判定。
进阶一问:同一条坏规则下,ls -l 会不会先出 allow。演示里把策略切到「前缀写短」再播一遍,对一下你的推演。
审批策略:同一条命令,问不问看哪两颗旋钮
默认问不问跟沙箱种类绑在一起。问过一次之后,记住的是完整命令,还是一段前缀。
- 读当前 turn 的 AskForApprovalprotocol.rs L924
- 看 FileSystemSandboxKind 是不是 Restrictedpermissions.rs L227
- Never 给 Skip,UnlessTrusted 给 NeedsApprovalsandboxing.rs L198
- OnRequest 或 Granular 只在 Restricted 时要问sandboxing.rs L200
- Granular 要问且关掉沙箱审批则 Forbiddensandboxing.rs L209
- execpolicy 的 Prompt 再过第二道闸exec_policy.rs L214
- 会话缓存只收 ApprovedForSession,认精确 keysandboxing.rs L108
- 策略改了,给模型的说明书一起改permissions_instructions.rs L271
你把审批留在 on-request,沙箱从 workspace-write 拧到 danger-full-access。十分钟前那条出沙箱命令还会弹窗。现在不弹了。你没改审批旋钮。
审批策略 AskForApproval 是当前 turn 用哪套问人规则,类型里有四个变体。文件系统沙箱种类是三个,默认 Restricted,意思是只读或只能写工作区。这两颗旋钮在同一个判定函数里。改其中一颗,另一颗的行为会跟着走。出处:codex-rs/protocol/src/protocol.rs 第 924 至 947 行,以及 codex-rs/protocol/src/permissions.rs 第 227 至 232 行
一条命令先读审批策略,再看文件系统是不是 Restricted,再让 execpolicy 把 Allow、Prompt、Forbidden 叠上去。execpolicy 是按命令文本给出三态的规则文件。
默认问不问写在 default_exec_approval_requirement。Never 这一层给 Skip。UnlessTrusted 这一层给 NeedsApproval。OnRequest 和 Granular 只在 Restricted 时要问。read-only 和 workspace-write 都是 Restricted,所以这两格的默认结局一样。danger-full-access 把 kind 变成 Unrestricted,默认函数走 Skip。出处:codex-rs/core/src/tools/sandboxing.rs 第 198 至 230 行
Granular 还多一个闸。需要审批、且 sandbox_approval 关掉时,直接 Forbidden。文件系统如果已经是 Unrestricted,needs_approval 先变成假,Forbidden 分支进不去。关掉沙箱审批,只挡住本来就要弹的窗。
Never 遇到 execpolicy 的 Prompt,提问会升级成拒绝。它并不表示什么都准跑。出处:codex-rs/core/src/exec_policy.rs 第 214 至 236 行
untrusted 写不进配置。类型还在,公开字符串已经退休。用户显式写出,整次加载失败。没写才看项目信任:已信任走 OnRequest,明确未信任走 UnlessTrusted。出处:codex-rs/core/src/config/mod.rs 第 3607 至 3625 行
还有一个加载期硬拒绝。requirements 不允许 danger-full-access,配置却写了 approval_policy = "never",加载器会先把权限档案回落到只读。只读加上从不提问,等于模型在窄沙箱里还没人可问。这种组合直接判非法。出处:codex-rs/core/src/config/mod.rs 第 3969 至 3979 行
全盘可写时,再问一次出沙箱没有意义。两颗旋钮绑在一起,少弹窗。代价是改沙箱会静默带走审批行为。换个语言重写,仍要先回答:全权模式还要不要问人。
策略枚举靠穷尽分支表达规则。新加一个变体,编译器会逼所有判定函数表态。这是类型在替运行时守门。
弹窗上两条记住长得很像。一条是本会话记住这条命令。一条是把前缀写进 execpolicy,跨会话生效。点错了,后面的边界题就会答反。
常规 exec 弹窗默认菜单里,甚至没有 ApprovedForSession。有网络上下文时才带上它。普通命令给一次批准,有前缀修正提案时再加按前缀记住,最后是取消。用户最常碰到的记住,是前缀那一条。出处:codex-rs/protocol/src/approvals.rs 第 314 至 347 行
会话缓存活在 ApprovalStore 里,跟会话同寿命。key 是规范化后的完整命令,外加 cwd、环境和 execpolicy 指纹。所有 key 都已经是 ApprovedForSession 才命中。Approved 一次放行不会进 map。出处:codex-rs/core/src/tools/sandboxing.rs 第 64 至 116 行
npm run test 批准进会话,npm run lint 是另一把 key。/bin/bash -lc 和 bash -lc 在能拆出单一明文命令时,缓存成同一组 token。会话缓存认完整命令,不认 npm *。前缀扩张只走 execpolicy。
改审批策略不会清空这座 map。key 里没有 AskForApproval。中途从 OnRequest 改成 UnlessTrusted,已经缓存的批准仍算数。改 execpolicy 会改指纹,缓存才会失效。
两套记住对应两种威胁。精确 key 挡不住换参数。前缀挡得住换参数,也容易把包装命令的范围扩太大。两层分开,才能各自配禁建议名单。一天弹窗不多的时候,先只做一次放行也成立。
策略改了,只改运行时分支不够。模型看到的说明必须同步改,否则它会按旧规则去要 require_escalated。Never 下面如果说明书还在教它提权,运行时会把提问升级成拒绝,模型只会反复撞墙。
权限说明是一段 developer 消息。先选沙箱模板,再选审批模板。Never、UnlessTrusted、OnRequest 各有现成 markdown。Never 那份只有一句:不要再给 sandbox_permissions,命令会被拒。Granular 没有第五份文件,按五个开关现场拼允许列表和拒绝列表。两套说明拼在同一段里,模型一次看到当前组合。出处:codex-rs/prompts/src/permissions_instructions.rs 第 271 至 291 行
钩子在人前面。钩子给出 Allow 或 Deny,弹窗就不会出现。钩子的 Allow 是一次 Approved,不写会话缓存。
运行时和模型说明书是同一份合同的两面。判定函数改了,字典那一行必须一起改。把这两份放在同一个模块,用同一组测试喂两边,换语言也用得上。
DSH:两颗旋钮,一次授权不扩大
DSH 的审批策略只有 ask 和 never。never 在分派给回答者之前就返回 rejected,后挂的监听器改不了这个承诺。授权结果只有 allowed-once。没有本会话缓存,没有前缀修正。问过一次,下一次还问。出处:packages/interaction/user-approval/src/index.ts 第 84 至 94 行,以及第 304 至 312 行
沙箱和审批是两颗独立旋钮。用户看见的下拉框是预设表:workspace-write 绑 ask,danger-full-access 绑 never。点下一档时分别调用两颗 setter。对不上表就显示 custom。所以 DSH 可以单独把审批留在 ask、把沙箱拧到全盘可写。Codex 的 OnRequest 在 Unrestricted 上默认 Skip,这个组合在默认函数里不可独立存在。出处:packages/interaction/permission-presets/src/index.ts 第 167 至 176 行
Claude Code:记住写进规则表
Claude Code 对外的权限模式是五档,外加内部的 auto 和 bubble。规则来源包括 userSettings、projectSettings、session、cliArg。判定先查整工具级 deny,再往下走。出处:restored-src/src/types/permissions.ts 第 16 至 29 行,以及 restored-src/src/utils/permissions/permissions.ts 第 1169 至 1181 行
alwaysAllowRules 可以按来源记下允许项,session 是其中一档。下次按规则匹配,不必完整 argv 相等。代价是匹配函数必须自己防包装。Codex 把扩大范围交给 execpolicy 前缀和禁建议名单。dontAsk 接近 Never。Codex 没有公开的全放行审批策略,Never 仍会被 execpolicy 拦住。出处:restored-src/src/types/permissions.ts 第 54 至 62 行,以及第 433 行
拧沙箱,弹窗还在吗
审批留在 on-request,沙箱从 workspace-write 拧到 danger-full-access。再跑一条会写工作区外路径的命令。弹窗还在不在?为什么?
接着把同一条 npm run test 按会话批准,再提交 npm run lint。缓存该不该命中?如果点的是按前缀记住,答案会不会变?
Guardian:让一个模型去审批另一个模型
审批弹窗多到人开始无脑点同意时,Codex 把决定权交给一次锁死的审查会话。超时、坏 JSON、连续拒绝,各有各的收场。
on-request 审批,用一次锁死的模型会话代替用户弹窗。审查材料按不可信证据处理,超时、跑崩、坏 JSON 一律停住动作。明确拒绝才记入熔断器,连拒到阈值就打断当前 turn。
- 判断本轮审批是否交给 Guardianreview.rs L186
- 扩展若声明接管则不再开审查会话review.rs L313
- 过滤 transcript,只留用户意图和工具证据prompt.rs L506
- 按 Full 或 Delta 拼审查材料prompt.rs L89
- 克隆父配置,锁成只读并写成 Neverreview_session.rs L1407
- trunk 空闲则追加,忙碌则 forkreview_session.rs L617
- 等待 JSON,超时上限 90 秒mod.rs L53
- 没有合法 JSON 就解析失败,可重试后关闸prompt.rs L714
- allow 放行,deny 写上禁止改道的指示review.rs L717
- 只有显式 Deny 才记入熔断器review.rs L711
- 达到连续或窗口阈值,就 InterruptTurnmod.rs L176
policy.md 的 Outcome rule 推演,用于展示四步法与熔断器的结构差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。你开着 Codex 改仓库。模型先跑 git status,再读两个文件,然后要 git push。下一分钟它要 curl,再下一分钟要往 /tmp 写备忘,再下一分钟要删掉那份备忘。弹窗叠弹窗。人开始只看命令头两个词,拇指已经形成肌肉记忆。
这就是审批疲劳。防线还在,判断已经不在。回路退化成一个总是亮绿灯的按钮。这时如果把自动放行交给刚写出这条命令的主模型,申请人兼审批人。如果失败时放行,超时和坏 JSON 就变成免费通行证。
Codex 把该不该自动放行抽成一次专职审查。它不替代沙箱,也不替代网络代理。它只接管一类本来会弹给用户的 on-request 审批。路由很窄:策略必须是 OnRequest 或 Granular,审查员必须是 AutoReview。默认审查员仍是用户。出处:codex-rs/core/src/guardian/review.rs 第 186 至 210 行
模块头把四步写死。
网络与凭据代理:模型看不见的那把钥匙
出站要过几道门。真 token 不进子进程。模型看见的是假值和一句 403。
- 拉起命令前,把真 token 换成同形状假值credential_broker.rs L92
- 沙箱只放行代理端口,直连 loopback 到此为止seatbelt.rs L325
- 解析 host,deny 先判且恒胜runtime.rs L553
- 本地字面量没有精确允许,按私有地址拦runtime.rs L578
- 域名解析到非公网,即便在名单里也拦runtime.rs L582
- allowlist 为空或未命中,回 NotAllowedruntime.rs L598
- 只有 NotAllowed 才问决策器network_policy.rs L349
- Limited 下非 GET、HEAD、OPTIONS 就拦config.rs L311
- HTTPS 要看见内层方法,MITM 缺失就拦http_proxy.rs L312
- 请求头带着假值,才注入对应的真凭据mitm.rs L301
你让模型去 GitHub 提一个 issue。它写出 curl,环境里有真 token。下一秒域名换成 evil.example,或者打到 169.254.169.254。只拦「不在名单里的域名」,deny 条目和内网地址会漏。空名单若默认放行,忘写配置等于全开。
判定顺序写死。先 deny,再本地或私有,最后 allowlist。空名单和没命中,都拦。决策器只能翻 allowlist 漏掉的案,翻不了 denylist,也翻不了本地网段。
出处:codex-rs/network-proxy/src/runtime.rs 第 549 至 552 行;codex-rs/network-proxy/src/network_policy.rs 第 346 至 378 行;codex-rs/network-proxy/README.md 第 161 至 162 行
*.example.com 不含 apex,**.example.com 才含。解析到私有 IP 的域名,精确写进名单也拦。本地字面量要精确写上 localhost 或 127.0.0.1,通配 * 不算。
出处:codex-rs/network-proxy/src/policy.rs 第 321 至 331 行;codex-rs/network-proxy/src/runtime.rs 第 1028 至 1043 行
被拦时回给命令进程 403,头是 x-proxy-error,正文是一句人话。not_allowed 和 not_allowed_local 对外都叫 blocked-by-allowlist,正文才能分开「不在名单」和「沙箱拦了本地」。这条 403 变成 exec 输出喂回模型,采样循环不会停。
出处:codex-rs/network-proxy/src/responses.rs 第 52 至 83 行
同一 pattern 既 allow 又 deny,有效值取大的。枚举序是 None < Allow < Deny。
出处:codex-rs/network-proxy/src/config.rs 第 19 至 27 行
默认拒绝、deny 恒胜、内网要显式开门,这是 SSRF 防护的通用形状。换语言重写,该问的还是同一组问题:没写名单时怎么办,冲突时谁赢,解析到内网的名字算不算放行。
token 跟着命令进 rollout。下一轮模型还能看见它,再下一轮可能进日志。凭据一旦进入模型上下文,后面每一层脱敏都是补救。
拉起命令前,凭据经纪把 GH_TOKEN、OPENAI_API_KEY 换成同长度、同前缀的假值。模型若 printenv,看见的是假的。假值进上下文,对上游无用。
出处:codex-rs/network-proxy/src/credential_broker.rs 第 92 至 119 行
出站时 MITM 按 host 过滤。请求头里必须带着假值,才换成真值。hook 的剥头、注头发生在经纪之后,可以剥掉刚注入的 Authorization。用户改过的环境值不还原。标记当不可信输入。经纪只覆盖 GitHub 和 OpenAI。
出处:codex-rs/network-proxy/src/mitm.rs 第 297 至 302 行
出网策略写错,模型还能凭 403 改主意。凭据进上下文,撤销成本高。所以真值在 spawn 前拿走,回填发生在代理进程内存里。假值保持形状,是为了让校验格式的客户端还能启动。
Go 的 net/http 对 loopback 绕过 HTTP_PROXY。你以为流量进了代理,其实它直连 127.0.0.1。本地有管理口、docker socket。策略文件写了名单,请求根本没走到那一层。
沙箱只放行代理端口。macOS 受限 Seatbelt 只写 localhost:{port}。Linux 走 ProxyOnly。allow_local_binding 为假时,NO_PROXY 写成空串,loopback 字面量也必须走代理。打开本地绑定等于承认 loopback 不再经过 allowlist。
出处:codex-rs/sandboxing/src/seatbelt.rs 第 309 至 336 行;codex-rs/network-proxy/src/proxy.rs 第 450 至 460 行
应用层代理挡不住不肯走代理的客户端。下一层必须是操作系统或防火墙,只留一个口。三道门叠在一起才构成托管网络。单独一道都挡不住另一道漏掉的面。
Grok:阀门在工具入口,loopback 默认放行
Grok 的 web_fetch 在工具内部做域名名单和解析后 IP 检查。空名单拦所有 URL,和 Codex 的 allowlist-first 同方向。path 还可以写成前缀,收窄到某一段文档。
SSRF 检查拦 RFC1918、link-local、CGNAT。loopback 明确放行,注释写 local development。bash 里的 curl 不走这张名单。Codex 把阀门放在所有子进程出口,所以要和沙箱绑在一起。
web_fetch/domain.rs 第 110 至 144 行;web_fetch/ssrf.rs 第 17 至 19 行
DSH:配置只存变量名,spawn 时按键名擦环境
DSH 把密钥做成引用。设置文件只带环境变量名,provider 在每次操作时 resolve。配置表面从不看见值。
子进程另有一层擦除。名字像 KEY、TOKEN 的键和所有 DSH_* 会被丢掉。它挡得住配置文件里出现真值。模型若还能 printenv,除非 spawn 用了这层擦除,环境里仍可能有真 token。
packages/credentials/credentials/src/index.ts 第 1 至 7 行;packages/subprocess/subprocess/src/index.ts 第 60 至 66 行 · DSH · 凭据每次现取
星号放行 localhost 吗
allow_local_binding = false,allowlist 只有 *。对 127.0.0.1 调一次判定,期望是什么。对公网 IP 8.8.8.8 再调一次,期望又是什么。
提示:本地字面量拒绝通配。allowlist 编译显式允许全局 *,denylist 编译拒绝它。
apply-patch,给模型设计一种 diff
模型填一份没有行号的补丁,人看的是事后算出来的 unified diff。同一处修改,两套格式各自会在哪一步翻车。
@@ -l,s +l,s 那四个数字;以及上下文对不上时,单文件为什么整份不落盘,跨多个文件时这个保证为什么不成立。
greet 里的 pass 换成 return 123- 文法里的
@@只有锚点,没有起始行和跨度parser.rs L20 - 更新文件的 chunks 必须按在文件中出现的先后排列parser.rs L74
- 有
change_context时,从line_index往下搜这一行file_update.rs L99 - 先整行精确比,再抹掉行尾空白,再两边 trimseek_sequence.rs L40
- 锚点找不到,立刻报 Failed to find context,不按附近行猜file_update.rs L109
- 单文件全部 chunk 在内存里算完,才调用 write_filelib.rs L695
- 跨文件失败时带着已经提交的 delta 返回,没有回滚lib.rs L453
- 给人看的 unified diff 是事后用 TextDiff 另算的file_update.rs L328
@@ 头要同时填对旧起始、旧跨度、新起始、新跨度。文件上面插两行,这四个数字一起废。@@ def greet():,运行时现搜。插两行也能对上,因为行号根本没进这份格式。你让模型改一个函数:把 greet 里的 pass 换成 return 123。它吐出标准 unified diff,头一行写成 @@ -47,3 +47,3 @@。
文件刚才被另一处编辑插了两行,greet 已经在第 49 行。模型是在带行号的摘录里数的,写补丁时还要自己加算起始行和跨度。这四个数字一起错,是常态。
patch(1) 会按行号去找,找不到就 fuzz。fuzz 再失败,整份补丁作废。更麻烦的是它可能把变更贴到邻近的另一个函数上。测试还是绿的,只是改错了函数。
Codex 把填写和阅读拆开。模型填的那份没有行号。更新一段时,头一行只写成 @@ def greet():。单独一个 @@ 表示从当前位置继续搜。定位交给运行时的 seek_sequence。
人看变更时,界面上再另外用 similar::TextDiff 生成一份标准 unified diff。工具参数里那份 Codex 格式到这里已经用完了。
codex-rs/apply-patch/src/file_update.rs 第 328 至 329 行
这份文法把没有行号写进了产生式。两种 @@ 写法都只带文本锚点:
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
openai/codex,核对文件 codex-rs/apply-patch/src/parser.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这三行就是给模型的 diff 头:有锚点,没有行号。发给模型的说明书把这套语言写成「stripped-down, file-oriented diff format designed to be easy to parse and safe to apply」。Add、Delete、Move 在文法里是三种标记,解析器按标记分发。模型不用记 ---、+++、/dev/null 和 rename 头怎么拼。
坐标靠加算,内容靠识别。模型数行号这件事,换一个模型、换一种语言都好不到哪去。把找到哪一段从填写时的算术,改成应用时的字符串搜索,这个分工不依赖 Rust,也不依赖 unified diff 这个具体格式。
模型写补丁时,行尾多一个空格,或者文件里是 en-dash、它写成了减号,都是高频事故。每次都整份失败,模型只能重写。按行号附近再试几行,又会回到 fuzz 贴错函数的老路。
seek_sequence 按四档从紧到松搜。第一档整行精确相等。第二档去掉行尾空白再比。第三档两边都 trim()。第四档把常见 Unicode 短横和弯引号收成 ASCII。四级都失败就返回空,报「Failed to find context」或「Failed to find expected lines」。没有按附近几行再试这个循环。
codex-rs/apply-patch/src/seek_sequence.rs 第 40 至 114 行
早期有一次事故,专门为奇怪的 Unicode 字符加了第四级。它只放宽空白和标点,不放宽位置。中文全角引号不在归一化表里,模型写了全角左引号,文件里是半角引号,四级都会失败。
容错要分清两类差异。行尾空格、弯引号是无意义的字节差,可以归一。行号偏了两行,是贴错地方,应该报错让模型重写。这个分界换语言重写也成立。
一份补丁里有两个 chunk。第二个对不上,第一个已经改进去了,文件会变成半成品。排查的人看到的是一份应用成功了一半的文件,比整份失败更难修。
单文件内部,compute_replacements 把每个 chunk 先收成替换列表,某一个对不上就立刻返回错误,还没走到 write_file。这个文件保持原样。
codex-rs/apply-patch/src/file_update.rs 第 109 至 113 行
跨文件是另一回事。apply_hunks_to_files 按 hunk 顺序写盘,失败时带着已经提交的 AppliedPatchDelta 返回,循环里没有回滚。测试 015 把这个钉死了:先成功新增 created.txt,再更新一个不存在的文件,磁盘上 created.txt 还在。
codex-rs/apply-patch/src/lib.rs 第 453 行,以及第 504 行起的 hunk 循环
算完再提交的范围,要和你能原子处理的单位对齐。一个文件可以先在内存里算完全部替换再写一次。多个文件已经落到磁盘上,回滚就要再写一遍,还要处理 Move 这种源和目标都动过的半成功。要不要跨文件事务,是产品选择,不是格式本身的承诺。写给模型的说明里,别把单文件的保证说成全局保证。
DeepSeek Harness:先读过,才能改
DSH 的 editIntent 查的是这个 session 有没有观测过这个文件。没观测过就抛 FS_NOT_OBSERVED。观测记录的是 dev:ino:size:mtimeNs:ctimeNs 拼出来的版本,不是内容 hash。
即便过了这道门,applyLiteralEdit 默认还要求 old_string 只出现一次,多处命中就抛 FS_AMBIGUOUS_EDIT。防错挂在事件门禁和字面量唯一上。Codex 没有先读约束,定位信息写在补丁里,运行时现搜;old_lines 出现两次时取第一处,没有歧义报错。
Claude Code:没读过就拒绝,多处命中也拒绝
FileEditTool 同时要两件事。文件必须先读过,没读过报 errorCode 6,原文是 File has not been read yet。 old_string 在文件里多于一处且 replace_all 为假时,报 errorCode 9,要求补更多上下文,把这一处单独标出来。
模糊只覆盖引号。findActualString 先精确搜,再把弯引号收成直引号搜。没有 Codex 那种行尾空白三级,也没有短横归一化。想少一次工具往返,抄 Codex 的格式;想对模型错误信息更具体,抄这几条带 errorCode 的拒绝文案。
两段相同的 old_lines,改哪一段
文件里有两段完全相同的 old_lines,模型只想改第二段,却没有给足够的 @@ 锚点。seek_sequence 会改哪一段,为什么?
进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么?
exec 与 wait:跑不完的程序怎么收场
与其让模型发二十次工具调用,不如让它写一段 JavaScript。这段程序在哪跑、能干什么、十秒跑不完又怎么办,本课讲这三个问题背后的两个思路。
- 模型可以在首行写一句 pragma,声明这次给多少让出时间description.rs L22
- 执行入口把这个毫秒数换成「跑到点就观察一次」的模式service.rs L77
- 超过十秒的预算再送一秒宽限,另受服务端上限封顶service.rs L198
- 定时器到点,把攒下的输出整包交出去,缓冲同时清空cell_actor/mod.rs L242
- 让出这件事被翻译成一句模型读得懂的话,带上 cell 编号code_mode/mod.rs L283
- 模型拿编号回来续跑,还能顺手改预算、限长度或直接叫停wait_handler.rs L24
- 脚本跑完,返回结果并关闭这个 cellruntime.rs L24
读五个文件再汇总,用普通工具调用要走五次完整往返。每次往返把整个文件内容推进上下文,模型下一轮还得把滚大的历史重读一遍。真正贵的地方在往返的节奏上,工具本身不贵。
那让模型写程序不就行了。麻烦在于,这段程序没有任何人审过一行,模型现写现交。给它一个能读文件、能发网络请求的运行时,等于把宿主的全部能力直接交出去。
Codex 给模型两个工具,exec 和 wait。exec 收一段 JavaScript 源码,扔进一个全新的 V8 isolate 当 async module 求值。所有工具挂在全局 tools 对象上,名字被规范化成合法的 JS 标识符,写起来就是 await tools.exec_command(...)。程序里想循环就循环、想分支就分支,中间值留在变量里,只有主动交出去的那部分回到模型。
关键在于这个运行时被削得很薄。工具说明书里对模型直说了它没有什么:
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
openai/codex,核对文件 codex-rs/code-mode-protocol/src/description.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这段文本本身就是发给模型的工具说明。没有 Node,没有文件系统,没有网络,连 console 都没有。这些能力不是忘了加,是特意不给。程序想产生任何副作用,只剩一条路,走 tools。那条路上审批和沙箱一样不少,该弹的窗照弹,该拦的照拦。
附带好处是要审查的面积小了。isolate 里如果能直接读文件,这一层就得自己再做一套文件权限;现在它什么都做不了,权限判断留在下一层就够,代码不用写两遍。
往返贵、批处理便宜,这是几十年的老账。数据库有批量写入,RPC 框架都在攒 batch。模型采样一轮比一次网络往返贵得多,把 N 次合成一次,收益只会更夸张。
减法这一半更通用。给不受信任的代码一个尽量小的环境,让它想干坏事都没有接口可用,这是安全设计的通用形状,和 V8 这个具体技术没关系。换成别的语言、别的沙箱,该问的还是同一个问题:这段代码到底需要哪几样能力,其余的能不能一样都不给。
程序要跑三分钟,超时该设多少。
设成三分钟,用户三分钟看不到动静,也没地方喊停。设成十秒,长任务永远做不完,更难受的是前面九秒的成果跟着一起丢,模型只收到一条超时消息,只能从头再来。两个方向都不对,问题出在把「还没跑完」当成了失败。
Codex 把正在跑的脚本做成一个有身份的东西,叫 cell。yield_time_ms 到点,cell 不死,它把这段时间攒下的输出整包交出去,然后清空缓冲继续跑。exec 这时返回一句话,告诉模型脚本还在跑,编号是多少。
模型拿到编号,手上就有三个选择:调 wait 再买一段时间;带 terminate: true 把它停掉;或者干脆先去干别的。wait 只返回上次让出之后的新输出,因为交出去的时候缓冲就被清空了,同一段内容不会重复占两次上下文。
有个小细节很能说明设计者在想什么。让出时间超过十秒时,Codex 会额外再送一秒宽限,然后才真的观察。出处:codex-rs/code-mode-runtime/src/service.rs 第 198 至 210 行刚好卡在边界上完成的脚本,不会因为差几毫秒白白多走一次往返。
还有一处不对称值得记一笔。发给模型的说明书里,wait 有四个参数:cell 编号、让出时间、返回长度上限、要不要终止。可协议层的请求结构体只带前两个,后两个停在处理器那一层,终止走的是另一条路径,长度上限是拿到结果之后才截断的。读源码的时候这两层很容易混成一层。
把「还没结束」做成一等状态,是长任务接口的通用形状。HTTP 有 202 加轮询,任务队列有 job id 加 poll,导出大文件的后台任务也是先给你一个编号。共同点是不让调用方在「一直等」和「当作失败」之间二选一,而是给一个可以再问一次的把手。
放到 agent 上,这个把手还多一层价值。模型拿到中间输出之后可以改主意,发现前四步的结果不对,直接 terminate,不用陪着跑完剩下八分钟。控制权回到了会思考的那一方手里。
超时:DeepSeek Harness 选择杀掉
DSH 的 run_code 用两本账。一本记忙碌时间,靠轮询 worker 的事件循环利用率,热循环藏不住,干等慢工具也不冤枉计费。另一本记墙钟,到点直接终止 worker。默认是六万毫秒和六十万毫秒。
代价很清楚:一次 run_code 必须在预算内结束,超时就是失败,没有「同一段程序接着跑」这种一等状态。换来的是实现简单,宿主不用维护一堆还活着的 cell。Codex 反过来,模型要多学一个 wait 协议,cell 会在会话里占着资源,直到跑完、被停掉或者会话结束。
状态:一次性的世界,还是留着的抽屉
DSH 的设计笔记写得很直白,程序所在的世界会随 worker 一同终止,不做池化,也不做跨运行状态。需要传给下一次的东西,要么写进工具结果,要么落到工作区文件里。好处是每次运行都是干净的新世界,出了问题容易重放。
Codex 给了 store 和 load,同一个会话里的多次 exec 可以共享数据,跨会话则互相看不见。编排起来方便,代价是清理责任落回自己身上:这个抽屉没有单条大小上限,只拒绝存不进 JSON 的值,也没有过期时间,要等整个会话结束才随运行时一起释放。
让出预算怎么算才不亏
一段程序要跑四十秒,让出预算默认十秒。推演一下:模型一共要发几次 wait,每次拿到的是全部输出还是新增的那一段,为什么把默认值改成三十秒并不总是更划算。
进阶一问:如果程序在第三十五秒把一个很大的对象放进了 store,随后模型决定 terminate,这个对象什么时候被清掉,谁来管它的大小。
宿主拆分:程序挂在谁身上
上一课讲 exec 和 wait 的语义。这一课问另一件事:这段 JavaScript 到底挂在谁身上,挂点换了之后,故障域和状态归属怎么变。
- thread manager 按特性挑选提供方thread_manager.rs L455
- 本地提供方检查宿主文件是否存在remote_session.rs L70
- spawn 宿主,Unix 上单独进程组connection.rs L217
- 握手后 session/open,宿主里 new 进程内会话lib.rs L602
- 嵌套工具经 RemoteDelegate 打回本机delegate.rs L27
- app-server 按 URL 方案换成 WS 或 gRPCcode_mode_host.rs L32
- gRPC 丢掉 lease 就关会话session.rs L105
- 重连后 cell ID 加世代前缀generation.rs L49
- 中断是否 terminate 看特性开关tasks/mod.rs L888
- 宿主不可用时,工具模式退回 Directtools/mod.rs L79
exec_command 仍绕回 session owner。宿主里没有审批窗,也没有 execpolicy。模型写一段 while (true) {},上一课见过,要靠 isolate 的 terminate 才能打断。这段程序如果和 Codex 事件循环抢同一个进程,卡死会从单个 cell 扩大到整条会话。
V8 的堆、JIT、没有条目上限的 store 表,任意一项在主进程里爆炸,都会带走 TUI 或 app-server。早期资料常把默认路径写成「主进程里直接跑 isolate」。当前特性注释已经把这句话改掉了。
出处:codex-rs/features/src/lib.rs 第 104 至 111 行
Codex 给「跑模型写的代码」留了一个提供方接口。业务层只依赖 CodeModeSession 和 CodeModeSessionProvider,自己不去 new 运行时。协议把会话收成四件事:execute、wait、terminate、shutdown。实现可以进程内,也可以远端。同会话共享 store,异会话隔离。
出处:codex-rs/code-mode-protocol/src/session.rs 第 146 至 167 行
主进程选提供方时,已经没有直接 new InProcessCodeModeSession 这条生产路径。CodeModeHost 打开,或者 disable_in_process_fallback 为真,都走向 ProcessOwnedCodeModeSessionProvider。两条都不成立,走向 DisabledCodeModeSessionProvider。
出处:codex-rs/core/src/thread_manager.rs 第 455 至 462 行
CodeModeHost 已经是 Stable,默认打开。用户看见的默认已经是本地子进程。进程内求值还在,只是沉到宿主进程内部:宿主打开会话时 new 的就是 InProcessCodeModeSession。isolate 活在小屋里,房东换成了独立二进制 codex-code-mode-host。
出处:codex-rs/features/src/lib.rs 第 921 至 925 行 · codex-rs/code-mode-host/src/lib.rs 第 599 至 608 行
拉起进程时,stdin / stdout / stderr 全管道化,Unix 上单独一个进程组,环境变量先 scrub 一遍。找不到可执行文件,availability() 直接失败,不会改去主进程里 new isolate。真正的回退发生在工具模式这一层:宿主不可用、请求的是普通 CodeMode、并且没有关掉回退时,有效工具模式变成 Direct。模型重新看见普通工具。
出处:codex-rs/code-mode/src/remote_session.rs 第 69 至 83 行 · codex-rs/core/src/tools/mod.rs 第 79 至 89 行
disable_in_process_fallback 这个名字容易让人以为还存在「回退到进程内 V8」。配置注释写的是另一件事:宿主不可用时,让 Code Mode 闭门失败。今天它控制的是「宿主没了以后,要不要从 Code Mode 退回普通工具」。
出处:codex-rs/core/src/config/mod.rs 第 1089 至 1096 行
不要让不受信任的语言运行时和 agent 主进程同命运。换成 Python 的 subprocess,换成别的 isolate,该问的还是同一句:这段代码崩了,谁还活着。
你把 app-server 指到一台远端机器的 --code-mode-host,容易以为整段 agent 都搬家了,连 exec_command 的审批弹窗都该出现在远端。当前源码对不上。远端宿主只搬走了求值。嵌套工具的审批、execpolicy、Guardian 仍在本机会话上。
宿主把 CodeModeSessionDelegate 做成 RemoteDelegate,经 IPC 打回 Codex 主进程。主进程上的 dispatch broker 才去走嵌套工具。isolate 搬家了,策略没有搬家。JS 在别处跑,副作用要绕回来问你。
出处:codex-rs/code-mode-host/src/delegate.rs 第 26 至 50 行
WebSocket 和 gRPC 是同一套求值、两套线。WebSocket 监听器拒绝带 Origin 头的请求,挡住浏览器页面跨源连到本机宿主。gRPC 把工具订阅、完成、执行流拆开,丢掉 OpenSession 那条租约流,会话关闭,正在跑的 cell 一并终止。app-server 的 --code-mode-host 认 http / https 为 gRPC,认 ws / wss 为 WebSocket。多个 thread 共享同一条远端连接,store 仍按会话切开。
出处:codex-rs/code-mode-host/src/transport.rs 第 288 至 302 行 · codex-rs/app-server/src/code_mode_host.rs 第 32 至 40 行
不可信的是 JS 世界,可信的是审批和策略。把前者搬走,后者留在有 UI 的那边。没有这条回路,远端宿主就必须复制你的整套权限系统。
一种常见预期是:你按了中断,V8 一起掐掉,下一轮 wait 立刻看到终止。当前默认对不上。另一种预期是:重连之后,刚才那段脚本还在原来的 cell 里接着跑。两种预期,源码都不认。
turn 被标成 Interrupted 时,任务取消令牌一定会取消。会不会再去 terminate 还在跑的 cell,要看 CodeModeInterrupt。这个开关还在开发,默认关。关掉时,中断只取消本轮工具调用和审批。宿主上的 isolate 可以继续跑,直到自己结束、被 wait(terminate: true) 停掉,或会话 shutdown。用户按 Ctrl-C,并不自动等于那条 terminate。
出处:codex-rs/core/src/tasks/mod.rs 第 888 至 899 行
掉线也一样。gRPC 丢掉 lease 就关会话。客户端可以再开一条租约,generation 从 1 往上加。第一代对外仍用原始 cell ID,第二代变成 g{generation}:{cell_id}。模型拿着第一代的编号去 wait,会收到 stale generation。本地子进程路径没有这套前缀,只是把状态机打回 New,再分配一个新的 session-N。对外 cell ID 仍从 1 数。两种重连都丢运行中的 cell 和那份 store。
出处:codex-rs/code-mode/src/grpc_session/generation.rs 第 49 至 67 行
store 表跟着宿主侧的 SessionRuntime。没有落盘,没有跨进程共享,没有 TTL。远端机器重启,表就没了。会话 ID 复用会被拒绝,不会把旧表偷偷接回来。重连恢复的是「还能再 exec」,不是「刚才那段脚本」。
cell 的寿命按会话算,不按 turn 算。中断轮次和杀掉程序是两件事。重连开的是新世界,旧身份证作废。自己做 Agent 时,如果用户按停止就期望程序立刻死,默认应该 terminate。Codex 默认不杀,是因为它还把 cell 当成可跨轮续跑的对象。
拓扑:DeepSeek Harness 选择同一栋楼
DSH 把程序放进进程内的 worker_threads.Worker。crate 头注释第一句把立场写死:这是 containment,不是 security boundary。模型代码按 bash 等价来对待。每次 run() 拉起一个新 Worker,环境是空的,堆有上限。程序世界随 worker 一起死,没有跨 run 状态。
一个 worker 崩了,主进程房间还在,可 V8 漏洞或原生崩溃仍可能带走整个 Node 进程。Codex 的 isolate 已经比 Node worker 窄,没有 fs、没有 net、没有 import,仍然不信任「窄 isolate 和主进程同命运」。默认再加一层操作系统进程。代价是多了一个必须随包装分发的 codex-code-mode-host,多了会话 ID、世代和掉线语义。
packages/code-runtime/code-runtime-worker-thread/src/index.ts 第 1 至 6 行 · README.md 第 23 行 · DSH · Code Mode
对位物:Claude Code 与 Grok 把这件事留给 shell
两边都没有「模型写一段程序、在独立运行时里编排工具」的对位实现。Claude Code 的 isolation 出现在 git worktree 和远端 CCR 会话,Grok 的 isolation 出现在子 agent 的 worktree。那是工作区隔离,不是 JS 宿主拆分。没有对位物本身就是结论:这两家把跑模型写的代码留给了普通 shell 工具。
仓库里还有 exec-server,搬走的是 shell、PTY 和文件系统 RPC,不跑 JavaScript。嵌套 tools.exec_command 仍然可以再走进去,那是下一层的执行拆分。两条路不要收成同一个远端。
codex-rs/exec-server/README.md 第 1 至 5 行
旧 cell 还能不能 wait
同一段脚本在 gRPC 宿主上跑到一半,连接断了又连上。模型拿着原来的 cell_id 去 wait,会看到什么。本地子进程路径会不会给这个编号改名。两种路径的 store 还在不在。
然后把 CodeModeInterrupt 拨到关,按中断再 wait 一次。答案会不会变,为什么用户按停止并不自动等于 terminate。
多 Agent 是一张要持久化的图
派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。
send_message 只入队,followup_task 才叫醒;关机卸运行时,关边才从图里除名。第二天还能不能接着说话,先查边。
thread_spawn_edges 记录。- 计算下一层深度,写入 ThreadSpawnregistry.rs L87
- 用 task_name 拼出绝对路径 /root/explore_authmulti_agents_common.rs L117
- 从科学家名单抽出外号 Hypatiacontrol/spawn.rs L32
- 非临时会话立刻 upsert 一条 Open 边control.rs L776
- send_message 按 QueueOnly 组包,只入队message_tool.rs L103
- 信箱是会话级队列,入队后发 Mailbox 活动input_queue.rs L127
- followup_task 把 trigger_turn 打开,这才叫醒handlers.rs L98
- 子 turn 结束,给父发 Result,不叫醒session/mod.rs L1977
- 关机不改边;关边只标目标自己的入边 Closedlegacy.rs L6
- 重启只把 Open 后代的身份装回注册表control/spawn.rs L158
你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent,工具回了一句任务名 /root/explore_auth,外号 Hypatia。过几分钟点 wait_agent,信箱里躺着一份 FINAL_ANSWER。
第二天打开同一条 thread。子会话的运行时已经卸掉了。系统如果只记得昨天发生过一次调用,这条路径就找不到。你再发 send_message,控制面会报 live agent path not found。
Codex 把父子做成有向边。边只有两个值。Open 表示还能当打开的 spawned agent 恢复。Closed 表示从图的视角已经关掉。序列化是 open 和 closed。
出处:codex-rs/agent-graph-store/src/types.rs 第 4 至 12 行
图存在 SQLite 的 thread_spawn_edges 表。child_thread_id 是主键,一个孩子不能挂两个父。同一孩子再 spawn 一次,父和状态都会被新值盖住。会话正文不进这张表。子 agent 的模型上下文仍走自己的 rollout。图只回答谁生了谁,这条边现在开还是关。
出处:codex-rs/state/migrations/0021_thread_spawn_edges.sql 第 1 至 8 行
非临时会话在线程建出来之后立刻 upsert 一条 Open 边。写入失败只打 warn。子 thread 已经在跑,图可以稍后补。补写用 ON CONFLICT DO NOTHING,不会把已经 Closed 的边改回去。
出处:codex-rs/core/src/agent/control.rs 第 767 至 780 行
列后代时,过滤条件作用在走过的每一条边上。Some(Open) 只沿着 Open 走。父边已经 Closed 的子树,就算孙边仍是 Open,也不会被列出来。
出处:codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行
路径才是寻址键,外号是给人认的。根是 /root。相对名接到当前路径后面。.. 和 . 被拒绝,所以不能靠相对路径爬到兄弟。角色可以收能力,不能替换父会话的权威。内置活角色是 default、explorer、worker。explorer.toml 是空文件。
出处:codex-rs/core/src/agent/role.rs 第 1 至 4 行
重启之后必须能问:这个孩子还算活着吗。只记一次 spawn 事件回答不了。Open 才能进恢复列表。Closed 从子树遍历里消失。child_id 做主键,图保持树,遍历可以按深度 BFS。换个语言重写,最小形态仍是一张三列表:parent、child、status。
子 agent 转完了,要回一封 FINAL_ANSWER。如果这封信自带叫醒,父正在写用户看得见的最终答案时,会被子结果强行开一轮。用户看到半截话,再加一份突然插进来的完成通知。
通信种类有四个标签:Spawn、Message、Followup、Result。它们是 OTEL 用的标签。协议侧只有一份 InterAgentCommunication,靠 trigger_turn 区分要不要叫醒。
send_message 是 QueueOnly,只入队。followup_task 是 TriggerTurn,才叫醒。空消息直接拒。followup_task 不能打根节点。
出处:codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs 第 11 至 24 行
处理函数先入队,再决定要不要开工。trigger_turn 为假时,信继续躺着。只有它为真,或会话还有未完成的 durable sleep,才去开工。V2 的完成通知是 Result,trigger_turn 为假。父如果正在说话,这封信按信箱相位排队。
出处:codex-rs/core/src/session/handlers.rs 第 89 至 99 行
信箱是会话级队列。用户插话进 pending_input,子邮件进 mailbox_pending_mails,两条槽。兄弟之间只要用绝对路径,例如 /root/worker_b,就可以互发。相对名 worker_b 会接到自己后面,变成自己的孩子。
出处:codex-rs/core/src/session/input_queue.rs 第 76 至 80 行
叫醒权是稀缺的。谁能开一轮,谁就不能随便开。把投递和开工拆开,完成通知默认不叫醒,叫醒权留给 followup_task 和用户。换一套消息总线也用得上这根布尔:wake 还是只入队。
V2 驻留名额满了,会按 LRU 卸掉一个孩子。如果卸运行时顺便把边标成 Closed,这个孩子从 Open 子树消失。下次恢复找不到它。用户没关过它,系统自己把它除名了。
shutdown_live_agent 关掉活着的 agent,刷 rollout,发 Shutdown,从管理器摘掉 thread。边还是 Open。下次恢复仍会把它当活子树成员。
出处:codex-rs/core/src/agent/control/legacy.rs 第 6 至 8 行
close_agent 先把目标自己的入边标 Closed,再关机。后代的边不会在这里被标 Closed。父 turn 正常结束走 TurnComplete,不调用 close_agent。孩子继续跑,边保持 Open。
出处:codex-rs/core/src/agent/control/legacy.rs 第 48 至 58 行
V2 恢复分两步。先把 Open 后代的身份装回注册表,不重开运行时。真正有人 send_message 或 followup_task 时,才按 rollout 把 thread 挂回来。
出处:codex-rs/core/src/agent/control/spawn.rs 第 144 至 162 行
运行时活着和从图上除名是两件独立的事。驻留 LRU、进程重启、用户关窗口,都可能卸运行时。只有编排者明确 close,才写成 Closed。恢复时沿着 Open 边走。这条分账不依赖 Rust。
DSH:接缝优先,图是列举结果
DSH 把子 agent 做成可替换的 provider 接缝。SubagentProvider 带 name、capabilities、inheritsParentContext、start。进程内 fork、Claude Code、Codex、ACP,都是同一张接口上的不同实现。
它也能列出孩子和后代,从活着的 session store 和可选的 persistence 只读枚举。没有一张 thread_spawn_edges 那样的 Open / Closed 边表。拓扑是 session header 的 origin: subagent 加上事后折出来的。换实现便宜,按边恢复要另做。
Claude Code:工具调用加 transcript 侧链
模型面对的工具现名是 Agent。旧线名仍叫 Task,给权限规则、hook、恢复中的会话做兼容。Explore / Plan 是一次性的,父不会再续跑。
运行时给每个孩子发一个 agentId。没有单独的 spawn-edge 表。恢复靠读这个 id 对应的 transcript。父要列活孩子得扫侧链,没有按边过滤。Codex 多一张表、两套状态,换来重启后仍能按图说话。
Closed 父边下面的 Open 孙边还在吗
画一棵三层树:根到 A 为 Closed,A 到 B 为 Open。用 Some(Open) 和 None 各列一次后代。B 会不会出现?
过滤条件作用在走过的每一条边上。然后对照 codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行的注释,把两种结果写下来。
挂钩点能改什么,由事件合同决定
一次 turn 会经过十一个挂钩。协议认四种处理器,运行表只装命令和 MCP。超时默认放行,拆卸期丢掉 stdout。
SessionEnd 为什么不读输出。挂钩点是生命周期上预留的插口,能改下一步的是事件合同,不是配置文件里的名字。
- 协议枚举列出十一个事件名protocol.rs L1510
- 配置层认四种 type,后两个是空结构体hook_config.rs L183
- 发现阶段把 Prompt 和 Agent 写成 not supported yetdiscovery.rs L626
- 运行表只收下 Command 和 McpToolengine/mod.rs L107
- 只有同步 hook 能施加控制效果engine/mod.rs L146
- 超时写入 error,should_block 保持 falsecommand_runner.rs L317
- 退出码 2 加 stderr 才标成 Blockedpre_tool_use.rs L261
- Allow 映射成一次性 Approvedapprovals.rs L465
- Stop 的 block 带 prompt 才在轮次层 continueturn.rs L509
- SessionEnd 退出码 0 即完成,丢掉 stdoutsession_end.rs L109
你刚从 Claude Code 把一份 hooks.json 搬过来。文件里有三条处理器:命令拦危险 shell,提示词让小模型审用户提交,agent 在 Stop 时再起一个子会话跑 linter。Claude 那边三条都能跑。
贴进 Codex,启动会话。命令那条亮了。后两条日志各写一句 not supported yet。协议枚举明明列着 Prompt 和 Agent,配置解析也认这两个 tag。装进运行表的只有命令和 MCP 工具。
内核对外有三张表,宽度不一样。
协议面列出十一个事件名,serde 走 snake_case。旁边四个处理器类型也在:Command、McpTool、Prompt、Agent。
出处:codex-rs/protocol/src/protocol.rs 第 1508 至 1531 行
配置面用 type 标签把这四个名字都接住。后两个是空结构体。解析能过,字段里没有可执行内容。
出处:codex-rs/config/src/hook_config.rs 第 183 至 187 行
发现阶段看见后两个就 continue,文案是 prompt hooks are not supported yet 和 agent hooks are not supported yet。引擎里真正可执行的种类只有命令和 MCP 工具。普通用户配置里,这两条只进 warning,会话继续。托管必选 hook 配了它们,启动会失败。
出处:codex-rs/hooks/src/engine/discovery.rs 第 626 至 645 行
JSON hook 的运行时类型名直接写成 ClaudeHooksEngine。兼容不是注释里的愿望,是类型名。stdin 喂 JSON,stdout 按 schema 解析。旁边还留着一条旧 notify 路,只在 turn 收工时 fire-and-forget 一条命令。两条路不要混。
出处:codex-rs/hooks/src/engine/mod.rs 第 107 至 119 行
配置面可以比执行器宽。先把生态里已有的四个 type 接住,未知 tag 才不会把整份 hooks.json 打爆。代价是搬家的人会按枚举名理解能力。所以发现阶段必须留下稳定文案,必选策略碰到未实现 type 必须拒绝启动。换个语言重写,最小形态仍是两张表加一个 skip。
有人写了一条 PreToolUse 脚本,超时设成 1 秒,脚本里 sleep 5 秒。他们以为 hook 崩溃等于拦截。工具照样执行。日志里这条 hook 的状态是 failed,文案带 timed out after 1s。
run_command 超时把 error 写成 hook timed out after {n}s,exit_code 是空的。解析看见 error 只标 Failed,should_block 保持默认 false。工具注册表于是继续 handle_any_tool。
出处:codex-rs/hooks/src/engine/command_runner.rs 第 317 至 326 行
会拦的路只有两条。JSON 里给出 deny 或 block。或者退出码 2 且 stderr 非空。退出码 2 却没有理由,算失败,不拦。异步 hook 即使返回 deny,也加不上控制效果。只有同步、可信、未超时的处理器能改下一步。
出处:codex-rs/hooks/src/events/pre_tool_use.rs 第 261 至 277 行
审批路径上的规则反过来。PermissionRequest 跑在 Guardian 和用户审批 UI 之前。它不改工具输入。折叠规则是:任一 deny 立刻赢,否则保留最后一次 allow。Allow 映射成一次性 Approved,不进会话缓存。下次同样的命令还要再问。
出处:codex-rs/core/src/tools/approvals.rs 第 454 至 474 行
Claude 的 PermissionRequest 输出里有 updatedInput。Codex 把这个字段标成 reserved,看见就 fail closed。从 Claude 原样搬一条带改写的审批 hook,在这里会失败,不会改写。
一条挂掉的 linter hook 不该让所有工具停摆。可用性放在拦截可靠性前面。想改流程,必须同步、必须有明确决策。想发通知,可以异步、最多 8 个并行。审批路径对歧义输出 fail closed,因为那一层不能把看不懂的字段当成允许。
PostToolUse 想拦一次危险写入,文件已经落盘。SessionEnd 想往上下文里塞收尾说明,stdout 被丢掉。wire 枚举里也没有这个事件名。两处翻车的共同点是:挂钩点已经走过它能改的那一段。
十一个挂钩点按生命周期排开。主轴是 SessionStart → UserPromptSubmit → PreToolUse → PermissionRequest → 工具 → PostToolUse → PreCompact → PostCompact → Stop → SessionEnd。子 agent 另走一条细轴,SubagentStart 和 SubagentStop 只在 ThreadSpawn 上响。
每个点的合同不一样。
PreToolUse 能拦工具、能改输入。失败默认放行。PostToolUse 只在工具成功之后跑,block 拒绝的是结果,副作用已经发生。Stop 的 block 带着 continuation prompt,才在轮次层 continue,不重发 TurnStarted。没有 prompt 的 block 被忽略。should_stop 才把控制权交回任务壳。stop 优先于 block。
出处:codex-rs/core/src/session/turn.rs 第 509 至 538 行
UserPromptSubmit 的 block 写成 should_stop,停的是当前这条用户消息,不会拿 stderr 当下一轮 prompt。matcher 在这里被忽略。SessionStart 尊重 continue: false。SubagentStart 只做上下文注入,同样的字段被丢掉。
SessionEnd 是拆卸期通知。超时默认 1 秒,上限 3 秒,给 app-server 的五秒 shutdown 留余量。退出码 0 就是完成,stdout 整段丢掉。MCP 形态直接 skip。reason 目前写死 other。
出处:codex-rs/hooks/src/events/session_end.rs 第 20 至 24 行
hook 文本进模型之前先变成 developer 角色的片段。默认预算 2500 个近似 token。超限时全文写到临时目录,模型看见头尾预览加一行路径。写盘失败就只截断。
出处:codex-rs/hooks/src/output_spill.rs 第 53 至 91 行
挂钩点的能力跟它在生命周期的位置绑定。工具还没跑,才能改输入或跳过。工具跑完,只能改模型看见的那一截。拆卸期只有几秒,读 JSON、回灌上下文、再等 MCP,都会把关机拖过上限。于是它变成纯通知。换一套运行时,该问的仍是:这个点还来不来得及改已经发生的事。
Claude Code:二十七个事件,Prompt 和 Agent 真的会跑
还原源码里 HOOK_EVENTS 有 27 项。Codex 的十一点都在,另外还有失败后事件、通知、工作树和文件变更。execPromptHook 用小模型跑一段提示词,构造 user message 时绕开 processUserInput,避免再次触发 UserPromptSubmit。execAgentHook 会起一轮完整 query。
Claude 的运行面比配置面宽。Codex 反过来,先把四个 type 接住,执行器后补。事件名高度重合,这是对齐动作。ClaudeHooksEngine 这个类型名把产品判断写进了标识符。
DSH:插件瀑布是原生接口,hook 文件是兼容桥
DSH 的工具管道在 tools/pre-execute 和 tools/post-execute 上各开一道瀑布。原生插件能做桥能做的一切。hooks-codex 只映射五点:PreToolUse、PostToolUse、SessionStart、UserPromptSubmit、Stop。没有 rewrite,没有 PermissionRequest。type: command 以外的、异步的,解析后跳过。
DSH 先有插件再补文件兼容。Codex 先有 Claude 文件合同,再让插件往同一引擎里塞声明。Grok 用十五个事件名补观察面,is_blocking() 只对 PreToolUse 返回真,没有审批前 hook。
同一条 PreToolUse,两种返回值
项目里有一条 PreToolUse 命令 hook,matcher 对着无害的 echo。先把 timeout 设成 1,脚本里 sleep 5 秒。再把脚本改成退出码 2,并向 stderr 写 blocked by test。
推演两趟结局:工具会不会执行,模型看见什么,hook 状态分别是 failed 还是 blocked。然后解释,为什么第一种不能靠「hook 挂了」来当拦截。
MCP 接进来:模型看见翻译过的名字
外部 server 的工具要先过一层翻译才进模型眼睛。skill 目录常在,缺 MCP 时另问人。
- 连接集整份发布,已有 binding 继续拿自己那份连接runtime.rs L246
- 各家 tools/list 汇成一张表,再交给命名翻译tool_catalog.rs L153
- 给命名空间加上历史前缀 mcp__tools.rs L228
- 非法字符洗成下划线,只留字母数字和下划线mcp/mod.rs L477
- 完全相同的原始身份丢掉一份tools.rs L134
- 清洗后命名空间撞车,末尾加 12 位 SHA-1tools.rs L166
- 清洗后工具名撞车,同样加 12 位哈希tools.rs L193
- 合起来超过 128 字节就截断再哈希,协议调用仍走原名tools.rs L226
你把 codex mcp-server 写进 Cursor 的 MCP 配置。Cursor 当 client,Codex 当 server。若这一次 tools/list 把内部 GitHub 工具一并交出去,IDE 调一次就摸到内部能力。权限边界从「调一次 Codex」扩成「直接调内部工具」。
crate 拆成两套。mcp-server 从 stdin 读行,一行一条 JSON。initialize 只打开 tools。tools/list 写死两个名字:codex 和 codex-reply。codex 会 start_thread,nested thread 再起自己的 McpRuntime。codex-mcp 管连接集,外部 server 的工具另做一份目录。
出处:codex-rs/mcp-server/src/lib.rs 第 131 至 152 行;codex-rs/mcp-server/src/codex_tool_runner.rs 第 66 至 90 行;codex-rs/codex-mcp/src/runtime.rs 第 88 至 98 行
同一份 JSON-RPC 线协议,处理器不是同一个。早期资料常把它们画成同一个 runtime 的两张脸。当前源码里它们甚至不共享 MessageProcessor。
出处:codex-rs/mcp-server/src/message_processor.rs 第 274 至 277 行;codex-rs/mcp-server/src/message_processor.rs 第 336 至 348 行
对外承诺和对内能力分开,是网关的通用形状。换语言也是两个函数:hosted 返回 run / continue,external 返回 mcp__*。IDE 只看见入口,会话里才看见外部店。
两家店都报 search,前缀还能分开。一家叫 basic-server,一家叫 basic_server,连字符洗成下划线之后,命名空间会撞。模型看见两个同名工具,下一次调用就不知道进哪家店。API 还有字节上限。
server 接进来,先把各家 tools/list 汇成一张表,再走 normalize_tools_for_model_with_prefix。顺序是固定的四步。
1. 给命名空间加上 mcp__ 前缀。
2. 非法字符洗成下划线,只留字母、数字和 _。
3. 完全相同的原始身份丢掉一份。清洗后命名空间或工具名还撞,就在末尾加 12 位 SHA-1。
4. 合起来超过 128 字节,截断再哈希。原始 server_name 和 tool.name 留在 ToolInfo 上,协议调用走原名。
出处:codex-rs/codex-mcp/src/tools.rs 第 105 至 117 行;codex-rs/codex-mcp/src/tools.rs 第 134 至 137 行;codex-rs/codex-mcp/src/tools.rs 第 166 至 194 行;codex-rs/codex-mcp/src/tools.rs 第 226 至 227 行;codex-rs/codex-mcp/src/mcp/mod.rs 第 477 至 485 行
给模型看的名字和协议上的名字本来就是两层。一层给人读、给 API 用,一层用来寻址。哈希消歧是撞名问题的通用答法。上限数字会变,这层翻译不会变。
若按 MCP 存活过滤目录,冷启动那几秒模型会以为 skill 不存在,下一轮又突然出现。说明书整份灌进每一轮,上下文也会被吃光。
点名记号是 $。目录只看 enabled 和 prompt_visible。用户点了名,或者任务和描述对得上,这一轮才读 SKILL.md 正文。Guardian 评审会话直接返回空注入,父 transcript 里的 $skill 不能再触发新说明书。
出处:codex-rs/skills/src/mentions.rs 第 41 行;codex-rs/ext/skills/src/catalog.rs 第 261 至 263 行;codex-rs/core/src/session/turn.rs 第 766 至 770 行;codex-rs/core/src/session/turn.rs 第 808 至 817 行
缺 MCP 时另问人。first-party 且功能开关开,才弹出 Install MCP servers。审批是 Never 就静默跳过。用户选 Continue anyway,目录还在,对应工具可能仍不可用。
出处:codex-rs/core/src/mcp_skill_dependencies.rs 第 47 至 60 行;codex-rs/core/src/mcp_skill_dependencies.rs 第 268 至 270 行
发现和就绪是两件事。索引先给,全文按需再给,缺依赖问人,不要把条目从目录里抹掉。装不装是配置变更,列不列是发现。
DSH:只桥 tools,一条插件对一台 server
DSH 的 MCP 客户端把范围写死:连一台外部 server,工具注册到 ctx.tools,公开名是 mcp__<serverName>__<rawName>。干净情况原样拼接。字符或长度被改过,就在末尾加 12 位 SHA-256。上限 64 字符。卸载就断连、注销、放命名空间。
出处:packages/mcp/mcp-client/src/index.ts 第 1 至 14 行;packages/mcp/mcp-client/src/tools.ts 第 96 至 102 行
没有 elicitation,也不把自己交出去当 MCP server。外部工具失败仍按普通 tool 失败处理。哈希长度碰巧也是 12,算法和拼接规则不同。
已核对源码 · 2026-08-22 · DSH · MCP 与扩展Claude Code:skill 是一等 tool
Claude Code 给模型一个 Skill tool。模型 call 才拿正文。注释写明同一时间只跑一个 skill,因为 tool 会把命令展开成整份 prompt。
出处:restored-src/src/tools/SkillTool/SkillTool.ts 第 331 至 344 行
MCP 上的 prompt 要标成 loadedFrom === 'mcp' 且 type === 'prompt',才进发现列表。方向相反:Codex 是 skill 需要 MCP,Claude Code 是 MCP 贡献 skill。触发器也不同。Codex 扫 $name,命中就注入 <skill>,不经过一次 tool call。
出处:restored-src/src/tools/SkillTool/SkillTool.ts 第 81 至 94 行
清洗之后谁还认得这家店
basic-server 报 lookup,basic_server 报 query。写出模型看见的两个命名空间,并说明调回去时凭什么还能进对的店。
再问一问:把审批改成 Never,打 $deploy 的时候,skill 目录还在不在。观察点在 is_model_visible 和 should_install_mcp_dependencies。
搬家只搬对得上的字段
换到 Codex 的第一周,最怕去年攒的 hook、MCP 和插件还在不在。检测会列出一份清单。导入只收下能映射的那一部分。
- 按字符串选择 Cla 或 Cur,对不上就落到 Claude Codemigration_source.rs L59
- 先扫用户级配置,仓库范围不跑插件检测detect/mod.rs L330
- 会话按 30 天和 50 条过滤,太旧的丢掉sessions/common.rs L43
- memory 要源支持,还要单独打开特性开关detect/mod.rs L59
- hook 只留同步 command,prompt 整条跳过hooks_cla.rs L135
- MCP 命令里出现 ${,整台 server 不要mcp.rs L208
- 市场来源只收 git 家族和本地目录,npm 丢掉source_cla.rs L270
- 说明文件改名,产品名按词边界改写成 Codexrewrite.rs L39
- 同步 import 对 Sessions 直接返回成功service.rs L437
- 后台再写 thread,远程插件后装;开关关则拒绝 memoryprocessor.rs L184
有人告诉你会自动从 Claude Code 搬家。你以为是把整个配置目录拷过来。第二天 hook 少了一条,MCP 少了一台,memory 没出现在清单里。少掉的 hook 用了 type: prompt。少掉的 MCP 命令里写了 ${API_KEY}。
再换一个同事。他用的是 Cursor。Codex 能看见他的 skill 和 hook,看不见他的 memory。仓库里的 Cursor 插件配置会被直接丢掉。
搬家 crate 只负责把外部配置读进来。插件怎么跑,在旁边的 core-plugins。源在类型上只有两个变体:Cla 是 Claude Code,Cur 是 Cursor。字符串对不上 cursor,就落到 Claude Code。调用方漏传源,检测会去翻 ~/.claude。
出处:codex-rs/external-agent-migration/src/migration_source.rs 第 51 至 67 行
一次检测最多产出十种条目。每种后面的导入必须给一个去处。字段先过白名单。Codex 认 11 个 hook 事件,Claude Code 发出 27 个。名字对不上的组,整组消失。单条 hook 的 type 默认当 command,对不上就跳过。MCP 的 command 或 url 里出现 ${,整台不要。市场来源只收 github、git、本地目录。file、url、npm、settings 直接丢掉。
出处:codex-rs/external-agent-migration/src/model.rs 第 53 至 64 行 · codex-rs/hooks/src/lib.rs 第 23 至 35 行 · restored-src/src/entrypoints/sdk/coreSchemas.ts 第 355 至 383 行 · codex-rs/external-agent-migration/src/hooks_cla.rs 第 131 至 137 行 · codex-rs/external-agent-migration/src/mcp.rs 第 204 至 210 行 · codex-rs/external-agent-migration/src/source_cla.rs 第 270 至 274 行
然后说明文件要改名。CLAUDE.md 变成 AGENTS.md。产品名按词边界改成 Codex。Cursor 只用大小写敏感的 Cursor,避免把普通英文 cursor 一起改掉。
出处:codex-rs/external-agent-migration/src/rewrite.rs 第 38 至 49 行
导入器的通用形状是白名单加三条出口。对不上的字段丢掉,不要改写成近似物。prompt hook 不会被塞进 command。带占位符的 MCP 不会被瞎展开。换个语言重写,这份表还用得上。
Claude Code 允许仓库 settings 带着启用插件名单,同事克隆之后自动有同一批插件。对 Codex 来说,仓库里的启用名单不能当安装权威。装上去会进用户配置。不可信仓库想在你机器上装可执行内容,这条路要先切断。
检测只在 home 扫插件。注释写死了:仓库控制的 settings 不能当安装权威。Cursor 只要看见仓库根,插件检测直接返回空。导入看见非空工作目录,报 repository-scoped plugin migration is not allowed。
出处:codex-rs/external-agent-migration/src/detect/mod.rs 第 330 至 332 行 · codex-rs/external-agent-migration/src/migration_source.rs 第 116 至 125 行 · codex-rs/external-agent-migration/src/plugins.rs 第 26 至 36 行
本地插件当场装。远程市场推进待装队列。会话也是两截:检测清单里可以有,同步 import() 对 Sessions 直接返回成功,什么都不写。真正写成 Codex thread 的,是 app-server 里的后台任务。调用方先拿到 import_id。
出处:codex-rs/external-agent-migration/src/service.rs 第 437 行 · codex-rs/app-server/src/external_agent_migration/session_importer.rs 第 100 至 111 行
Codex 认市场清单时,按固定相对路径找第一个存在的文件。四条路径里两条是自己的,两条是竞品的。同一套查找同时服务「用户主动加市场」和「从竞品搬市场」。
出处:codex-rs/core-plugins/src/marketplace.rs 第 20 至 25 行
可执行内容的安装权威必须落在用户级。面向任意 git clone 的产品,home-only 更稳。同步先回执、慢活进后台,是长任务接口的通用形状。
换产品最怕把已经改过的新配置盖掉。目标 hooks.json 里已经有内容,整文件替换会把用户手写的 hook 冲掉。
目标 hook 文件非空,整项取消。已有 config.toml 只补缺失键。MCP 同名 server 保留旧的。第一次搬家像填空。第二次再点迁移,多数条目会因为已经有了而不出现。
出处:codex-rs/external-agent-migration/src/hooks_common.rs 第 13 至 19 行
memory 另有一道门。特性开关默认关,阶段是 UnderDevelopment。检测函数不看这个开关,导入前处理器会查。关着就回 external agent memory import is disabled。导入按字节拷贝,不脱敏。
出处:codex-rs/features/src/lib.rs 第 998 至 1003 行 · codex-rs/app-server/src/external_agent_migration/processor.rs 第 180 至 185 行
导入是填空。用户已经写过的文件,搬家碰不得。memory 默认关,是因为另一家 agent 的项目记忆会按明文进盘。
DeepSeek Harness:不搬家,再装一次
DSH 的插件入口是 thin pnpm forwarder。初始化 profile,在 profile 目录里跑 pnpm,再按安装结果调和清单。插件是 npm 包。没有检测 Claude Code,没有导入 Cursor hook。换产品要自己重装。代价是用户税。好处是不用对一家不断加字段的 settings schema 做永久兼容。
已核对apps/cli/src/plugin.ts 第 120 至 133 行 · 2026-08-22
Grok:自建市场,不搬竞品
Grok 从市场根加相对路径拷进自己的安装登记,并写下 provenance。相对路径禁止 ..、绝对路径和盘符。没有 plugin.json 但根上有 SKILL.md 时,它会补一份合成清单。市场对象是一份自己的目录加来源记录。它不读用户在另一家里启用过的插件。
crates/codegen/xai-grok-plugin-marketplace/src/installer.rs 第 36 至 45 行 · 2026-08-22 · Grok · 插件市场的发现与信任
哪些字段会进清单
桌上有四条 Claude Code 资产:PreToolUse 的 type: command hook、同事件的 type: prompt hook、一台 command 含 ${API_KEY} 的 MCP、一条 40 天前的会话。对照上面的白名单,列出检测清单里应出现和不应出现的条目。
再补一问:调用方何时收到 import_id,这条会话何时变成 thread。开关关着的 memory 若被送去导入,会在哪一扇门被挡回来。
SQ 进、EQ 出:同一件事两副面孔
命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。
TurnStarted,写到 JSON 上却是 task_started。旧客户端碰到不认识的 type,同进程编不过,跨版本 JSON 解不出,resume 旧文件则跳行继续开。
- 生成 UUID7 作为提交 idsession/mod.rs L918
- 把 Op 包成 Submissionsession/mod.rs L817
- 送进容量 512 的 SQsession/mod.rs L833
- submission_loop 按变体分发handlers.rs L526
- send_event 用 sub_id 做 Event.idsession/mod.rs L1952
- 需要时再发 legacy 副本session/mod.rs L1965
- 按白名单决定是否写入 rolloutsession/mod.rs L2169
- 送进 unbounded EQsession/mod.rs L2185
- MCP 把整个 Event 序列化成 codex/eventoutgoing_message.rs L117
- resume 时坏行计入 parse_errorsrecorder.rs L1046
你给侧栏等 type 等于 turn_started。联调那天字段对得上,type 却写成 task_started。你改成新名,旧夹具里的旧名还能解出来。
然后你加了一个自己的事件。本地和内核一起编,过了。隔壁旧版 MCP 客户端解不出来。再过一周,新版写下的 rollout(会话落盘文件)拿到旧版里 resume。那一行被跳过,parse_errors 加一,会话还能开,少了一段生命周期。
命令里带着 oneshot 回调、审批决定,甚至 realtime 音频帧。事件要进 rollout,要被 MCP 写成 JSON,要被旧客户端按 type 分发。方向、寿命、能不能过网,叠在同一种「消息」上会互相拖累。
模块头只用四行,把说话方式写死:一次会话里,客户端和 agent 用 SQ / EQ 异步通信。
//! Defines the protocol for a Codex session between a client and an agent.
//!
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
//! between user and agent.
openai/codex,核对文件 codex-rs/protocol/src/protocol.rs,commit 4f39251a01,核对日期 2026-08-22。代码块保留源码原文,这四行就是整课的模式声明。下行条目是 Submission。它有关联用的 id,有要执行的 Op(内核动词,当前 28 个),只派生 Debug,没有 serde。上行条目是 Event。它有 serde。id 对上当初那条提交,msg 才是事件本体。
出处:codex-rs/protocol/src/protocol.rs 第 185 至 200 行;codex-rs/protocol/src/protocol.rs 第 1276 至 1283 行
会话启动时同时建两条通道。下行 bounded,容量 512。上行 unbounded。客户端连打 512 条还没被 loop 收走,下一次 send 会等。事件可以堆积,占内存,不反压这一轮。
出处:codex-rs/core/src/session/mod.rs 第 460 至 461 行;codex-rs/core/src/session/mod.rs 第 533 至 534 行
TurnInput 的路由结果走 oneshot,不走 Event Queue。EventMsg 描述这一轮发生了什么。oneshot 只回答「这条提交有没有被接住」。
出处:codex-rs/core/src/session/handlers.rs 第 515 至 526 行
命令是人发的,频率低,堵住可以反压。事件是模型和工具喷出来的,堵住会把这一轮卡住。换语言重写,只要命令带回调、事件要落盘,这两条队列还是得分开。
Rust 变体已经改名叫 TurnStarted。若 JSON 上的字符串跟着改,旧 rollout 和旧客户端会在反序列化边界上断。按标识符名猜 wire 名,会猜错。
serde 写出 task_started,读入时也认 turn_started。Display 和指标走 turn_started。同一变体两套字符串:磁盘保住旧名,代码用新名。
出处:codex-rs/protocol/src/protocol.rs 第 1337 至 1340 行
item 生命周期还会再喷一份旧名字。新前端看 ItemStarted,旧前端看 ExecCommandBegin 或 AgentMessage。队列上会出现重复语义。这是迁移动线,给还没迁到 TurnItem 的消费者留的。
出处:codex-rs/core/src/session/mod.rs 第 1965 至 1973 行;codex-rs/protocol/src/legacy_events.rs 第 65 至 69 行
标识符可以改,已经落盘的字符串改不起。rename 加 alias 是给磁盘留后门的通用做法。指标用哪一套,要单独测,不要假设和 serde 相同。
EventMsg 是内部事件词表,81 个变体,没有 #[serde(other)],也没标 non_exhaustive。加一个新 type,旧读取器怎么办,不能靠「看情况」。
三条路径,答案都写在代码里。
TUI、exec、MCP 和内核链到同一份类型。穷尽 match 编不过。旧客户端若还没升级,根本不会和这份新内核链在一起。
MCP 把整个 Event 序列化成 codex/event。旧客户端用旧词表去解,未知 type 让 serde 失败。内核已经发出去了,失败发生在客户端。
坏行把 parse_errors 加一,然后 continue。未知 type 不会让整个会话打不开。它会少一行。函数仍返回已经解出来的 items。
出处:codex-rs/mcp-server/src/outgoing_message.rs 第 108 至 133 行;codex-rs/rollout/src/recorder.rs 第 1009 至 1071 行
Op 反过来。它标了 non_exhaustive,submission_loop 末尾 _ => false,未知命令被丢掉,loop 不崩。事件是对外词表,漏一个变体要在编译期被看见。命令面向内部扩展,丢掉比崩掉更安全。
出处:codex-rs/core/src/session/handlers.rs 第 684 行
词表会变。先决定未知 type 的默认方向:拒绝打开、跳过坏行,或收成 Unknown。三条都能抄,不要让三条路径各做一套却不写下来。真源事件和通知流可以给不同默认值,但要写在信封上。
DSH:未知且未标 ignorable 就拒绝
DSH 把事件日志当成真源。信封上有一个 ignorable?: true。缺这个标记时,读取器碰到不认识的 type 必须拒绝重建,不能悄悄丢掉。忘了打标记,结果是过分拒绝,比静默恢复一份被掏空的会话更安全。
代价很清楚:旧 harness 打不开新日志。换来的是「能打开就完整」。Codex 的 EventMsg 已经 81 个,还要给 exec 输出和审批发瞬时事件,这些东西若全部成为真源,JSONL 会按 token 涨。
Grok:未知收成 Unknown,必须静默忽略
Grok 的会话事件协议只有 6 个变体。Unknown 带 #[serde(other)]。模块头写明:旧消费者碰到新的 event_type,解成 Unknown,不要失败。消费者必须静默忽略。原始类型名不会被保留。
适合通知流。通知丢了,会话还能靠别的状态活。Codex 的 TurnStarted 是 rollout 截断边界,真源事件不能静默丢。resume 路径选择跳过坏行,比 Grok 更接近「打开」,比 DSH 更接近「尽量打开」。
三行 JSON,四个出口
准备三行,type 分别是 task_started、turn_started、future_event。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败,哪一行会让 DSH 拒绝整份日志,哪两行在 Codex 里其实是同一个变体。
进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started",旧 rollout 会在哪一条边界上断。
type 先选一条默认方向:拒绝、跳行,或收成 Unknown。
对外协议是投影
IDE 看见的是 Thread / Turn / Item,不是内核 EventMsg。一次 turn/start 先回响应,再推事件流;审批是反向请求,不回包这一轮就停住。
turn/start 的回包只表示请求被接受,真正开跑看 turn/started;Python SDK 和 TypeScript SDK 走的不是同一条协议面。
turn/start 的 params。回车即播放。
turn/start 的响应立刻回来,只表示请求被接受。真正开始转圈,要等后面那条 turn/started 通知。你在给编辑器写插件。调试器里已经能看到内核往外抛事件:turn_started、exec_command_begin,字段是 snake_case。第一包数据过来,对不上。方法名是 turn/started,中间是斜杠。字段是 threadId、startedAt。
命令开始时你等的 exec_command_begin 没出现,来的是 item/started,里面塞着一个 type: "commandExecution" 的 item。审批更怪:服务端反向发来一条 request,你得回 response,否则这一轮卡在那儿。
如果编辑器按 81 种 EventMsg 写 switch,每加一种内部事件都是一次客户端升级。deprecated 别名也会从仓内兼容问题变成对外合同。
调度函数 apply_bespoke_event_handling 吃一条内核 Event,按四条规则收成对外消息。
EventMsg 的 snake_case type 变成 turn/started、item/agentMessage/delta 这种资源路径,字段改成 camelCase。
delta 和工具生命周期被收进 ThreadItem,再塞进 item/started 或 item/completed。IDE 按 item 的 type 画卡片。
ExecCommandBegin、ViewImageToolCall、以及 match 末尾的通配臂,线上没有对应通知。旧事件还在给 rollout 扇出。
一条 ItemStarted(DynamicToolCall) 既发通知,又发 item/tool/call 这条 ServerRequest,等客户端执行。
出处:codex-rs/app-server/src/bespoke_event_handling.rs 第 159 至 188 行;codex-rs/app-server/src/bespoke_event_handling.rs 第 880 至 918 行;codex-rs/app-server/src/bespoke_event_handling.rs 第 996 至 1036 行
item_event_to_server_notification 只覆盖一对一、无状态的投影。函数名像总入口,调用点才知道它是助手。ExecCommandBegin 在助手里还能变成 item/started,在调度里却走进 deprecated 空分支。现场命令卡片来自后面的 ItemStarted。以调度为准。
出处:codex-rs/app-server-protocol/src/protocol/event_mapping.rs 第 25 至 37 行;codex-rs/app-server-protocol/src/protocol/item_builders.rs 第 1 至 11 行
内核按发生了什么命名,对外按用户看见什么命名。内部还可以继续发 deprecated 事件给 rollout,调度写一句注释丢掉即可。换语言重写,这张表还在:左边内部 type,右边写清留下、改名、丢掉还是拆开。
未知行必须失败。空默认等于通配臂,新事件能通过编译,IDE 的 stdout 上什么都没有。
出处:codex-rs/app-server/src/bespoke_event_handling.rs 第 1238 至 1245 行
同事把 turn/start 的响应当成一轮已经开始。响应立刻回来,里面是一份空 items 的 turn。模型还没开口。真正开跑是后面那条 turn/started 通知。
出处:codex-rs/app-server/README.md 第 81 至 81 行
审批做成普通 notification,客户端可以不理。turn 会停在等待上,直到超时或中断。
线上能解出来的对象只有四种:带 id 的 request、不带 id 的 notification、成功 response、错误 response。看起来像 JSON-RPC,结构体里没有 jsonrpc 字段。常量 JSONRPC_VERSION 还在,线上不带这个键。
出处:codex-rs/app-server-protocol/src/rpc.rs 第 1 至 11 行;codex-rs/app-server-protocol/src/rpc.rs 第 34 至 72 行
对外消息是四套,而且不对称。
1. ClientRequest:客户端问,等人回包。initialize、turn/start 是稳定面主干。
2. ServerNotification:服务端推,不等回包。turn/started、item/started 在这里。
3. ServerRequest:服务端问人。第一条稳定方法是 item/commandExecution/requestApproval。
4. ClientNotification:展开之后只有 Initialized。
出处:codex-rs/app-server-protocol/src/protocol/common.rs 第 1663 至 1670 行;codex-rs/app-server-protocol/src/protocol/common.rs 第 1954 至 1956 行
请求要回执,通知是广播,反向请求把人拉进环。这三件事混成一种,编辑器要么空转等开跑,要么漏画审批按钮。id 对得上,过载时还能把 request 失败回给调用方,避免审批悬挂。
实验方法有 57 个方法级标记。如果靠第二端口,稳定客户和冒险客户要连两个地方。TUI 如果因为同进程就改收 EventMsg,现场通知和远端 IDE 会各写一份 item。
实验面靠 initialize 时一个布尔 experimentalApi,缺省 false。再 initialize 会收到 Already initialized。没开开关就打 server/diagnostics,错误码 -32600,句子是固定的 server/diagnostics requires experimentalApi capability。Python SDK 把这个默认改成 True,官方脚本已经站在实验合同上。
出处:codex-rs/app-server/src/message_processor.rs 第 891 至 895 行;sdk/python/src/openai_codex/client.py 第 209 至 209 行
TUI 不直连 core。内嵌只换载体:socket 和 stdio 换成内存通道,MessageProcessor 还在。请求仍是 ClientRequest,响应仍走同一套 envelope。进程内是 transport-local,不是 protocol-free。
出处:codex-rs/app-server/src/in_process.rs 第 1 至 24 行
TypeScript SDK 不走这条路。它拼的是 exec --experimental-json,事件 type 是点号,字段是 snake_case,完整枚举只有 8 个变体。没有 initialize,没有审批 request。能力差在协议面,不差在语言。
出处:sdk/typescript/src/exec.ts 第 89 至 90 行;codex-rs/exec/src/exec_events.rs 第 8 至 37 行
远程和本机的差别应落在网络,不落在语义。实验面用 capability,比文档里写一句实验更硬。一个布尔把稳定面和实验面切开,schema 生成出两份,默认那份不含实验字段。
DeepSeek Harness:内核类型就是协议类型
DSH 五个入口共用同一棵插件树。headless 的入口配置把自己写成 composition base:负责拼插件,不另写一套事件类型。跨进程时 Typert 从 TypeScript 类型图生成 stub,@Remote('create') 返回的是 identity,不是另一套展示模型。
改一个事件字段,五张脸一起变。收益是不会出现 Python 看见 thread/started、TypeScript 看见 thread.started 这种分裂。Codex 反过来,内部可以标 deprecated 继续扇给 rollout,对外合同按投影层冻结。漏改投影,客户也看不见,只是功能丢了。
Claude Code:入口标记,没有第二协议面
还原源码里能找到的是入口判断:CLAUDE_CODE_ENTRYPOINT === 'claude-vscode' 时返回 claude-vscode。没有对位的对外 IDE 协议 crate。扩展靠 MCP 和进程入口嵌进来,第三方 IDE 没有一份带 schema 的双向 RPC 可以对。
Codex 付了投影层的维护成本,换来 VS Code 扩展、Python SDK 和本机 TUI 共用同一份 v2。
已核对源码 · 2026-08-22 · restored-src/src/main.tsx 第 823 至 823 行回包到了,该不该转圈
turn/start 的响应已经回来,items 是空的。编辑器现在该转圈,还是该等 turn/started?如果内核新加一个 EventMsg 变体,投影没跟上,stdout 上会出现什么?
进阶一问:同一轮对话里模型要跑一条需要提问的命令。Python 客户端可以弹窗并回包,TypeScript 的 Thread.run() 为什么做不到?
流式输出怎么在终端两区之间定稿
模型按 token 往外推,终端却是一个写出去就改不了的字符网格。已经不会变的行交给 scrollback,还可能变的尾巴留在活动 cell,表格没闭合之前整段扣住。
- 没有换行的 delta 不改可见尾巴streaming.rs L489
- 收集器等到换行才提交 sourcemarkdown_stream.rs L87
- 扫描器看上一行是不是表头table_holdback.rs L23
- 确认表之后从 header 起整段扣在尾巴controller.rs L384
- 散文行入队,等 tick 写进 scrollbackcontroller.rs L343
- tick 把稳定行写成 HistoryCellstreaming.rs L399
- insert_history 写进终端 scrollbackinsert_history.rs L3
- 流结束才把整张表一次定稿controller.rs L160
你盯着终端看模型写答案。散文还好,一行一行往下长。接着它开始吐一张表:先出表头,再出分隔行,再出第一行数据。列宽每来一行就变一次。刚才对齐好的 Description 被挤到下一列,上一帧的竖线还印在屏幕上。
你往上滚想看刚才那句结论,滚轮动了,历史和正在写的尾巴叠在一起。网页换 DOM 时浏览器会保住滚动位置。终端往 stdout 写一个字,光标就往前走一格。想留住旧答案,又想让表跟着新行改列宽,就得先决定哪些格子属于过去,哪些还属于现在。
Codex 把渲染结果切成两区。能确定不再变的行进动画队列,等 commit tick 写成 HistoryCell,用转义序列塞进终端自己的 scrollback。还可能变的行只活在活动 cell 里,下一帧可以整段换掉。
出处:codex-rs/tui/src/streaming/controller.rs 第 1 至 36 行
控制器同时记两套长度。enqueued_stable_len 是交给队列的行数,emitted_stable_len 是写进 scrollback 的行数。尾巴从 enqueue 边界算起。按 emit 切的话,排队还没写出的行会在活动 cell 里再出现一次。三个指针同向移动,已经 emit 的那一截不许回头改。
没换行的 token 连尾巴都不更新。用户看见的最小时间单位是一行 markdown source。半行表格如果先画出来,下一秒结构一对,列会立刻消失再长出来。未结束的 source 进缓冲,不能改可见尾巴。
出处:codex-rs/tui/src/chatwidget/streaming.rs 第 489 至 492 行
这是格子所有权的合同。已经交给终端 scrollback 的字,进程里没有可写副本。用户用滚轮、搜索、复制,用的是终端自己的能力,TUI 不必再做一份完整历史视口。代价是提交之后不能改。换个语言重写,只要界面是终端字符网格,这道题还在。
markdown 表加一行就能改所有列宽。表头如果已经按窄列冻进 scrollback,后面的长单元格没法回去改它。竖线对不齐,残影留在原处。
普通散文里的竖线也会误伤。一句 status | owner | note 看起来像表头,其实只是一句话。扣得太狠,这段散文会被卡住,直到流结束才放行。
扫描器只认 header 加 delimiter。上一行像表头、下一行还没到,先乐观扣住,状态叫 PendingHeader。后面来的如果是普通散文,状态回到 None,那一行再进稳定队列。两行对上了,进入 Confirmed,从 header 起整张表留在尾巴,直到 finalize。表前面的散文可以继续提交。
出处:codex-rs/tui/src/streaming/table_holdback.rs 第 21 至 32 行
尾巴预算由扫描状态决定。None 时预算是 0,行直接进稳定队列。PendingHeader 和 Confirmed 则从 header 起点起整段扣住。Raw 模式预算也是 0,表格当纯文本流走,列宽问题交给用户自己的终端选区。
出处:codex-rs/tui/src/streaming/controller.rs 第 384 至 412 行
sh 围栏里的竖线当代码,不触发扣留。连续多张表时,第一张没结束,后面的表也不提前定稿,超长多表答案会在活动区堆到流结束。
任何按增量画表的界面都有这个问题。列宽是全局量,局部追加会改已经画过的行。把整张未闭合的表留在可变区,是这条约束的最小解。网页里对应的做法是,表格节点在闭合之前不要拆进不可变的 DOM 片段。
稳定行入队之后,立刻全部写进终端也不对。慢流希望一行一行长出来,像打字。快流希望队列赶得上模型。只有一档速度,两端都会难受。
还有一帧里的两处写入。历史行用转义序列写到 viewport 上方,ratatui 再画输入框和活动尾巴。拆成两次刷新,用户会先看见历史往上跳一截,输入框还停在旧位置。
排水分两档。平滑档每个 tick 出一行,追赶档一次抽空当前队列。深度到 8 行,或者最老一行超过 120 毫秒,就能进追赶。退出要深度降到 2、年龄降到 40 毫秒,并且保持 250 毫秒。退出后再挡 250 毫秒,除非堆到 64 行或 300 毫秒。策略不看这段文本是标题还是表格,只看队列深度和年龄。
出处:codex-rs/tui/src/streaming/chunking.rs 第 82 至 125 行
定时线程只按帧间隔发 CommitTick,抽多少行是策略的事。这个间隔等于 120 FPS 的下限,平滑档上限就是每秒 120 行。写屏时,scrollback 插入和 viewport 绘制包进同一次 sync_update。双 buffer 只把变过的格子写出去。画回调必须画满整帧,少画一块,终端会留下上一帧的残字。
出处:codex-rs/tui/src/tui.rs 第 954 至 973 行
两档齿轮对付的是同一种队列的两种压力。深度抓一下子来了很多行,年龄抓行不多但等太久。一帧里的多处写入要原子提交,终端用同步输出协议,网页用一次 DOM 替换。先清屏再画,中间帧一定会被人眼看见。
Claude Code:整棵消息树都能改,同步输出先问终端
Claude Code 的 TUI 是 Ink。每帧先调和 React 树,再对前后屏做 cell diff,最后决定要不要用 DEC 2026 的 BSU/ESU 包一层。探测函数写得很直:支持时能避免重绘闪烁;tmux 会拆包,原子性已经没了,再发这 16 个字节只是给外层终端添负担,所以直接跳过。
它没有稳定区、可变尾、表格 holdback 这套。已经画出来的节点,下一帧还能改。代价是调和发生在 JS 里。Codex 把已提交行交给终端 scrollback,活动区本来就小,所以每次 draw 都走 sync_update,不再维护一份终端白名单。
已核对restored-src/src/ink/terminal.ts 第 66 至 74 行 · 2026-08-22
Grok Build:历史留在进程里,每个 chunk 作废缓存
Grok 的 pager 也是 ratatui TUI,历史却不交给终端 scrollback。Agent 块按 EntryId 活在进程里的 ScrollbackState。来一个 chunk,就往同一块追加文本,立刻 invalidate_cache,并标记高度脏。下一次布局按新宽度重算。
宽度一变,进程里的块能按 source 重画。Codex 已经 emit 的行属于终端,只能等 finalize 之后用完整 source 再生成一份可重绘的 cell。Grok 付的账是内存里挂着全部块,每个 chunk 都作废布局缓存。
已核对xai-grok-pager/src/scrollback/state/mod.rs 第 915 至 926 行 · 2026-08-22
超长段落一直不换行,屏幕上会停在哪
模型在一个超长段落中间一直不吐 \n。对照 push_delta 只在看到换行才提交渲染,以及「Unterminated source is buffered by the controller and cannot change the visible tail」这行注释,写出:可见尾巴何时更新,这段文字何时进入 scrollback。
进阶一问:如果这段文字其实是表格的半截行,扣留打开和关掉时,用户分别会看见什么。
把架构决策写成 lint
同一份 AGENTS.md 里,有命令的规则会在三台操作系统上亮红。只有路径的那条,重命名之后没人发现。
- 调用点是不是匿名字面量lib.rs L261
- 注释名字是否等于参数名lib.rs L222
- 被调方是不是 workspace cratelib.rs L177
- CI 是否三平台同时跑rust-ci.yml L174
- Markdown 路径是否存在AGENTS.md L35
- Feature 是否登记在穷尽表lib.rs L379
- 开发中特性默认必须关闭tests.rs L18
新人接到任务:改 MCP 工具调用。它打开 AGENTS.md,抄下第 35 行的路径。文件不存在。真实文件叫 connection_manager.rs,就在同一个目录。文档里那个带 mcp_ 前缀的名字,是一次重命名之后没改干净的残留。
出处:AGENTS.md 第 32 至 36 行;codex-rs/codex-mcp/src/connection_manager.rs 第 1 至 15 行
同一份文件里,位置参数少了 /*base_url*/,本地命令会红。改了 Cargo.toml 忘刷 Bazel 锁,CI 会红。第 35 行那条路径没有检查器。Markdown 不会自己核对文件在不在。
先改 API,让调用点自己能读。foo(false) 的读者必须跳到定义才能知道这个 false 管什么。改不了 API,才允许 /*param_name*/。lint 是退路。
出处:AGENTS.md 第 14 至 20 行
实现住在独立的 Dylint 库,当一次 rustc。类型解析完成后,才能拿到被调方的参数名。入口只看函数调用和方法调用,宏展开出来的直接跳过。
检查按这个顺序走。
1. 只查本仓库 crate,std 和 tokio 直接放过。
2. 注释从参数前的空隙、前 64 字节、参数文本自身三处找。
3. 名字不对报 mismatch。错注释不会再落到没写注释那条。
4. 没写时,方法名等于唯一参数名就豁免,例如 .enabled(false)。
5. 剩下的只拦匿名字面量。None、布尔、数字要写,字符串和字符放过。
出处:tools/argument-comment-lint/src/lib.rs 第 165 至 180 行;tools/argument-comment-lint/src/lib.rs 第 261 至 274 行
仓库入口把默认 Allow 的那条抬成 deny。CI 在 Linux、macOS、Windows 各跑一次,一台失败另外两台继续跑完。人在 macOS 上绿了,Windows 目标的宏展开若多出一处 None,第三台仍会拦住。
出处:.github/workflows/rust-ci.yml 第 164 至 187 行
调用点局部、名字可解析、误报能用豁免收住。换个语言,形状一样:先改名字,改不了就要求行内名字。TypeScript 用 ESLint,Python 用 ruff,都用得上。
第 35 行和第 265 行是同一种腐坏。app-server 指南还写着 v2.rs,当前是目录 v2/,下面拆成三十多个文件。文件靠近 800 行就要拆。拆了之后,指南里的单文件路径没人改。
出处:AGENTS.md 第 260 至 266 行
模块行数规则点名五个高频文件,四个已经越过 800,一个贴着 900。chat_composer.rs 按行计有 12859 行。仓库里没有数行数的命令。行数能数,CI 不数。一次改动是不是机械,机器做不好,所以 800 行上限停在评审。
出处:AGENTS.md 第 49 至 61 行;AGENTS.md 第 125 至 131 行
把规则分成两套来读。一套有命令或编译器,合并前会亮红。一套只能被人和评审读,漏看就过。路径是否存在本来最容易检查:抽出反引号路径,对仓库根做存在性判断。仓库没做。预算花在调用点可读性上,没有花在路径存在性上。
文档不会自己复查。能局部检查却只写在 Markdown 里,重命名和拆文件的那天,文字还在,对象已经搬家。最小形态是二十行脚本核对路径,不需要 rustc 插件。
特性开关如果只靠布尔和一篇说明,漏登记、开发中默认打开、Deprecated 一直待着,都不会第一时间亮红。
Feature 枚举旁边有一张 FEATURES 表。FeatureSpec 把标识、配置键、阶段、默认是否打开焊在同一行。表里找不到对应项就 unreachable!。枚举多一个变体、表少一行,运行到 key() 会直接崩。
出处:codex-rs/features/src/lib.rs 第 41 至 58 行;codex-rs/features/src/lib.rs 第 819 至 826 行;codex-rs/features/src/lib.rs 第 379 至 384 行
旁边两道测试锁住默认值。开发中的特性默认必须关闭。默认打开的特性,阶段只能是 Stable 或 Removed。阶段有五态,多出来的 Experimental 带着菜单名和公告。Deprecated 没有过期日,三个 Deprecated 项仍能打开。阶段能表达不该再用,不能表达下个版本删。
出处:codex-rs/features/src/tests.rs 第 17 至 28 行;codex-rs/features/src/tests.rs 第 82 至 94 行
穷尽表加两条测试,换语言也成立。漏登记就崩,默认值被锁住。换不来自动删除,只换来这两条不变量。
DSH:每个包必须露面,空也要解释
DeepSeek Harness 把「每个包必须拥有 ./invariant」同时写成散文和门禁。散文在 packages/AGENTS.md。门禁是 21 行的 verify-package-invariants,失败就 process.exit(1)。空安装器必须带固定前缀 No runtime invariant:。空是显式架构结论,以后引入可变状态,必须换成真正的检查。
笔记回答为什么允许空,检查器保证空必须解释。两者缺一,就会回到 Codex 第 35 行那种状态:文字还在,对象已经搬家。DSH 没有 rustc 插件去管 foo(false)。Codex 没有穷尽式包门禁去管路径存在性。
出处:packages/AGENTS.md 第 18 行;scripts/verify-package-invariants.ts 第 1 至 21 行
Grok:能局部化的决策直接丢进 clippy
Grok Build 仓库根没有 AGENTS.md。它仍把一条架构决策写成 lint:clippy.toml 禁止 canonicalize,理由是 Windows 上会得到 verbatim 前缀,破坏 git、泄漏进模型上下文。执行边界写在同一份文件:这条禁令由各 crate 的 cargo clippy presubmit 执行,只走 Bazel 的 crate 要靠人看。
和 Codex 的参数注释是同一类判断:调用点局部、误报面可控。Grok 承认 Bazel 覆盖不全。Codex 承认本地只跑当前操作系统。小团队先抄路径存在性和 21 行 verify 脚本,比抄 Dylint 便宜。
出处:clippy.toml 第 9 至 28 行
先做哪一道自动检查
AGENTS.md 第 35 行和第 265 行都是失效路径。若你只能先做一道自动检查,你检查带 codex-rs/ 前缀的路径,还是检查所有反引号里含 / 的字符串?
第一种会漏掉 app-server-protocol/src/protocol/v2.rs 这种相对写法。第二种会把命令名、crate 名和网址碎片误伤。写出你的过滤规则,并用这两条失效路径当正例。
两种安全视角:同一条命令,两套判词
出事之前有没有门可以拒绝,出事之后能不能复原模型当时看见的世界。同一条危险命令上,这两套视角会一致,也会给出相反结论。
- 同一条 argv 同时交给两套视角L场景
- 可拒绝先看 Decision 三态,用最严的那一档decision.rs L9
- 审批缓存的 key 带完整 argv,不认前缀unified_exec.rs L92
- Guardian 超时或坏输出就关闸guardian/mod.rs L11
- 平台沙箱三套后端,Windows 关着就是 Nonemanager.rs L37
- 可回放认 JSONL 原文,SQLite 只是镜像README.md L22
- fork 必须撞到真实的 TurnStartedthread_rollout_truncation.rs L187
- 失败写成观察,success 仍为 truecontext.rs L351
- 代理 403 回给命令进程,循环继续responses.rs L80
- 对照两份判词:一致还是相反L验收
周一早上,安全同事把一段聊天记录甩到群里。模型昨晚连了外部 API,请求头带着仓库里的部署令牌。他问:当时模型究竟看见了什么环境变量。你打开会话,标题还在,点进去对不上。SQLite 里有一行元数据,JSONL 缺了半截。
可回放要回答的就是这件事。出事之后,你能不能精确复现模型当时看见的世界。
Codex 把历史写成两份。JSONL 是原文,只追加已经定稿的条目,不从内容里推断元数据。SQLite 是镜像,给列表和检索用。元数据丢了可以再抽。JSONL 丢了,恢复必须读文件。
出处:codex-rs/thread-store/README.md 第 22 至 28 行
然后还有一道筛选。持久化策略会丢掉流式增量、审批弹窗、警告和 MCP 启动进度。TurnStarted 留下。你能回放 turn 边界和完成态,回放不了当时屏幕上闪过的审批文案。
出处:codex-rs/rollout/src/policy.rs 第 86 至 105 行
fork 认的也是这条物理边界。目标 turn 必须在有效历史里,文件里真有一条 TurnStarted,进行中的 turn 直接拒绝。投影出来的合成 ID 不能当切点。
出处:codex-rs/core/src/thread_rollout_truncation.rs 第 187 至 191 行
所以真相也经过筛选。列表能告诉你有过这个线程。只有 JSONL 能告诉你模型当时看见了哪几条消息。
原文和投影拆开,投影坏了可以重建,原文坏了现场就没了。换一套存储,该问的还是谁是原文。这份合同不随语言变。
周三下午,另一台机器上的 agent 跑完了 git reset --hard。策略文件写过禁止。审批弹窗那天太多,有人点了记住。沙箱开着,那条命令没碰受保护的路径,内核没拦。你事后能把 rollout 完整回放一遍。回放告诉你它做过什么,没有在它做之前把路堵上。
可拒绝要回答的是:出事之前,有没有一道门可以拒绝。
一条命令要从模型提议走到进程启动,Codex 至少经过四道可以拒绝的门。execpolicy 的 Decision 只有 Allow、Prompt、Forbidden,用序取最严。规则文件里的 not_match 加载器真的跑一遍,反例被命中,会话开不起来。
出处:codex-rs/execpolicy/src/decision.rs 第 9 至 16 行
出处:codex-rs/execpolicy/src/rule.rs 第 281 至 306 行
第二道是审批。会话缓存认精确 key,带上规范化后的完整 argv、工作目录、权限。早期常把记住同类理解成前缀缓存。当前源码里,npm run test 和 npm run lint 是两把 key。这道门挡住同一条精确命令的重复弹窗。
出处:codex-rs/core/src/tools/runtimes/unified_exec.rs 第 86 至 97 行
第三道用模型换弹窗。Guardian 超时、坏输出就关闸,只认明确的 allow 或 deny。它挡住审批疲劳。用户本人点批准,这道门会让路。
出处:codex-rs/core/src/guardian/mod.rs 第 1 至 12 行
第四道才是操作系统。macOS 拼 SBPL,Linux 默认 bubblewrap 加 seccomp,失败不回退到遗留 Landlock。Windows 走受限令牌,开关关着就返回 None。进程启动之后,出站再过代理。代理拒绝时把头带 x-proxy-error 的 403 回给命令进程,循环继续。
出处:codex-rs/sandboxing/src/manager.rs 第 36 至 42 行
出处:codex-rs/network-proxy/src/responses.rs 第 76 至 83 行
出处:codex-rs/network-proxy/README.md 第 234 至 238 行
每一层看的东西不一样。策略看 argv,审批看人,内核看路径和 syscall,代理看域名。一层看不清,就停在这一层。规则只写在人读的文件里、没有加载期例子,就会和源码一起漂。AGENTS.md 第 35 行还指向 mcp_connection_manager.rs,仓库里没有这个文件。
出处:AGENTS.md 第 35 行
命令退出码非零,按直觉像错误。若把沙箱拒绝升级成引擎错误,模型看不到退出码,只会换一条更绕的命令。代理 403 若打到引擎,整轮对话停,模型无法改域名再试。
工具层只允许两种失败:回喂模型,或打断引擎。沙箱拒绝走的是成功的工具输出。process_id 被清掉,exit_code 留在正文里。记日志时成功位恒为 true。失败写在 Exit code 那一行。
出处:codex-rs/tools/src/function_call_error.rs 第 1 至 10 行
出处:codex-rs/core/src/tools/context.rs 第 340 至 353 行
可回放要的是观察还在。可拒绝已经在启动前或内核里做过了。这里不再用错误把 turn 打断。代理 403 是同一合同的网络版:命令进程读到人话,输出进 JSONL,模型再决定下一步。
出处:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 第 383 至 411 行
把工具错和引擎错分开,是任何 agent 循环都用得上的形状。退出码、超时、策略拒绝,默认走第一档。只有编排自己坏了,才停整轮对话。
回放:DSH 把看见的必须能重建写成红线
DSH 仓库用同一句话写进 AGENTS.md(CLAUDE.md 是指向它的符号链接)和架构文档:凡是进模型请求的输入,都必须能从会话日志重建。只追加的日志是真相,给模型看的是投影。审批策略只有 ask 和 never,没有 Guardian,也没有进程内出站代理。
Codex 的可回放停在定稿历史。DSH 往前推了一步:新的模型可见输入必须先成为一条会话事件。回放侧的合同更硬,拒绝侧更薄。
出处:AGENTS.md 第 107 行
出处:docs/architecture.md 第 92 至 96 行
出处:packages/interaction/user-approval/src/index.ts 第 84 至 94 行
拒绝:Grok 在启动时装一次隔离
Grok 用 nono 在进程启动时装一次 Landlock 或 Seatbelt,网络在进程级保持打开,子进程用 seccomp 拦网。web_fetch 空名单全拦,loopback 默认放行。Codex 怕的是工具打到本机管理口。Grok 怕的是模型乱访外网,仍给本机开发留门。
出处:crates/codegen/xai-grok-sandbox/src/lib.rs 第 8 至 12 行
出处:crates/codegen/xai-grok-tools/src/implementations/grok_build/web_fetch/ssrf.rs 第 14 至 18 行
两份判词何时相反
打开上面的演示,切到带令牌出站。推演可拒绝为什么四道门过完仍然放行,可回放为什么有 JSONL 也拼不回环境变量。
再切到沙箱拦住危险写,看同一套机制这次为什么给出一致的判词。