Codex 源码导读:第二部分------一次 Turn 的完整生命周期
本文承接《第一部分------工程分层》,沿着第一部分预告的主线往下走:一次用户输入如何驱动「模型 → 工具 → 模型」的循环,并在什么时候终止。
阅读粒度是「微单元」:一次只钉住一个函数或一条分支,给出真实文件路径、行号与骨架伪代码。所有行号基于 codex-rs 当前检出 d58d0e5841;行号会随仓库演进漂移,回看时先用 git log -1 与 grep -n 复核。
阅读主线(六个微单元):
bash
用户回车
└─ 1. 开新 turn 还是插进正在跑的 turn session/turn_input.rs
└─ 2. Turn 任务的启动 / 结束 / 打断 tasks/mod.rs
└─ 3. 为什么 turn 结束了还会再跑一轮 tasks/regular.rs
└─ 4. run_turn 骨架 session/turn.rs
└─ 5. 一轮如何判定结束 session/turn.rs(end_turn)
└─ 6. 流式读取与工具执行 session/turn.rs + tools/parallel.rs
阅读主线(后五个微单元:工具安全链,接在第 6 单元之后):
bash
工具并发闸门(tools/parallel.rs:155-159)
└─ 7. 工具执行的阶段划分与结果去向 tools/registry.rs + tools/events.rs
└─ 8. 审批三段式与三级提问者 tools/orchestrator.rs + tools/approvals.rs
└─ 9. 审批怎么变成一次「等待」 session/mod.rs + state/turn.rs
└─ 10. 审批弹窗全流程 protocol/src/approvals.rs + tui/approval_overlay.rs
└─ 11. 谁判「要不要问」 exec_policy.rs + execpolicy/
总图:一次 turn 的正常流程(端到端)

单元 1:回车之后------开新 turn 还是插进正在跑的 turn
落点 :codex-rs/core/src/session/turn_input.rs:551 steer_input
用户按下回车后,系统要回答的第一个问题是:这是新的一轮,还是对正在跑的那一轮的补充? 答案不是猜的,而是靠"当前有没有可 steer 的活动 turn"来判定。
steer_input 的守卫条件(按顺序):
rust
async fn steer_input(&self, input, ...) -> Result<String, NotSubmittedReason> {
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(NotSubmittedReason::NoActiveTurn); // :562 ★没有活动 turn
};
let Some(active_task) = active_turn.task.as_ref() else {
return Err(NotSubmittedReason::NoActiveTurn); // :566 ★有 turn 但任务已结束
};
let active_turn_id = &active_task.turn_context.sub_id;
if let Some(expected) = expected_turn_id && expected != active_turn_id {
return Err(NotSubmittedReason::ExpectedTurnMismatch { .. }); // :573 客户端指定的 turn 已过期
}
match active_task.kind {
TaskKind::Regular => {} // :580 只有 Regular 可插话
TaskKind::Review => return Err(ActiveTurnNotSteerable { .. }),// :582
TaskKind::Compact => return Err(ActiveTurnNotSteerable { .. }),// :587
}
if matches!(input, SubmittedTurnInput::UserInput { content, .. } if content.is_empty()) {
return Err(NotSubmittedReason::EmptyInput); // :594
}
// 输出 schema 不一致也拒绝(:599)
let mut pending_input = merge_additional_context_input(self, additional_context).await; // :604
机制小结:
- 返回
Err(NoActiveTurn)→ 上层据此开一轮新 turn - 返回
Err(ActiveTurnNotSteerable)/ExpectedTurnMismatch/EmptyInput→ 既不开新 turn 也插不进去,向用户报错 - 成功 → 输入被塞进当前 turn 自己的
TurnState.pending_input.items,steer_input全程不碰cancellation_token,不会打断正在执行的那一圈
易踩的认知点 :TaskKind::Review/Compact 是不可 steer 的------审查轮和压缩轮期间用户插话会被拒绝,而不是排队。
单元 2:Turn 任务的启动、结束与打断
落点 :codex-rs/core/src/tasks/mod.rs:271 spawn_task → :282 start_task
rust
pub async fn spawn_task<T: SessionTask>(self: &Arc<Self>, turn_context, input, task: T) {
self.abort_all_tasks(TurnAbortReason::Replaced).await; // :277 ★开新 turn 先杀掉旧的
self.clear_connector_selection().await;
self.start_task(turn_context, input, task).await; // :279
}
pub(crate) async fn start_task<T: SessionTask>(...) {
let task: Arc<dyn AnySessionTask> = Arc::new(task);
let started_at = Instant::now();
let turn_started_at_unix_ms = turn_context.turn_timing_state.mark_turn_started(started_at).await;
let token_usage_at_turn_start = self.total_token_usage().await.unwrap_or_default();
let cancellation_token = CancellationToken::new(); // :301 每个 task 一枚取消令牌
let done = Arc::new(Notify::new()); // :302 完成信号
// ... 起 span、登记 active_turn、tokio::spawn 任务主体
}
关键设计 :spawn_task 的第一件事是 abort_all_tasks(Replaced) ------ Codex 的模型是「一个 session 同时只有一个活动 turn」 ,新的把旧的顶掉(TurnAbortReason::Replaced),不存在两个 turn 并行跑。
任务的生命周期由三样东西管理:cancellation_token(打断)、done(Notify,结束)、以及 Session.active_turn 里的登记项(决定 steer_input 能否找到它)。
插讲:span 是什么
落点 :tasks/mod.rs:350(TurnStarted 事件发送)、:397、:712
读到这里会反复遇到 trace_span! / field::Empty。这不是 agent 业务逻辑,是 tracing 框架的链路追踪单元:
- 一个 span = 一段有时间跨度的上下文(如"这一轮 turn"、"这一次工具调用"),有自己的名字和一组 key-value 字段
- span 可以嵌套,形成树;
parent: &receiving_span就是显式挂父节点 field::Empty是先占位、后填值 :声明时值还不知道(如tool_name、token_usage),后面拿到再record- 它不参与控制流,去掉不影响行为,但决定了 Codex 的 otel 遥测质量
读源码时看到 span,直接跳过,只当它是"这段代码属于哪个逻辑单元"的标注。
单元 3:为什么 turn 结束了还会再跑一轮
落点 :codex-rs/core/src/tasks/regular.rs:39-96(RegularTask::run)
这是整个生命周期里最容易看糊的一段:最外层是一圈 loop,但它不是在跑模型。
rust
async fn run(self: Arc<Self>, sess, ctx, input, cancellation_token) -> SessionTaskResult {
let run_turn_span = trace_span!("run_turn");
// :49-63 ★先把 TurnStarted 事件发出去,不等启动预热完成
let prewarmed_client_session = async {
sess.send_event(ctx.as_ref(), EventMsg::TurnStarted(TurnStartedEvent { .. })).await;
sess.set_server_reasoning_included(false).await;
sess.consume_startup_prewarm_for_regular_turn(&cancellation_token).await
}.instrument(trace_span!("regular_task.prepare_run_turn")).await;
let prewarmed_client_session = match prewarmed_client_session {
Cancelled => { run_hooks_and_record_inputs(..).await; return Ok(None); } // :65-68
Unavailable { .. } => None,
Ready(session) => Some(*session),
};
let mut next_input = input;
let mut prewarmed_client_session = prewarmed_client_session;
loop { // :76 ★外层 loop
let last_agent_message = run_turn(
Arc::clone(&sess), Arc::clone(&ctx), next_input,
prewarmed_client_session.take(), // :81 预热连接只用一次
cancellation_token.child_token(),
).instrument(run_turn_span.clone()).await?;
if ctx.terminal_error.lock().await.is_some() {
return Ok(last_agent_message); // :89 致命错误:不再重跑
}
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return Ok(last_agent_message); // :92 ★队列空了才真正收工
}
next_input = Vec::new(); // :94 再跑一轮,但不带新输入
}
}
为什么要有外层 loop :run_turn 返回只说明"内层循环结束了",不说明"用户没话说了"。存在一个时间窗------run_turn 收尾(跑 Stop hook、收工具结果)期间用户又打了字------这些输入属于"这一轮结束时才到",只能由外层接住,再开一轮 run_turn。
注意 :94 next_input = Vec::new():第二轮起不再传新输入,因为输入已经通过队列被消费过了,避免重复。
TurnStarted 事件的位置也值得注意 (:47-48 的注释):Regular turn 把 TurnStarted 内联发,为的是"第一轮生命周期不等启动预热解析",即 UI 能立刻显示"开始了",而不是卡在预热的 await 上。
单元 4:run_turn 骨架
落点 :codex-rs/core/src/session/turn.rs:156-603(448 行,turn.rs 最大函数)
结构分三段:准备 156-302 → 内层 loop 304-600 → 返回 602。
准备阶段(:164-286)
| 做什么 | 行 | 内容 |
|---|---|---|
| 收尾上一轮 | :164 |
drain_async_hook_results(before_user_prompt=true)------hook 是用户可配置的外部脚本钩子(turn 前/后、工具前/后执行),异步执行、结果可能迟到 |
| 定连接 | :166 |
复用 prewarmed_client_session,否则新建;整轮重试共用这一个 |
| 定视野 | :172-263 |
:172 run_pre_sampling_compact(发请求前先看要不要压历史;:180 TurnAborted → 先记输入再抛 Err;:192 其他错误 → 报 Error 事件后 return Ok(None))→ :196 扫输入确定需要哪些 MCP server → :210 capture_step_context(required)("拍照":本轮模型看什么、用哪个模型)→ :227 record_context_updates() + 并发算 diff 根目录 → :253 注入 skills/plugins 条目 |
| 记输入 | :265-286 |
:269 用户输入以 PersistContext::TurnStart 记进历史;:268 can_drain_pending_input = input.is_empty();跑 session-start hook、合并 connector 选择、记 analytics |
内层 loop(:304-600)
rust
loop {
if can_drain_pending_input && sess.input_queue.has_pending_input(..).await {
input = sess.input_queue.get_pending_input(..).await; // :310 ★抽干式(split_off(0))
}
if record_conversation_items(&sess, &turn_context, &input).await { break; } // :317
// :329-370 记时间提醒 / world_state 变化
let req_input = sess.clone_history().await.for_prompt(..); // :373
let result = run_sampling_request(.. req_input ..).await; // :384 ★工具在它内部执行完
match result {
Ok(SamplingRequestResult { needs_follow_up, last_agent_message }) => {
can_drain_pending_input = true; // :411
let needs_follow_up = model_needs_follow_up
|| sess.input_queue.has_pending_input(&sess.active_turn).await; // :426 ★只看不抽
if 上下文撑不住 { // :461
run_auto_compact(..).await; // :473
continue;
}
can_drain_pending_input = !model_needs_follow_up; // :499
if !needs_follow_up {
// :510-562 跑 Stop hooks;:522-538 hook 要求继续则 continue
break; // :562
}
}
Err(CodexErr::TurnAborted) => return Err(..), // :566
Err(e) => { 发 Error 事件; break; } // :588-597
}
}
return last_agent_message; // :602
四个容易看错的地方:
can_drain_pending_input三个赋值点语义完全不同 ::268初值= input.is_empty()(本轮带新输入进来时先不抽队列,避免用户刚打的字和上一轮 steer 挤在一起、顺序乱);:411采样成功后置true;:499= !model_needs_follow_up(模型刚说要继续调工具时,先让它继续干活):416的has_pending_input只看不抽 ,真正抽干在:310(split_off(0),实现见session/input_queue.rs:305)。所以同一句用户输入不会被处理两次needs_follow_up是"或"不是"二选一" :模型要调工具 || 队列有货。两者都让内层continue,下一圈把工具结果 + 用户插话放进同一个请求Ok(None)≠ 失败 。:192 :262 :266 :270 :494 :497 :560处的Ok(None)表示"这一轮什么都没产出但不算失败"(被 hook 拦住 / 被压缩中断 / 错误已作为事件报给 UI);Err才是往上抛给外层任务
关于「两个 loop 优先级」的结论
内层和外层不是两个来源在抢优先级 ,而是同一条队列被检查了多次:
arduino
同一队列(session 级 mailbox + turn 级 steer)
├─ 内层 loop :310 抽干式抽(split_off(0)) ← 常态路径,每圈开头
├─ 内层 loop :416 只看不抽 ← 算 needs_follow_up
├─ 外层 :91 只看不抽 ← 兜底,接住收尾瞬间新到的输入
└─ tasks/mod.rs:467 抽干 ← 任务收尾时保存未消费的输入
用户体感的优先级:
| 场景 | 结果 |
|---|---|
| 模型还在干活(要调工具/还在采样),你插话 | 不打断当前圈;等这圈结束,下一圈开头被吃进去------同一轮 turn 内消化 |
| 模型已给出最终答案、这轮在收尾,你插话 | 另起一轮 turn(外层接手),语义接近"你又发了条新消息" |
"不可打断"是有代码依据的:steer_input 里没有任何 cancel 调用;真正打断是独立操作 Op::Interrupt(session/handlers.rs),对应 UI 上的 Esc / Ctrl-C。
mailbox 的额外门控
MailboxDeliveryPhase(state/turn.rs:50)两态:
CurrentTurn(默认):mailbox 消息(子 agent / 外部投递)可被当前轮消费NextTurn:本轮已吐出可见最终答案,mailbox 消息留到下一轮
turn.rs:404 在 model_needs_follow_up 为真时调 accept_mailbox_delivery_for_current_turn() 把状态重开为 CurrentTurn。state/turn.rs:46-48 的注释解释了动机:"如果同一个 task 后来又有了明确的同轮工作(用户 steer,或一个工具调用跟在无标签的前言之后),我们重新打开 CurrentTurn,让积压的子 agent 消息被吸进那个 follow-up 请求里。"
单元 5:一轮如何判定结束
反直觉点:Codex 不是靠"模型不调工具了"判断结束的,而是靠模型自己发的明确信号。
Responses API 的 response.completed 事件带字段 response.end_turn(codex-api/src/sse/responses.rs:124),解析进 ResponseEvent::Completed { .., end_turn }(codex-api/src/common.rs:114-121)。注释写得很直白(common.rs:118):
"Did the model affirmatively end its turn? Some providers do not set this, so we rely on fallback logic when this is
None."
rust
// turn.rs:2610(收到 response.completed 时)
if let Some(false) = end_turn {
needs_follow_up = true; // 模型明说"我还没说完" → 再转一圈
}
只处理 Some(false) :Some(true) 和 None 都不动 needs_follow_up,让它保持由"这一轮响应里有没有工具调用"决定的值。
三层判定
ini
第一层:一次采样流有没有要接着干的事 [try_run_sampling_request 内部]
OutputItemDone 里:
是 tool call → needs_follow_up = true(stream_events_utils.rs:327),工具挂进 in_flight
是普通消息 → 记 last_agent_message(:361)
工具请求被拒 → needs_follow_up = true(:383)
response.completed:
end_turn == Some(false) → needs_follow_up = true(turn.rs:2610)
end_turn == Some(true)/None → 不改(None 时靠"没调工具"兜底)
返回 SamplingRequestResult { needs_follow_up, last_agent_message }(:2613)
第二层:这一轮 turn 还转不转 [turn.rs 内层 loop]
needs_follow_up = 模型还要继续 || 队列有新输入(:426)
真 → continue 假 → 跑 Stop hooks → break(:562)
第三层:整个 task 还跑不跑 run_turn [tasks/regular.rs 外层 loop]
break 之后外层看队列 → 有货就再开一轮(:91)
fallback 到底是怎么兜的
provider 不返回 end_turn(None)时,判据退化为"这一轮响应里没有工具调用,就认为模型说完了"。实际生效的表格:
| 情形 | needs_follow_up |
turn 是否结束 |
|---|---|---|
只有文字消息,end_turn=true |
false | ✅ 结束 |
只有文字消息,end_turn=None(provider 没给) |
false | ✅ 结束(兜底规则生效) |
只有文字消息,但 end_turn=false |
true | ❌ 再转一圈 |
| 有工具调用 | true | ❌ 执行工具后再转一圈 |
第三行是关键 :模型可以"嘴上说完了但声明我还要继续",于是 run_turn 会什么都不带地再采样一圈------这是 GPT-5 类"先说一句、再接着说"行为的实现空间。
唯一的"抢占"通道
turn.rs:2430(在处理流的过程中):
rust
// todo: remove before stabilizing multi-agent v2
if preempt_for_mailbox_mail && sess.input_queue.has_pending_mailbox_items().await {
break Ok(SamplingRequestResult { needs_follow_up: true, last_agent_message });
}
开了 preempt_for_mailbox_mail 且有子 agent 的 mailbox 消息等待时,会直接打断当前采样流 、提前返回并要求 follow-up。这是全机制里唯一能抢占正在进行的模型流的地方,且专给多 agent 用(代码自带 todo)。用户的 steer 没有这个能力。
一句话总结
arduino
turn 结束 ⟺ 没有待执行工具 且 模型没声明"还要继续"(end_turn) 且 队列没新输入 且 上下文没过载
↑ 前两个 = needs_follow_up = false
↑ 后两个 = :426 那一"或"的两边
单元 6:流式读取------工具在流还没读完时就跑了
落点 :turn.rs:1362 run_sampling_request → :2207 try_run_sampling_request
两层函数,各自管一件事
arduino
run_sampling_request :1362 只管"重试":包一个 loop,可重试错误就再来一遍
└─ try_run_sampling_request :2207 管真正"读流":一次 SSE 流从头读到尾
重试策略(:1390-1461):ContextWindowExceeded / UsageLimitReached 直接抛(不重试),其他错误走 handle_retryable_response_stream_error,上限来自 provider.info().stream_max_retries()(:1385)。重试时重新组装 prompt (:1391-1409),因为历史可能已变。
流循环骨架
rust
loop { // :2281-2766
let event = match stream.next().or_cancel(&token).await {
Ok(e) => e,
Err(Cancelled) => break Err(CodexErr::TurnAborted), // :2304
};
let event = match event {
Some(Ok(e)) => e,
Some(Err(e)) => break Err(e),
None => break Err(CodexErr::Stream("stream closed before response.completed")), // :2313 ★
};
match event { /* 三类 */ }
}
None => Err 那行值得注意:流没等到 response.completed 就断开,算错误,不是"正常结束"。
事件分三类:
| 类型 | 事件 | 处理 |
|---|---|---|
| 内容(流式渲染) | OutputTextDelta、ReasoningSummaryDelta、ReasoningContentDelta |
转成 EventMsg::*Delta 发给 UI(:2618 :2741 :2759)------就是用户看到的逐字输出 |
| 结构 | Created、OutputItemAdded、OutputItemDone |
标记 item 起止;Done 里做真正的分派 |
| 终止 | Completed |
取 end_turn、算 token、结束流循环 (:2570-2616) |
★ 核心机制:工具的"提前启动 + 延后收集"
OutputItemDone 收到工具调用时,走 handle_output_item_done(stream_events_utils.rs:290):
rust
// stream_events_utils.rs:317-328
record_completed_response_item(...).await; // 先把工具调用记进历史
let tool_future: InFlightFuture<'static> = Box::pin(
ctx.tool_runtime.clone().handle_tool_call(call, cancellation_token), // ★ 这一行就启动了
);
output.needs_follow_up = true;
output.tool_future = Some(tool_future);
然后(turn.rs:2422-2423):
rust
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future); // 只是"存起来"
}
看起来要等后面才跑,其实不是。 关键在 handle_tool_call 的签名(tools/parallel.rs:74-92):
rust
pub(crate) fn handle_tool_call(self, call, cancellation_token)
-> impl std::future::Future<Output = Result<ResponseItemEnvelope, CodexErr>> // ★ 普通 fn,不是 async fn
{
let future = self.handle_tool_call_with_source(call, source, cancellation_token); // :81 立即调用
async move { future.await ... } // 只有这个是惰性的
}
而 handle_tool_call_with_source(:95)也是普通 fn,body 里第一件事就是:
rust
// tools/parallel.rs:147-178
let mut dispatch_handle = AbortOnDropHandle::new(tokio::spawn(async move {
// 等工具就绪 → 抢并发闸门 → 真正执行工具
router.dispatch_tool_call_with_terminal_outcome(...).await
}));
普通 fn 的 body 在调用时即执行 ,所以 tokio::spawn 在那一瞬间就发生------模型还在继续吐 token、流还没读完,工具已经在后台跑了。
FuturesOrdered 那个"箱子"的作用只是保序收集结果 。流读完后(:2766 循环退出):
rust
// turn.rs:2769-2783
flush_assistant_text_segments_all(...).await; // 刷残留的流式文本给 UI
let tool_blocking_timing_guard = if in_flight.is_empty() { None }
else { Some(begin_tool_blocking()) }; // 记"流结束后还在等工具"的耗时
drain_in_flight(&mut in_flight, sess, turn_context).await?; // :2782 ★这里才收结果
// drain_in_flight(:2159-2182)
while let Some(res) = in_flight.next().await { // 按序收
sess.record_annotated_conversation_items(&turn_context, vec![envelope]).await; // 结果写回历史
}
真实时间线:
css
模型流: ──token──token──[tool_call 完成]──token──...──[response.completed]
↓ spawn
工具: └──────执行中──────┐
↓
流读完 → drain_in_flight 收结果 → 写回历史
↓
下一圈采样(带上工具结果)
收益 :模型输出剩余内容与工具执行并行 ,省掉"等模型说完才开始干活"的空档。代价:流最后报错中断时,工具可能白跑一部分,靠取消机制兜底。
并发闸门:一个读写锁
工具支不支持并行,看 router.tool_supports_parallel(&call)(parallel.rs:116),然后在 spawn 的任务里抢锁(:155-159):
rust
let _guard = if supports_parallel {
Either::Left(lock.read().await) // 读锁:多个并行工具可同时持有 → 并发跑
} else {
Either::Right(lock.write().await) // 写锁:独占 → 其他工具此刻都要排队
};
一个 RwLock 干完所有并发控制------"哪些工具能并发"不是散落各处的 if,而是工具自身的 supports_parallel 属性说了算。
取消靠 AbortOnDropHandle(:147)+ tokio::select!(:182-184):future 被 drop 或 token 被 cancel 时工具任务被 abort。细节在 :185-186------如果工具已跑到"有终局结果",就等它把结果拿出来(dispatch_handle.await),不再 abort。
伪代码总览
ini
run_sampling_request(input):
loop: # 重试循环
prompt = build_prompt(历史 或 首次 input)
match try_run_sampling_request(prompt):
Ok(result) → return
Err(ContextWindowExceeded / UsageLimitReached) → 抛(不重试)
Err(可重试) → 退避重试,超上限才抛
try_run_sampling_request(prompt):
stream = client.session.stream(prompt, ...) # :2238 建立 SSE
in_flight = FuturesOrdered::new()
while let Some(event) = stream.next(): # :2281
OutputTextDelta / ReasoningDelta → send_event(EventMsg::*Delta) # 渲染
OutputItemAdded → 记 active_item
OutputItemDone(item):
记进历史
if item 是工具调用:
tool_future = handle_tool_call(...) # ★ 内部已 tokio::spawn,工具此刻开跑
in_flight.push_back(tool_future) # 只是排队等结果
needs_follow_up = true
else: last_agent_message = 模型这段话
Completed{end_turn} → 算 token;end_turn==Some(false) → needs_follow_up=true;break
flush 流式文本残留 # :2769
drain_in_flight(in_flight) → 按序收工具结果,逐个写回历史 # :2782
if cancelled → Err(TurnAborted)
return SamplingRequestResult{needs_follow_up, last_agent_message}
单元 7:工具执行的阶段划分与结果去向
上一单元停在 parallel.rs:167 的 spawn 之后,这一单元补齐「一条工具调用从被分派到写回历史」的全部阶段,以及每一阶段的结果去向。
分派链
bash
tools/parallel.rs:167 router.dispatch_tool_call_with_terminal_outcome(...)
└─ tools/router.rs:325 dispatch_tool_call_with_terminal_outcome
└─ tools/router.rs:348 dispatch_tool_call_with_code_mode_result_inner
│ 只组装 ToolInvocation(:367)
└─ tools/registry.rs:493 dispatch_any_with_terminal_outcome ★真正的分派
├─ ① 工具存在吗 :532
├─ ② payload 类型匹配吗 :564
├─ ③ PreToolUse hook 拦不拦 :582
├─ ④ tool.handle(invocation) :793(handle_any_tool)
└─ ⑤ PostToolUse hook 拦不拦 :697 :738
五道关口与「错误去向」的对应
| 关口 | 位置 | 失败类型 | 去向 |
|---|---|---|---|
| ① 工具存在性 | registry.rs:532 |
RespondToModel |
模型可见,可自我纠正 |
| ② payload 匹配 | registry.rs:564 |
Fatal |
协议级错误,打断整轮 |
| ③ PreToolUse hook | registry.rs:582 |
Blocked → RespondToModel |
模型可见;Continue{updated_input} 可改写参数 |
④ tool.handle() |
registry.rs:793 |
见下方 | 见下方 |
| ⑤ PostToolUse hook | registry.rs:738 |
should_block → RespondToModel |
只能拒结果,不能撤已发生的副作用 |
判据:RespondToModel 用于「模型有可能自己纠正」 (工具不存在、hook 拦截、业务失败);Fatal 用于「协议/框架级坏了、纠正不了」。
FunctionCallError 全部变体只有两个(codex-rs/tools/src/function_call_error.rs:5-10)。
权限申请:判定与提问分离
权限判定发生在第④层内部,不在这五道关口里。
bash
第④层 tool.handle()
├─ 命令类(shell/unified_exec):exec_policy.rs:409 ┐
└─ 补丁类(apply_patch):safety.rs:26 → apply_patch.rs:51 ┘
↓ 都产出 ExecApprovalRequirement
tools/orchestrator.rs:208 ← 统一"问用户"入口
apply_patch 三态去向(apply_patch.rs:36-60):
SafetyCheck |
变成 | 结果 |
|---|---|---|
AutoApprove |
ExecApprovalRequirement::Skip |
直接执行 |
AskUser |
ExecApprovalRequirement::NeedsApproval |
交给 orchestrator 问用户 |
Reject { reason } |
FunctionCallError::RespondToModel("patch rejected: ...") |
直接给模型,不问 |
apply_patch.rs:45-47 注释说明这是有意设计:"Delegate the approval prompt (including cached approvals) to the tool runtime, consistent with how shell/unified_exec approvals are orchestrator-driven."
ExecApprovalRequirement(tools/sandboxing.rs:152,注释:"Specifies what tool orchestrator should do with a given tool call."):
rust
pub(crate) enum ExecApprovalRequirement {
Skip { bypass_sandbox: bool, proposed_execpolicy_amendment: Option<ExecPolicyAmendment> },
NeedsApproval { reason: Option<String>, proposed_execpolicy_amendment: Option<ExecPolicyAmendment> },
Forbidden { reason: String },
}
→ 策略分散(每个工具自己判)、执行集中(统一由 orchestrator 问) 。proposed_execpolicy_amendment 允许一次审批同时提议「以后类似的都别再问」,把一次性授权升级为持久策略。
结果形态:四次变形
css
① handler 返回 AnyToolResult { call_id, payload, result, post_tool_use_payload } registry.rs:806
② parallel.rs:83 翻译 Ok(r) → r.into_response() 正常输出
Err(Fatal) → Err(CodexErr::Fatal) ★唯一打断整轮
Err(其它) → Ok(failure_response(call, err)) ★错误也变输出
③ 包装 ResponseItemEnvelope { item, metadata } history/src/lib.rs:39
④ 写回历史 turn.rs:2168 record_annotated_conversation_items
最终形态(parallel.rs:219 failure_response):
rust
ResponseItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
success: Some(false),
},
}
match 三分支都 产出一条与 call_id 配对的输出 ------ 这是协议硬约束(有 function_call 就必须有对应 output),也是 in_flight 必须逐个收集 future 的原因。
一次工具调用的全部阶段 × 是否给模型
| # | 阶段 | 出问题时 | 给模型? |
|---|---|---|---|
| 1 | 查工具是否存在 | RespondToModel |
✅ |
| 2 | payload 类型匹配 | Fatal |
❌ |
| 3 | PreToolUse hook | RespondToModel |
✅ |
| 4 | 权限判定 | Reject/Forbidden → RespondToModel |
✅ |
| 5 | 审批提问 | 用户拒绝 → ToolError::Rejected → RespondToModel |
✅ |
| 6 | 真正执行 | 见下 | ✅ 几乎全部 |
| 7 | PostToolUse hook | RespondToModel |
✅ |
| 8 | 写回历史 | --- | --- |
| 9 | 下一圈采样 | --- | ✅ 真正送达 |
第 6 阶段的证据(tools/events.rs:379-460 finish())------ exec 类工具唯一的「结果 → 给不给模型」翻译点:
rust
match out {
Ok(output) if exit_code == 0 => Ok(content) // 成功
Ok(output) => Err(RespondToModel(content)) // ★ 退出码非 0 → 给
Err(Codex(Sandbox(Timeout))) => Err(RespondToModel(response)) // ★ 超时 → 给
Err(Codex(Sandbox(Denied))) => Err(RespondToModel(response)) // ★ 沙箱拒绝 → 给
Err(Codex(其他)) => Err(RespondToModel("execution error")) // ★ 其他 → 给
Err(Rejected(msg)) => Err(RespondToModel(normalized)) // ★ 用户拒绝 → 给
}
exec 类工具的失败路径几乎全是 RespondToModel ------ 命令失败、沙箱超时、沙箱拒绝、用户拒绝,全部变成给模型的一条失败输出。
Fatal 只有两个来源:
- payload 与工具类型不匹配(
registry.rs:577) - 工具任务本身挂掉(
parallel.rs:215Fatal("tool task failed to receive: ..."))
| 性质 | 处理 | |
|---|---|---|
| 业务失败(命令失败 / 沙箱拒绝 / 用户拒绝) | 信息 | RespondToModel → 喂模型,让它补救 |
| 框架失败(payload 不匹配 / task panic) | 事故 | Fatal → 无法补救,打断整轮 |
「这次给」还是「下次给」:永远是下次
一次采样请求是服务端 → 客户端 的单向 SSE 流,客户端在流中间读到 function_call,但没有通道往这个已开的流里塞东西。所以:
ini
采样请求 #1 ──SSE流──▶ function_call ──▶ 工具执行 ──▶ 结果写回历史(turn.rs:2168)
│
needs_follow_up = true → continue
▼
采样请求 #2(prompt = 历史,含工具结果)──▶ 模型此刻才看到
准确表述:工具结果是「这一次调用」产生的,但「送达模型」发生在「下一次采样请求」。内层 loop 存在的意义之一就是这个。
两个附带发现
① 源码承认的语义混淆(看日志别全信) ------ tools/events.rs:435-440:
rust
// NOTE: ToolError::Rejected is currently used for both user-declined approvals
// and some operational/runtime rejection paths (for example setup failures).
// ... which means a subset of non-user failures may be reported as Declined.
// TODO: We should add a new ToolError variant for user-declined approvals.
即用户拒绝 与运行时 setup 失败 共用 ToolError::Rejected,一部分非用户失败会被上报成 "Declined"。排查时看到「用户拒绝了」不能默认真是用户点的 ------ 与端侧埋点「事件名语义过载」是同一类坑。
②「模型可见」与「日志真实」可以分离 ------ PostToolUseFeedbackOutput(registry.rs:232-244):
rust
fn success_for_logging(&self) -> bool { self.original.success_for_logging() } // 日志:工具真实成败
fn to_response_item(...) -> ResponseInputItem { self.model_visible.to_response_item(...) } // 模型:hook 改写后的话
hook 改写结果时,模型看到 hook 的话,埋点记工具的真实结果。排查「模型为什么不知道刚发生的错误」时,先查有没有 hook 换了结果。
另:AnyToolResult::into_response(registry.rs:196-212)会把 fallback_token_limit_override 放进 metadata ------ 工具返回值能反向调整后续 token 预算。
单元 8:审批 ------ 最多问两次,且不一定是问用户

ToolOrchestrator::run 三段式(源码自带编号注释)
rust
// tools/orchestrator.rs:125
// ── 1) Approval ────────────────────────────────────────── :144
match &requirement {
Skip { .. } => if strict_auto_review { /* ★即使是 Skip,严格审查下也要过审 :174-203 */ },
Forbidden { reason } => return Err(ToolError::Rejected(reason)), // :205 直接拒,不问
NeedsApproval { .. } => {
session.request_approval(action, ctx).await?; // :226 ★第一次问
already_approved = true; // :228
}
}
// ── 2) First attempt under the selected sandbox. ───────── :232
let (first_result, _) = Self::run_attempt(...).await; // :306
// ── 3) 沙箱拒绝 → 升级重试 ────────────────────────────── :309
Err(Codex(Sandbox(Denied { output, .. }))) => {
// 三重门 :352-396:escalate_on_failure / wants_no_sandbox_approval / unsandboxed_allowed
// 任一不满足 → return Err,不放行
let retry_reason = build_denial_reason_from_output(output); // :404
let bypass_retry_approval = !strict_auto_review
&& tool.should_bypass_approval(policy, already_approved) // :410 ← 用第 1 段的标记
&& network_approval_context.is_none();
if !bypass_retry_approval {
session.request_approval(action, ctx).await?; // :436 ★第二次问
}
// 重试时沙箱换成 SandboxType::None(:446-456)→ 再跑一次
}
同一次工具调用最多问两次 ::226(执行前)+ :436(沙箱拒绝后要逃逸沙箱时)。 第二次的提示语写死在 build_denial_reason_from_output(:542-546):
rust
"command failed; retry without sandbox?"
两次审批用 run_id 区分:approvals.rs:517 给重试那次加 :retry 后缀(format!("{}:retry", ctx.call_id))。
三级提问者:Hooks → Guardian → User
rust
// approvals.rs:521-541 源码注释原文:
// Approval precedence is:
// 1. Hooks
// 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
let resolution = match run_permission_request_hooks(...).await {
Some(PermissionRequestDecision::Allow) => Approved (source: Hook),
Some(PermissionRequestDecision::Deny { message }) => denied (source: Hook),
None => self.request_reviewer_approval(action, &ctx).await, // :540 ↓
};
// :570 request_reviewer_approval
let reviewer = if ctx.strict_auto_review { ApprovalReviewer::Guardian }
else { ApprovalReviewer::for_policy(policy, approvals_reviewer) };
match reviewer {
ApprovalReviewer::Guardian => request_guardian_approval(...), // :592 不打扰人
ApprovalReviewer::User => request_user_approval(...), // :593 ★真的发事件等人回答
}
→ 「权限申请」这个词偏窄:真正去问人只是三级里的第三级。
缓存审批在你被问之前就生效了 (approvals.rs:733-756):
rust
let cache_keys = action.cache_keys().map(|k| (k, &policy_fingerprint)); // :733
with_cached_approval(&self.services, "unified_exec", cache_keys, || async {
self.request_command_approval(...).await // ← 闭包只在缓存未命中时才执行
}).await
policy_fingerprint 参与 key → 改了 exec policy,缓存自动失效。
决定的去向(approvals.rs:463-491 into_tool_result)
ReviewDecision |
变成 | 后果 |
|---|---|---|
Approved / ApprovedForSession / ApprovedExecpolicyAmendment |
Ok(decision) |
放行(原样上递,上层要用它写缓存/落策略) |
Denied { rejection } |
ToolError::Rejected |
→ RespondToModel,给模型换招 |
TimedOut |
ToolError::Rejected(超时文案) |
同上 |
Abort |
CodexErr::TurnAborted |
终止整轮 turn |
判据:能不能变成"给模型的一段话"。 能 → Rejected;不能(因为模型也得一起停)→ TurnAborted。 Abort 语义(protocol/src/protocol.rs:4040-4042):"User has denied this command and the agent should not do anything until the user's next command."
五条岔路(同一动作,五种"被拒绝")
以 rm -rf /var/log/prod/* 为例,前三条连弹窗都不会出现:
| # | 谁拒绝 | 弹窗 | 产出 | 去向 |
|---|---|---|---|---|
| 1 | 策略不许问(Forbidden) |
否 | Rejected("approval policy disallowed sandbox approval prompt") |
给模型 |
| 2 | Hook | 否 | ReviewDecision::denied(source Hook) |
给模型 |
| 3 | Guardian 自动审查 | 否 | Denied(文案 automatic approval review denied the action) |
给模型 |
| 4 | 用户点 No | 是 | Denied(rejected by user) |
给模型 |
| 5 | 用户点 Stop | 是 | Abort |
终止整轮 |
1-4 的去向完全相同,日志上只差文案 ------ 这就是单元 7 那个埋点坑的具体面目:看板上的"用户拒绝率"大部分不是用户点的。
Forbidden 的真实触发条件(sandboxing.rs:194-222)不是"这事不许做",而是**"本该问你,但配置说连问都不许问"**:
rust
let needs_approval = match policy {
Never => false,
OnRequest | Granular(_) => matches!(fs_policy.kind, FileSystemSandboxKind::Restricted),
UnlessTrusted => true,
};
if needs_approval && matches!(policy, Granular(cfg) if !cfg.allows_sandbox_approval()) {
Forbidden { reason: "approval policy disallowed sandbox approval prompt" } // :216
}
反例:同一个 Abort,来源不同去向不同
在网络审批 这条路径上,如果 Abort 来自 Guardian(不是你),会被降级 成一次普通失败(approvals.rs:559-563):
rust
(ReviewDecision::Abort, ApprovalResolutionSource::Guardian) =>
return Err(ToolError::Rejected("automatic approval review was cancelled")),
理由:你没叫停,不该停掉你的回合。→ 别背"Abort → 终止",要记「看是谁给的、以及是不是关于整个回合的意见」。
单元 9:审批怎么变成一次「等待」
统一套路:oneshot channel + 一张等待表
等待方(core/src/session/mod.rs:2536):
rust
pub async fn request_command_approval(...) -> ReviewDecision {
let (tx_approve, rx_approve) = oneshot::channel(); // :2557
turn_state.insert_pending_approval(approval_id, tx_approve); // :2563 先登记再发事件
self.send_event(turn_context, EventMsg::ExecApprovalRequest { .. }).await; // :2621
rx_approve.await.unwrap_or(ReviewDecision::Abort) // :2622 ★停在这一行
}
回答方(core/src/session/mod.rs:3100):
rust
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
let tx = turn_state.remove_pending_approval(approval_id); // :3106
match tx {
Some(tx_approve) => { tx_approve.send(decision).ok(); } // :3113 ★唤醒
None => warn!("No pending approval found for call_id: {approval_id}"),
}
}
TurnState 是一张「等待中的问题」总表(state/turn.rs:90-107)
rust
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
凡「turn 中途停下等外部回答」都是同一套 :建 oneshot → 发送端登记进 TurnState → 发事件 → await。审批只是这五分之一。
三个细节
- 先登记再发事件 (
:2556注释原文Add the tx_approve callback to the map before sending the request.)------ 防 UI 抢答时找不到回信地址。 unwrap_or(ReviewDecision::Abort)与clear_pending_waiters(state/turn.rs:131)配对 :turn 取消时清空等待表 → 所有tx被 drop →await拿到Err→ 统一落成Abort。→Abort有两个来源:① 用户主动 Cancel;② 通道被丢弃(turn 已取消)。ApprovalAction::RequestPermissions永远到不了用户 :approvals.rs:888-890写的是unreachable!("permission requests are routed directly to Guardian")。
apply_patch 的三条分支(approvals.rs:816-855):
rust
if *permissions_preapproved && reason.is_none() { return Approved; } // 预授权直通,不问也不查缓存
if reason.is_some() { return self.request_patch_approval(...).await; } // 重试走这条,故意绕过缓存
with_cached_approval(&self.services, "apply_patch", action.cache_keys(), || ...) // 常规路径
单元 10:一次审批弹窗 ------ 数据 / 表现 / 操作 / 流程
数据:ExecApprovalRequestEvent(protocol/src/approvals.rs:245-313)
rust
kind: ExecApprovalKind, // 命令审批 / stdin 写入
call_id: String, // 挂在哪个 function_call 上
approval_id: Option<String>, // 子命令/stdin 才有,否则等于 call_id
turn_id: String,
environment_id: Option<String>,
started_at_ms: i64, // ★ 弹窗时刻,用于统计用户等待时长
command: Vec<String>, // ★ argv 数组,非字符串,无 shell 解析
cwd: LegacyAppPathString,
reason: Option<String>, // ★ "为什么突然问我",沙箱重试文案由此进 UI
parsed_cmd: Vec<ParsedCommand>, // ★ 解析后的命令,UI 用它做语义化展示
available_decisions: Option<Vec<ReviewDecision>>, // ★ 服务端算好「该显示哪些按钮」
四个值得注意的点:
command是Vec<String>(argv 形式),不是"rm -rf /var/log/prod/*"这样的字符串,没有 shell 解析。reason是"为什么突然问我"的说明 ------ 沙箱重试那句command failed; retry without sandbox?从这里进 UI。available_decisions由服务端算好 (session/mod.rs:2585-2592),UI 不自己判断该显示哪些按钮,只负责渲染。parsed_cmd是解析结果(Read / ListFiles / Search 之类),UI 拿它做语义化展示而非纯字符串。
表现:tui/src/bottom_pane/approval_overlay.rs
三段式浮层(overlay,不是行内输出):
- 标题 (
:256-273):Would you like to run the following command?/Do you want to approve network access to "{host}"?/Would you like to make the following edits?/Would you like to grant these permissions?/{server} needs your approval. - 正文 (
build_header :693-733):命令全文、Reason: xxx(斜体)、stdin 场景的Input: ... - 选项列表 + 底部快捷键提示 (
:304-319)
所以看到的从来不是"是 / 否",而是一个带快捷键的纵向选项列表。
操作:UI 6 个选项 → core 5 个 ReviewDecision
映射函数 command_decision_to_review_decision(approval_overlay.rs:808-827):
| UI 文案 | UI 枚举 | core ReviewDecision |
后果 |
|---|---|---|---|
Yes, proceed / Yes, just this once |
Accept |
Approved |
放行这一次 |
Yes, and don't ask again for this command in this session |
AcceptForSession |
ApprovedForSession |
放行 + 本会话免问 |
Yes, and don't ask again for commands that start with \x`` |
AcceptWithExecpolicyAmendment |
ApprovedExecpolicyAmendment |
放行 + 持久规则 |
Yes, and allow this host in the future |
ApplyNetworkPolicyAmendment |
NetworkPolicyAmendment |
放行 + 网络规则 |
No, continue without running it |
Decline |
Denied |
拒绝,turn 继续 |
No, and tell Codex what to do differently |
Cancel |
Abort |
拒绝,turn 立即中断 |
Decline / Cancel 的语义写在枚举注释里(app-server-protocol/src/protocol/v2/item.rs:78-81):
arduino
/// User denied the command. The agent will continue the turn.
Decline,
/// User denied the command. The turn will also be immediately interrupted.
Cancel,
⚠️ 反直觉点 :No, and tell Codex what to do differently 读起来像「我拒绝但还想接着说」,实际是 Abort(立刻掐掉整个 turn)。想「拒绝但让模型换招」应选听起来更干脆的 No, continue without running it。
反向映射(core → 协议,用于回放/展示):app-server-protocol/src/protocol/v2/item.rs:84-107(其中 CoreReviewDecision::TimedOut => Self::Decline,超时在 UI 上呈现为"拒绝")。
流程(10 步)
orchestrator.rs:209判定NeedsApproval:226session.request_approval→ 按优先级选择 Hook、Guardian 或 Usersession/mod.rs:2536:建oneshot(:2557)→ 登记tx(:2563)→ 算available_decisions(:2585):2621send_event(EventMsg::ExecApprovalRequest)------ 事件离开 core- app-server 转发 → TUI 构造视图模型
ExecApprovalRequest(approval_overlay.rs:82-93) - overlay 渲染
- 用户按键 →
apply_selection(:324)→handle_exec_decision(:367) command_decision_to_review_decision(:808)翻回 core 枚举notify_approval(session/mod.rs:3100)→ 取出tx→send(decision)(:3113)- 卡在步骤 3 的
rx_approve.await(:2622)返回,工具执行流继续
步骤 4 → 9 之间整个工具执行链停住(模型也早已停产出)。
用户操作的本质:2 个方向 × 2 条范围轴
- 同意 方向的 4 个选项其实是同一个「批准」,区别只在记住多久 / 记到哪:这一次 → 本会话 → 本前缀(持久规则) → 本主机(网络规则)。
- 不同意 方向只有 2 个,区别只在要不要连带停掉回合 :
Denied(继续)vsAbort(中断)。
体感 vs 机制:用户体感是"审批 = 一个弹窗",但弹窗只是三级路由的最后一级;Hook 与 Guardian 都在弹窗之前就把事情办完了(见单元 8)。仅凭"有没有弹窗"判断用户参与度会严重高估。
单元 11:同一个命令,凭什么这次不弹窗问你
一句话定性 :execpolicy 不是安全检查,也不是权限数据库------它是一份用户可维护的「免问白名单」文本 + 一次查表动作 ,只回答一个问题:这条命令要不要弹窗问你。答案只有三种:不问直接跑 / 要问 / 直接拒。它不判断危险、不与模型交互,就是拿命令的第一个词去比对前缀字符串。
先修正两个常见误解
- 不是数据库:规则文件是纯文本,一行一条规则(Starlark 语法),启动时全读进内存;程序自己也会往里追加(弹窗勾「以后别再问」⇒ 追加一行并热更新内存策略,无需重启)。
- 主要记的是「不用问」 :文件里可写
allow/prompt/forbidden三种;「哪些要问」基本不在文件里,是代码按approval_policy+ 沙箱类型兜底现算的。
三层各管一件事(别混成一层)
execpolicy(这个文本文件)→ 要不要问你approval_policy(配置)→ 没写进表时默认问不问- 沙箱 → 允许跑,也只能在划定的范围内动(免问 ≠ 能随便干)
它站在哪:判定在「构造请求」时做完,orchestrator 只消费
单元 8 的三段式第 ① 段(tools/orchestrator.rs:144)不做判定,只读一个已经填好的字段:
rust
// core/src/tools/runtimes/apply_patch.rs:147-151(unified_exec.rs:190-194 同形)
fn exec_approval_requirement(&self, req) -> Option<ExecApprovalRequirement> {
Some(req.exec_approval_requirement.clone())
}
真实判定链(判定发生在构造请求阶段):
bash
process_manager.rs:1373 组装 ExecApprovalRequest{command, approval_policy,
permission_profile, sandbox_permissions, prefix_rule...}
→ executable_identity.rs:20 剥 shell 包装
→ exec_policy.rs:315 create_exec_approval_requirement_for_command
→ exec_policy.rs:324 ..._for_parsed_commands ★ 大脑
查表五步(换成自己机器走一遍:adb kill-server)
- 命令是字符串数组
["adb","kill-server"];外壳命令先拆段 (exec_policy.rs:835把bash -lc "a && b"拆成a、b)------不拆的话,任何命令套个bash -c就能冒充bash混过白名单。 - 用第一个词 当 key 取候选(
execpolicy/src/policy.rs:334,rules_by_program是 MultiMap)。 - 逐条前缀匹配 (
rule.rs:46-59):只比前 N 个 token,后面的参数不管 ⇒pattern=["adb","install"]就能管住adb install -r <任意包名>;单个位置可写备选[a|b]。 - 精确层空手 → 命令若是绝对路径,用 basename 反查 +
host_executables校验真实路径(policy.rs:344-371,防"别处放个同名假 adb");仍空 → 走启发式兜底(exec_policy.rs:735-819)。 - 多条命中取最严 (
policy.rs:402-411):Decision派生Ord且声明序为Allow < Prompt < Forbidden,故map(decision).max()= 最严者胜。
判定链图

核心伪代码
ini
func decide(command, approval_policy, sandbox) -> Skip | NeedsApproval | Forbidden:
matches = []
for seg in split_shell_wrapper(command): # bash -lc "a && b" → [a, b]
hit = exact_prefix_rules(seg) # key = seg[0],只比前缀
if hit.empty and seg[0] 是绝对路径:
hit = host_executable_rules(seg) # basename 反查 + 路径白名单校验
if hit.empty:
hit = [Heuristic(decision = heuristic_policy(seg, approval_policy, sandbox))]
matches += hit
d = max(matches.decision) # 取最严,不是"第一条赢"
if d == Forbidden: return Forbidden(reason)
if d == Prompt:
if approval_policy 不允许弹窗: # Never / Granular 关了对应开关
return Forbidden(reason) # ★ 不是"不问就跑",是"拒"
return NeedsApproval(reason, 提议持久化的 amendment)
return Skip(bypass_sandbox = 每段都被显式 allow 规则命中)
兜底策略 (表里一个字都没命中时,render_decision_for_unmatched_command,exec_policy.rs:735-819):
- 危险命令命中(或 Windows 关了沙箱后端但仍有托管 FS 限制)→
never拒 / 其他策略问 never→ 直接跑(安全交给沙箱);unless-trusted→ 一律问on-request/granular→ 沙箱「不受限」直接跑;「受限」则只有命令要求脱离沙箱时才问
设计点
- 取最严,不做「最后一条赢」:规则可以叠加而不会互相削弱;加规则只能更严,放宽必须先改/删原规则(对端侧的含义:白名单是单调收紧的,不会因为新增一条 allow 而绕过已有的 forbid)。
- 三层来源单向降级,且上层能分辨来源 :
is_policy_match(exec_policy.rs:203)把「真有.rules规则」与「代码启发式推断」分开,因为granular策略下两者走不同准入门 (:395的prompt_is_rule决定是查「规则审批」还是「沙箱审批」开关)。 - 白名单会自己长大 :一次审批里提议的 amendment 由
execpolicy/src/amend.rs:65-81拼成一行prefix_rule(pattern=[...], decision="allow")追加进规则文件(append_amendment_and_update,exec_policy.rs:447),且追加后内存策略热更新 (ArcSwap,exec_policy.rs:277),无需重启。 never的语义最容易抄反 :命中Prompt规则而approval_policy = never(不许弹窗)⇒ 结果是 Forbidden ,不是「不问就跑」(decision.rs:12注释、exec_policy.rs:221)。
发布说明
规则文件的具体内容属于运行环境配置,不在本文中列出。本文只说明源码中的判定算法:规则命中后与 approval_policy、沙箱类型共同决定 Allow、Prompt 或 Forbidden。
最值得带走的结论
-
两层 loop 同源单队列 :内层每圈开头抽干、外层收尾兜底。不是两个输入源抢优先级,而是同一条队列在四个不同时刻被检查;真正消费(
split_off(0))只发生在turn.rs:310和tasks/mod.rs:467两处,所以不会重复处理。 -
结束判定靠模型显式的
end_turn,不是靠"它不调工具了";没这个字段的 provider 才回落到"没调工具就算说完"。这解释了模型"先吐一句再继续"的行为空间。 -
工具与模型流重叠执行 :
tokio::spawn发生在"创建 future"那一行(因为handle_tool_call是普通 fn,body 立即执行),FuturesOrdered只负责保序收结果。并发由工具自身的supports_parallel+ 一把读写锁决定。 -
工具调用只有「框架事故」会终止整轮,业务失败一律喂回模型 :
Fatal只用于 payload 不匹配 / task panic;命令失败、沙箱超时、沙箱拒绝、用户拒绝审批全部走RespondToModel,变成FunctionCallOutput { success: Some(false) }写回历史,在下一圈采样请求才送达模型(单向 SSE 流没有回灌通道)。 -
审批的本质是「裁决 → 翻译 → 去向」三段,而不是"弹窗" :判定(工具自己)与提问(Hook → Guardian → User 三级)分离,一次调用可能问两次(执行前 + 沙箱拒绝后逃逸沙箱);出路只有两种 ------ 能变成给模型的一段话(
Rejected)就继续,是关于整个回合的否定(Abort)就终止整轮。绝大多数"用户拒绝"根本不是用户点的。 -
execpolicy是「免问白名单 + 兜底策略」,不是权限系统 :它只决定「要不要弹窗问用户」,答案来自一份可增量追加的纯文本前缀表(可写allow/prompt/forbidden);没命中才按approval_policy+ 沙箱类型兜底。多条命中取最严 ,且never遇到prompt规则是拒绝而非放行。真正限制能力的是沙箱。
与 Agent Team 端侧的对照点
| Codex 机制 | 端侧可借鉴处 |
|---|---|
steer_input 的守卫矩阵(NoActiveTurn / NotSteerable / EmptyInput) |
端侧"用户在流式中途输入"的分支判定可直接照搬这套显式原因枚举,避免用 boolean 猜 |
end_turn 显式结束信号 |
端侧与模型服务约定显式结束标记,比"没 tool_call 就算完"更稳,也更容易埋点区分"说完"与"说不完" |
| 工具 spawn 与流读取重叠 | 端侧若有"模型吐字 + 同时执行动作"的场景,值得评估提前启动的收益与"流失败白跑"的代价 |
supports_parallel + RwLock 单闸门 |
比在调用点散写互斥判断更集中,容易测试 |
单活动 turn + abort_all_tasks(Replaced) |
端侧会话若允许并发 turn,需要明确 Replaced 语义,否则会出现两个 turn 抢 UI |
ExecApprovalRequirement(判定与提问分离,Skip/NeedsApproval/Forbidden) |
端侧权限流程可照搬:工具只输出「要不要问」,由统一入口负责「怎么问 + 缓存审批」,避免每个工具各写一套弹窗逻辑 |
proposed_execpolicy_amendment |
一次授权同时提议持久策略("以后类似的都别再问"),比每次单独授权体验好 |
execpolicy 前缀白名单(文本文件 + 前缀匹配 + 多条命中取最严) |
端侧「免弹窗清单」不必写成代码常量或数据库:一份可增量追加的文本/配置 + 前缀匹配 + 取最严,就能覆盖高频命令,并随用户授权自我生长;取最严保证规则只收紧不放宽 |
Fatal vs RespondToModel 二分 |
端侧错误回传若能区分「业务失败(可让模型补救)」与「框架事故(终止)」,模型的自愈率会明显不同 |
PostToolUseFeedbackOutput 的「模型可见 / 日志真实」分离 |
端侧埋点若也有中间件改写,需明确「用户/模型看到的」与「上报的」是两个口径,否则对不上账 |
| 三级审批路由(Hook 优先,未裁决时选择 Guardian 或 User)+ 缓存审批 | 端侧「自动放行 / 自动审查 / 弹窗」不必只有两态,分层路由 + 按 policy 指纹失效的缓存是可照搬的结构 |
Denied vs Abort 的语义区分 |
端侧必须把「拒绝这个动作」和「中止整个会话」分成两个结果,否则用户点"停止"会被当成一次普通失败,模型还会接着干活 |
审批提示语集中在一个函数(build_denial_reason_from_output) |
面向用户的拒绝文案应收敛到单点,便于统一口径与测试,避免散落在各工具里 |
源码证据清单
codex-rs/core/src/session/turn_input.rs:551steer_input(守卫矩阵:562 :566 :573 :582 :587 :594)codex-rs/core/src/tasks/mod.rs:271spawn_task/:282start_task(Replaced语义:277,取消令牌:301)codex-rs/core/src/tasks/regular.rs:39-96(TurnStarted内联:49-63,外层 loop:76-95)codex-rs/core/src/session/turn.rs:156-603run_turn(准备:164-286,内层 loop:304-600,关键行:268 :310 :317 :373 :384 :411 :416 :426 :461 :499 :503 :562 :566 :602)codex-rs/core/src/session/turn.rs:1362run_sampling_request(重试 loop:1390-1461)codex-rs/core/src/session/turn.rs:2207try_run_sampling_request(流建立:2238,in_flight:2252,流循环:2281-2766,end_turn判定:2610,mailbox 抢占:2430,drain:2782)codex-rs/core/src/session/turn.rs:2159-2182drain_in_flightcodex-rs/core/src/stream_events_utils.rs:290handle_output_item_done(tool future:317-328,needs_follow_up=true:327 :383)codex-rs/core/src/tools/parallel.rs:74handle_tool_call/:95handle_tool_call_with_source(spawn:148,并发闸门:155-159)codex-rs/core/src/session/input_queue.rs:305split_off(0)抽干 /:348has_pending_inputcodex-rs/core/src/state/turn.rs:46-50MailboxDeliveryPhasecodex-rs/codex-api/src/common.rs:114-121ResponseEvent::Completed { end_turn }/codex-rs/codex-api/src/sse/responses.rs:124codex-rs/core/src/tools/router.rs:325dispatch_tool_call_with_terminal_outcome/:348inner(组装ToolInvocation:367)codex-rs/core/src/tools/registry.rs:493dispatch_any_with_terminal_outcome(五道关口:532 :564 :582 :793 :738)/into_response :196/PostToolUseFeedbackOutput :232/notify_tool_finish_if_unclaimed :774codex-rs/core/src/safety.rs:20SafetyCheck三态 /:26assess_patch_safety(UnlessTrusted疑点 TODO:44-45)codex-rs/core/src/apply_patch.rs:22prepare_apply_patch(三态去向:36-60,判定与提问分离注释:45-47)codex-rs/core/src/tools/sandboxing.rs:152ExecApprovalRequirement(角色注释:150)codex-rs/core/src/tools/orchestrator.rs:208NeedsApproval消费点(request_approval:224-227)codex-rs/core/src/exec_policy.rs:409命令类工具产出NeedsApprovalcodex-rs/core/src/tools/events.rs:379-460finish()(第 6 阶段翻译点;ToolError::Rejected语义混淆注释:435-440)codex-rs/core/src/tools/parallel.rs:83-89结果三分支翻译 /:215task join 失败 /:219failure_responsecodex-rs/tools/src/function_call_error.rs:5-10FunctionCallError两变体codex-rs/history/src/lib.rs:39ResponseItemEnvelopecodex-rs/core/src/tools/orchestrator.rs:125run(三段式:144/:232/:309;两次审批:226 :436;bypass 判定:410;拒绝文案:542-546;沙箱结果打点:530-540)codex-rs/core/src/tools/approvals.rs:495request_approval(优先级注释:521-523;hook 分支:524-541;网络 Abort 降级:559-563;request_reviewer_approval :570;request_guardian_approval :602;request_user_approval :696;缓存审批:733-756)/ApprovalContext :55/ApprovalAction :67/into_tool_result :463/ApprovalReviewer :434codex-rs/core/src/tools/sandboxing.rs:194-222default_exec_approval_requirement(Forbidden真实触发条件:216)codex-rs/protocol/src/protocol.rs:4008-4043ReviewDecision(Abort语义:4040)codex-rs/core/src/session/mod.rs:2536request_command_approval(oneshot:2557;登记:2563;available_decisions:2585-2592;发事件:2621;await兜底:2622;request_patch_approval :2629)codex-rs/core/src/session/mod.rs:3100notify_approval(取tx:3106;send:3113;迟到告警:3116)codex-rs/core/src/state/turn.rs:90-107TurnState五张等待表/:116 :124insert-remove/:131-135clear_pending_waiterscodex-rs/protocol/src/approvals.rs:245-313ExecApprovalRequestEvent全字段/:315-334effective_available_decisions/:336-default_available_decisionscodex-rs/tui/src/bottom_pane/approval_overlay.rs:74-122TUI 视图模型(ApprovalRequest四变体)/:250-322标题与选项组装/:367handle_exec_decision/:808-827command_decision_to_review_decision/:829-915exec_options文案/:693-733build_headercodex-rs/app-server-protocol/src/protocol/v2/item.rs:63-82CommandExecutionApprovalDecision(6 变体,Decline/Cancel语义注释:78-81)/:84-107From<CoreReviewDecision>(TimedOut => Decline:104)codex-rs/core/src/exec_policy.rs:315create_exec_approval_requirement_for_command/:324create_exec_approval_requirement_for_parsed_commands(决策映射:379-444;Allow⇒Skip.bypass_sandbox判据:426-437)/:203is_policy_match/:216-238prompt_is_rejected_by_policy(Never直拒:221)/:735-819render_decision_for_unmatched_command(危险命令分支:763-771)/:831default_policy_path/:835-872commands_for_exec_policy/:53 :55RULES_DIR_NAME/DEFAULT_POLICY_FILE/:1079-1122collect_policy_files/:276-279ExecPolicyManager(ArcSwap热更新)/:447append_amendment_and_updatecodex-rs/core/src/exec_policy/executable_identity.rs:20create_exec_approval_requirement_for_shellcodex-rs/core/src/unified_exec/process_manager.rs:1373真实调用点(组ExecApprovalRequest后取exec_approval_requirement)codex-rs/core/src/tools/runtimes/apply_patch.rs:147-151/unified_exec.rs:190-194exec_approval_requirement(只把请求里的字段原样交回 orchestrator)codex-rs/execpolicy/src/policy.rs:305-332三层递降/:334-341精确前缀层/:344-371宿主路径层/:402-411from_matches取最严/:45fingerprint(未展开)codex-rs/execpolicy/src/rule.rs:46-59matches_prefix(前 N token /Alts备选)/:64-108RuleMatch两变体 +with_resolved_programcodex-rs/execpolicy/src/decision.rs:7-16Decision三值 + 派生Ord(声明序即Allow < Prompt < Forbidden)codex-rs/execpolicy/src/parser.rs:57-79Starlark 求值入口(Dialect::Extended+enable_f_strings)/:86-131PolicyBuilder(rules_by_program/network_rules/host_executables_by_name)codex-rs/execpolicy/src/amend.rs:65-81blocking_append_allow_prefix_rule(拼prefix_rule(pattern=[...], decision="allow")写回文件)codex-rs/core/src/tools/sandboxing.rs:150-187ExecApprovalRequirement三变体/:194-230default_exec_approval_requirement/:250-257Skip.bypass_sandbox覆盖面注释
本章状态
本章的 Turn 主线和工具安全链已经完成。沙箱、上下文压缩、事件出口、模型网络、Skill/Agent、Loop、工具路由、Session/Thread/Memory 会在系列其他篇目中分别展开;唯一保留的未展开点是 Policy::fingerprint 以及未在 macOS 上运行验证的 Linux 沙箱实现,这两项不影响本章主流程。
源码基线:OpenAI Codex
d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。