ARTICLE · 1161354
Codex 源码-Apply Patch 补丁应用——先干跑,再决定放不放行
Codex 源码解析系列
第 18 讲:Apply Patch 补丁应用——先干跑,再决定放不放行
基于 OpenAI Codex 源码 · 2026-10-12
💡 本讲一句话:Codex 的 apply_patch 是一条"解析 → 干跑校验 → 安全裁决(自动放行/问用户/拒绝)→ 沙箱落盘"的四段流水线,外加一套容忍模型上下文行不精确的模糊匹配机制——即使部分写入失败,也会留下带 exact 标记的增量记录供回滚。读完你会明白:AI 改代码为什么可以"放心让它试错"。
一、为什么让模型"写补丁"而不是直接写文件?
第 17 讲我们看了 shell 命令怎么被卡在 execve 咽喉上。这一讲看另一种改代码的方式:模型不直接写文件,而是输出一段补丁文本——*** Begin Patch ... *** End Patch。为什么多此一举?因为"一次完整的、可校验的变更单元"比"一串散落的写操作"安全得多:补丁可以先干跑验证(文件在不在、行号对不对)、可以整体审批(一个弹窗覆盖所有目标文件)、失败时还能留下精确的增量记录用于回滚。
补丁格式由一组标记行定义,全部集中在 apply-patch/src/parser.rs(682 行)的头部:
📄 codex-rs/apply-patch/src/parser.rs (第 37-45、66-82 行)
pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; // 补丁起始标记:模型输出的第一行必须是它,否则直接判非法pub(crate) const END_PATCH_MARKER: &str = "*** End Patch"; // 补丁结束标记:最后一行必须是它(流式解析的 finish() 会强制检查)pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: "; // 新增文件指令前缀,冒号后跟相对路径pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: "; // 删除文件指令前缀pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: "; // 修改文件指令前缀(最常用)pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: "; // 重命名目标路径,出现在 UpdateFile hunk 内部pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ "; // chunk 定位上下文行:通常是一个函数/类定义,用来缩小搜索范围pub enum Hunk { // 一个补丁块 = 对单个文件的一种操作;整个补丁是 Hunk 的有序列表 AddFile { path: PathBuf, contents: String }, // 新增文件:路径 + 完整内容(覆盖已有文件也算 Add) DeleteFile { path: PathBuf }, // 删除文件:只需要路径,内容在验证阶段从磁盘读取 UpdateFile { path: PathBuf, move_path: Option<PathBuf>, chunks: Vec<UpdateFileChunk> }, // 修改/重命名:move_path 非空即 rename;chunks 必须按文件内先后顺序排列}为什么这样设计:注意 UpdateFile 的 chunks 注释——"chunks should be in order"。这不是文档洁癖:后面的模糊匹配算法(第八节)依赖 chunk 顺序单调推进搜索起点,乱序会导致定位错行。而 AddFile 携带完整内容而非 diff,是因为"新增文件没有旧版本可对齐"——用全量文本最不容易出错。
补丁有两个入口:模型直接调用 apply_patch 工具(ToolPayload::Custom),或者在 shell 里跑 apply_patch <<'EOF' ... EOF——后者会被 core/src/tools/handlers/apply_patch.rs(642 行)里的 intercept_apply_patch()(第 499-545 行)在真正执行 shell 之前截胡。两条路最终汇入同一个校验+安全裁决流水线,这是后面所有分析的主线。
二、宽容解析:一个被 GPT-4.1"坑"出来的兼容层
parse_patch() 的入口只有三行,但背后藏着一个真实事故:
📄 codex-rs/apply-patch/src/parser.rs (第 53、145-152 行)
const PARSE_IN_STRICT_MODE: bool = false; // 全局开关:当前默认走 Lenient(宽容)模式pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> { // 入口:把补丁文本解析成 ApplyPatchArgs(hunks + workdir + environment_id) let mode = if PARSE_IN_STRICT_MODE { ParseMode::Strict } else { ParseMode::Lenient }; // 按开关选模式;生产环境恒为 Lenient parse_patch_text(patch, mode) // 先校验 Begin/End 边界标记,再逐行解析 hunk}为什么要有 Lenient 模式:parser.rs 第 154-191 行的文档注释讲得很直白——GPT-4.1 经常把 shell 工具调用写成 ["apply_patch", "<<'EOF'\n*** Begin Patch\n...\nEOF\n"]。问题在于 Codex 的 local_shell 并不是真 shell:参数是走 execvpe(3) 直接执行的,heredoc 语法不会被解释,整段 <<'EOF'... 会作为字面字符串传给 apply_patch。Lenient 模式检测到输入以 <<'EOF' 开头、以 EOF 结尾时,就剥掉这层壳再解析。这是典型的"为模型的已知坏习惯打补丁"——不要求模型改行为,而是让系统兼容它。
更狠的是 shell 拦截路径:invocation.rs(1036 行)里的 extract_apply_patch_from_bash()(第 317 行起)用 Tree-sitter Bash 做严格查询,只认两种整脚本形态——apply_patch <<'EOF' ... EOF 和 cd <path> && apply_patch <<'EOF' ... EOF。前后夹带任何别的命令(echo ...;、管道)都不匹配,直接放行给真正的 shell。为什么这样设计:拦截器必须"宁可漏拦、不可错拦"——它运行在安全边界上,误把一条普通命令当补丁处理比不拦截危险得多。
三、流式解析:模型还在吐 token,TUI 已经在画 diff
补丁是模型逐 token 生成的。如果等生成完才开始解析,用户在 TUI 里就要干等几十秒看不到任何动静。apply-patch/src/streaming_parser.rs(924 行)的 StreamingPatchParser 是一个五态状态机,边收流边出结果:
📄 codex-rs/apply-patch/src/streaming_parser.rs (第 22-46、139-152 行)
pub struct StreamingPatchParser { // 流式解析器:delta 按任意边界切分,它负责拼回完整行 line_buffer: String, // 半行缓冲:一个 delta 经常在行中间截断,攒到换行为止才处理 state: StreamingParserState, // 当前模式 + 已完成的 hunks 列表 + environment_id(多环境路由用) line_number: usize, // 行号计数:报错时告诉模型"第 N 行错了",方便它自我修正}enum StreamingParserMode { // 五态状态机:每一行该被谁消费、什么算非法,全由当前模式决定 NotStarted, // 还没看到 *** Begin Patch——此时任何内容都直接判错(fail fast) StartedPatch, // 已开始,等待 hunk 头(Add/Delete/Update)、Environment ID 或 End Patch AddFile, // 正在收集新增文件的内容行;遇到下一个 hunk 头才切换 DeleteFile, // 删除文件:后面不应再有内容行,出现即报错 UpdateFile { hunk_line_number: usize }, // 正在解析 chunk(@@/+/− 行);记下起始行号用于精确报错 EndedPatch, // 已收尾:之后再来任何输入都是非法的}pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> { // 每收到一段流式增量调用一次 for ch in delta.chars() { // 逐字符扫描:delta 边界可能落在任意位置,不能假设按行切分 if ch == '\n' { // 遇到换行 → 一行完整了,可以处理 let mut line = std::mem::take(&mut self.line_buffer); // 取出半行缓冲并清空(零拷贝交换) line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len)); // 剥掉 CRLF 的 \r:兼容 Windows 风格换行的模型输出 self.line_number += 1; // 行号 +1,供后续报错定位 self.process_line(&line)?; // 按当前状态机模式消费这一行;出错立即上抛 } else { self.line_buffer.push(ch); // 非换行字符继续攒进半行缓冲 } } Ok(self.state.hunks.clone()) // 返回"目前为止已完成的 hunks"——注意是 clone:调用方拿去做流式事件,解析器自己保留状态}为什么这样设计:三个细节值得咂摸。其一,push_delta 返回的是已完成 hunk 的快照而非增量——调用方(事件层)不需要理解状态机内部,拿到就能渲染。其二,finish()(第 154-173 行)会强制检查最后一行必须是 *** End Patch,否则报 "The last line of the patch must be '*** End Patch'"——模型漏写结束标记是高频错误,这里 fail loud 而不是静默接受半截补丁。其三,状态机把"空 hunk""非法行"这类检查前置到解析期(ensure_update_hunk_is_not_empty(),第 53-82 行),错误信息精确到行号——模型看到 "line 7" 这种反馈比看到 "invalid patch" 更容易自我修正。
四、干跑校验:审批之前,先对着真实文件系统试一遍
解析成功 ≠ 补丁能应用。文件可能不存在、行号可能对不上、同一文件可能被两个 hunk 重复操作。invocation.rs 的 verify_apply_patch_args_with_mode()(第 201-295 行)在不写任何文件的前提下,把每个 hunk 对着真实文件系统"干跑"一遍:
📄 codex-rs/apply-patch/src/lib.rs (第 176-188 行) + invocation.rs (第 233-241 行)
pub enum MaybeApplyPatchVerified { // 校验结果四态:调用方必须逐一处理,不能笼统当"成功/失败"二分 Body(ApplyPatchAction), // 合法补丁:changes 已按绝对路径解析好、UpdateFile 已算出新内容,可直接进入安全裁决 ShellParseError(ExtractHeredocError), // shell 命令无法解析(heredoc 提取失败)——这不是补丁错误,shell 拦截路径应放行给真 shell CorrectnessError(ApplyPatchError), // 确实是 apply_patch 但内容不合法:找不到行、重复目标、文件不存在等 NotApplyPatch, // 明确不是 apply_patch 调用:直接放行,别误伤普通命令}// invocation.rs —— try_verify_apply_patch_args() 的核心循环(节选)for hunk in hunks { // 逐个 hunk 校验并转成"绝对路径 → 变更"的映射表 let path = hunk.resolve_path(&effective_cwd)?; // 相对路径拼上 cwd;非法路径直接报错,不进入后续流程 if changes.contains_key(&path) { // 同一文件被两个 hunk 操作(如先删后加)→ 拒绝:语义歧义,宁可让模型重写补丁 return Err(ParseError::InvalidPatchError(format!("multiple operations target {}", path.inferred_native_path_string())).into()); // 报错带上冲突路径:模型可据此把两个 hunk 合并成一个再重试 }为什么这样设计:干跑阶段对三种 hunk 的处理各不相同(invocation.rs 第 242-287 行):AddFile 直接记录内容;DeleteFile 会先把文件原文读出来存进 ApplyPatchFileChange::Delete.content——删错了还能恢复,这是"删除可回滚"的数据基础;UpdateFile 最重,它调用 unified_diff_from_chunks_with_mode()(file_update.rs 第 272-288 行)把 chunks 应用到原文、算出完整新内容和 unified diff——如果模型给的 old_lines 在文件里根本找不到,这里就会失败,错误信息会带上期望的行内容回传给模型。四态枚举的妙处在于:shell 拦截路径对 NotApplyPatch/ShellParseError 返回 Ok(None)("这不是我的事,放行给 shell"),而工具调用路径对同样的状态返回错误——同一个解析器服务两种入口,语义不混淆。
五、安全裁决:AutoApprove / AskUser / Reject 三向门
补丁合法之后,真正的问题是:该不该让用户点头?core/src/safety.rs(145 行)的 assess_patch_safety() 是这道三向门的裁决函数,全文如下:
📄 codex-rs/core/src/safety.rs (第 29-85 行)
pub fn assess_patch_safety( // 补丁安全裁决:输入"补丁+策略+沙箱状态",输出三选一 action: &ApplyPatchAction, // 已干跑校验的补丁动作(含全部目标绝对路径) policy: AskForApproval, // 用户配置的审批策略:Never/OnRequest/UnlessTrusted/Granular permission_profile: &PermissionProfile, // 权限档案:Managed / Disabled / External file_system_sandbox_policy: &FileSystemSandboxPolicy, // 文件系统沙箱策略:可写根集合、全盘写等 context: &FileSystemSandboxPolicyContext<'_>, // 策略上下文(cwd、workspace roots),路径判定依赖它 sandbox_route: PatchSandboxRoute, // 沙箱路由:Platform(本地平台沙箱) or ExecutorManaged(executor 托管)) -> SafetyCheck { // 三选一裁决结果:放行 / 问用户 / 拒绝,调用方必须穷尽处理 if action.is_empty() { // 空补丁直接拒绝——不给模型"试探边界"的机会,也不浪费一次审批弹窗 return SafetyCheck::Reject { reason: "empty patch".to_string() }; // 原因字符串直接回传模型——"空补丁"是它可自我修正的明确信号 } match policy { // 按用户审批策略分流:先排除"必须问人"的模式,再争取自动放行 AskForApproval::Never | AskForApproval::OnRequest | AskForApproval::Granular(_) => {} // 这三种策略不直接问人,继续走"可写路径"检查争取自动放行 AskForApproval::UnlessTrusted => return SafetyCheck::AskUser, // "除非可信"模式:一律先问用户(源码 TODO 注明作者对顺序也有保留) } let rejects_sandbox_approval = matches!(policy, AskForApproval::Never) // Never:连沙箱审批弹窗都不允许 || matches!(policy, AskForApproval::Granular(granular_config) if !granular_config.sandbox_approval); // Granular 且显式关掉 sandbox_approval:同上 let sandbox_available = match sandbox_route { // 当前环境到底有没有可用的兜底沙箱? PatchSandboxRoute::ExecutorManaged => true, // executor 托管(远程执行)默认视为有沙箱能力 PatchSandboxRoute::Platform(windows_sandbox_level) => get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled).is_some(), // 本地:按平台探测(bwrap/Landlock/Seatbelt/Windows),Disabled 级别直接算没有 }; if is_write_patch_constrained_to_writable_paths(action, file_system_sandbox_policy, context) // 所有目标路径都在可写根内?(含 rename 的源+目的两个路径) && (matches!(permission_profile, PermissionProfile::Disabled | PermissionProfile::External { .. }) || sandbox_available) // 且:本就无外层沙箱(Disabled/External),或平台沙箱可用兜底 { SafetyCheck::AutoApprove // 两个条件都满足 → 自动放行,不打扰用户 } else if rejects_sandbox_approval { // 越界 + 用户禁止审批弹窗 → 直接拒绝(不浪费一次注定被拒的询问) SafetyCheck::Reject { reason: patch_rejection_reason(permission_profile, file_system_sandbox_policy, context).to_string() } // 越界 + 用户禁止审批 → 直接拒绝,原因区分"只读沙箱拦截"和"项目外写入" } else { SafetyCheck::AskUser // 其余情况:弹审批让用户决定(含缓存的"始终允许") }}为什么这样设计:核心思想是"自动放行必须同时满足两个独立条件":路径在可写根内(意图合规)且有沙箱兜底(执行受控)。源码第 66-69 行的注释点破了第二个条件的必要性——即使补丁看起来只碰可写目录,目标文件也可能是指向项目外文件的硬链接,没有沙箱就拦不住这种"合法路径、非法落点"。反过来,Disabled/External 权限档案(用户明确要全权)即使没有沙箱也放行——尊重用户的显式选择。is_write_patch_constrained_to_writable_paths()(第 106-140 行)对 UpdateFile 会同时检查源路径和 move_path:rename 的目的地越界同样算越界。
| AutoApprove | ||
| AskUser | ||
| Reject | ||
| Reject | ||
| CorrectnessError |
为什么这样设计:这张表把"安全裁决"和"正确性校验"分成了两层——CorrectnessError(补丁本身有问题)永远先于 SafetyCheck(该不该问人)。顺序不能反:一个行号全错的越界补丁,用户看到的应该是"你的 old_lines 在文件里不存在"而不是"是否允许写 /etc/xxx"——先修对、再谈权限,模型的反馈回路才最短。
六、从裁决到审批流:ExecApprovalRequirement 的翻译
core/src/apply_patch.rs(97 行)的 prepare_apply_patch() 把三向裁决翻译成 orchestrator 能执行的审批指令:
📄 codex-rs/core/src/apply_patch.rs (第 32-64 行)
match assess_patch_safety(&action, step_context.settings.approval_policy(), turn_environment.permission_profile(), file_system_sandbox_policy, context, sandbox_route) { // 调用第五节的三向裁决 SafetyCheck::AutoApprove => Ok(ApplyPatchRuntimeInvocation { action, auto_approved: true, exec_approval_requirement: ExecApprovalRequirement::Skip { bypass_sandbox: false, proposed_execpolicy_amendment: None } }), // 自动放行:跳过审批弹窗,但 bypass_sandbox=false——沙箱照常生效,"免审批"不等于"免管控" SafetyCheck::AskUser => Ok(ApplyPatchRuntimeInvocation { action, auto_approved: false, exec_approval_requirement: ExecApprovalRequirement::NeedsApproval { reason: None, proposed_execpolicy_amendment: None } }), // 需要用户点头:审批流交给 orchestrator,与 shell/unified_exec 的审批机制完全同构(含"始终允许"缓存) SafetyCheck::Reject { reason } => Err(FunctionCallError::RespondToModel(format!("patch rejected: {reason}"))), // 直接拒绝:错误信息回传模型——注意是 RespondToModel,让模型读到原因后自行调整策略}为什么这样设计:注释里那句"consistent with how shell/unified_exec approvals are orchestrator-driven"是关键——apply_patch 没有自己发明审批 UI,而是复用第 20 讲要细讲的 ToolOrchestrator 审批管线。好处是用户侧体验统一(同一个弹窗、同一套"始终允许此路径"缓存),安全侧策略统一(runtimes/apply_patch.rs 第 147-152 行的 exec_approval_requirement() override 确保 orchestrator 走补丁专属审批而不是全局 exec 策略)。而 RespondToModel 这个错误类型名本身就说明了设计意图:拒绝不是终点,是给模型的反馈信号。
七、沙箱落盘:delta 追踪与"部分失败"的诚实记账
审批通过(或自动放行)后,apply-patch/src/lib.rs(1444 行)的 apply_hunks_to_files()(第 470 行起)开始真正写盘。这里有一个容易被忽略的设计:失败时也要记账——
📄 codex-rs/apply-patch/src/lib.rs (第 489-535 行,节选)
// 一次失败的写入可能已经改动了目标(比如先 truncate 再 ENOSPC),// 所以失败后累计的 delta 不再"精确"——用 exact 标志诚实标记这件事macro_rules! try_write { // 自定义宏:统一处理"写文件"的错误路径 ($result:expr) => { match $result { Ok(value) => value, Err(error) => { delta.exact = false; return Err(anyhow::Error::from(error)); } } }; // 成功透传;失败先把 exact 置 false(delta 可能不完整),再上抛错误}Hunk::AddFile { contents, .. } => { // 新增文件分支:先读旧内容,再写新内容 let overwritten_content = read_optional_file_text_for_delta(&path_uri, fs, follow_symlinks, sandbox, &mut delta.exact).await; // 若目标已存在(覆盖场景),读出被覆盖的原文存进 delta——这是"覆盖可回滚"的数据基础 try_write!(write_file_with_missing_parent_retry(fs, &path_uri, contents.clone().into_bytes(), follow_symlinks, sandbox).await); // 写文件;父目录不存在时自动补建后重试(见下块) delta.changes.push(AppliedPatchChange { path: path_uri, change: AppliedPatchFileChange::Add { content: contents.clone(), overwritten_content } }); // 记入已提交增量:路径 + 新内容 + 被覆盖的旧内容,三者齐备才能完整回滚 added.push(affected_path); // 汇总"新增"清单,最后打印 git 风格摘要(A/M/D)}为什么这样设计:AppliedPatchDelta(第 247-250 行)带一个 exact: bool 标志,语义是"这份增量记录是否完整可信"。任何一步 IO 失败(哪怕文件其实写了一半),try_write! 都会先把 exact 置 false。下游(rollout 录制、回滚逻辑)据此区分"完整变更,可安全回放/撤销"和"部分变更,只能人工检查"——宁可少用自动化,不可基于脏数据自动操作。删除分支同理:先 ensure_not_directory()(第 728 行)防止误删目录,再 remove(recursive:false, force:false)——两个 false 是刻意的:apply_patch 永远不做递归强删。
📄 codex-rs/apply-patch/src/lib.rs (第 801-858 行,节选)
async fn write_file_with_missing_parent_retry( // 写文件 + 父目录缺失自动重试:模型经常给"新模块/新测试"这类不存在的子路径 fs: &dyn ExecutorFileSystem, path: &PathUri, contents: Vec<u8>, follow_symlinks: bool, sandbox: Option<&FileSystemSandboxContext>, // 全部走 executor 文件系统抽象:本地/远程同一套代码;sandbox 上下文决定这次写是否受沙箱约束) -> anyhow::Result<()> { // 返回 anyhow:错误可携带路径上下文,最终被 try_write! 捕获并标记 exact=false match fs.write_file(path, contents.clone(), WriteFileOptions { follow_symlinks }, sandbox).await { // 先直接写一次(快路径) Ok(()) => Ok(()), // 成功就完事,不做任何多余操作 Err(err) if err.kind() == io::ErrorKind::NotFound => { // 仅当 ENOENT(父目录不存在)才进入重试分支——权限错误等不在此列 if let Some(parent) = path.parent() { fs.create_directory(&parent, CreateDirectoryOptions { recursive: true, follow_symlinks }, sandbox).await.with_context(|| format!("Failed to create parent directories for {}", path.inferred_native_path_string()))?; } // 递归创建缺失的祖先目录(等价 mkdir -p),同样受沙箱约束 fs.write_file(path, contents, WriteFileOptions { follow_symlinks }, sandbox).await.with_context(|| format!("Failed to write file {}", path.inferred_native_path_string()))?; // 第二次写:这次父目录已就位,失败则带上下文上抛 Ok(()) // 重试成功 → "建目录 + 写入"整体视为一次原子操作返回 } Err(err) => Err(err).with_context(|| format!("Failed to write file {}", path.inferred_native_path_string())), // 其他错误原样上抛(附路径上下文),交给沙箱/审批链路处理 }}为什么这样设计:只对 ErrorKind::NotFound 重试,其他错误一律上抛——这是"最小惊讶"原则:权限被拒不该触发 mkdir(那只会制造更多越界目录),磁盘满也不该。而 create_directory 本身也带着 sandbox 参数,意味着"自动建目录"同样受可写根约束——沙箱不是只包住了 write,而是包住整条路径。
八、UpdateFile 的模糊匹配:容忍模型"记错行号"
UpdateFile 是最难的 hunk:模型给的 old_lines 必须能在真实文件里找到。但模型的上下文经常不精确——缩进差一个空格、行尾多了个空行、甚至把破折号打成了全角。apply-patch/src/file_update.rs(335 行)+ seek_sequence.rs(193 行)就是为这个设计的:
📄 codex-rs/apply-patch/src/file_update.rs (第 96-131、225-246 行,节选)
for chunk in chunks { // 按顺序处理每个 chunk:定位 → 记录替换区间(依赖 chunk 顺序单调推进) if let Some(ctx_line) = &chunk.change_context { // 有 @@ 上下文行时,先用它把搜索起点推进到正确位置——避免同名代码块匹配错地方 if let Some(idx) = seek_sequence::seek_sequence(original_lines, std::slice::from_ref(ctx_line), line_index, /*eof*/ false, update_file_mode) { line_index = idx + 1; } else { return Err(ApplyPatchError::ComputeReplacements(format!("Failed to find context '{ctx_line}' in {path}"))); } // 上下文找不到 → 整个补丁失败(fail loud,错误带路径和上下文行) } if chunk.old_lines.is_empty() { // 纯插入 chunk(old_lines 为空):定位到文件末尾附近插入 let insertion_idx = match update_file_mode { NormalizeToLf => { ... } PreserveLineEndings => original_lines.len() }; // 两种换行模式对"末尾空行"的语义不同,分别处理 replacements.push((insertion_idx, 0, chunk.new_lines.clone())); // (起点, 旧长度=0, 新行) —— 零长度替换即插入 continue; // 纯插入无需后续匹配逻辑,直接处理下一个 chunk } let mut pattern: &[String] = &chunk.old_lines; // 先按原样匹配 old_lines let mut found = seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file, update_file_mode); // 带 eof 标志的模糊搜索(见下块) if found.is_none() && pattern.last().is_some_and(String::is_empty) { // 没找到、且模式以空行结尾 → 去掉这个"终止换行哨兵"再试一次 pattern = &pattern[..pattern.len() - 1]; // old_lines 末尾的空串代表区域结束换行,SourceFile 里并不存在这一行 if new_slice.last().is_some_and(String::is_empty) { new_slice = &new_slice[..new_slice.len() - 1]; } // new_lines 同理:保持两侧对称,避免插入多余空行 found = seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file, update_file_mode); // 第二次搜索:这是"文件末尾修改"场景的关键容错 } if let Some(start_idx) = found { replacements.push((start_idx, pattern.len(), new_slice.to_vec())); line_index = start_idx + pattern.len(); } else { return Err(ApplyPatchError::ComputeReplacements(format!("Failed to find expected lines in {}:\n{}", path, chunk.old_lines.join("\n")))); } // 找到 → 记录替换区间并推进搜索起点;找不到 → 报错时把期望的行内容原样带回给模型}fn apply_replacements(mut lines: Vec<String>, replacements: &[Replacement]) -> Vec<String> { // 把所有 (起点, 旧长度, 新行) 应用到原文,产出新文件内容 for (start_idx, old_len, new_segment) in replacements.iter().rev() { // 倒序应用:先改文件后面的区间,前面的下标才不会因插入/删除而错位 for _ in 0..old_len { if start_idx < lines.len() { lines.remove(start_idx); } } // 逐行删掉旧内容(越界保护:start_idx 超出长度时跳过) for (offset, new_line) in new_segment.iter().enumerate() { lines.insert(start_idx + offset, new_line.clone()); } // 在原位置按序插入新内容 } lines}为什么这样设计:"倒序应用替换"是经典技巧——正序应用时,第一个插入会让后面所有区间的下标全部漂移;倒序则每个区间操作的都是"尚未被改动过的后缀",下标天然稳定。而 line_index 的单调推进(start_idx + pattern.len())配合 chunk 顺序约束,保证同一个文件里的多个 chunk 不会互相"抢"到同一段代码。
📄 codex-rs/apply-patch/src/seek_sequence.rs (第 12-48 行,节选)
pub(crate) fn seek_sequence( // 在 lines 里从 start 起找 pattern:四级宽松度逐级降级,返回命中下标 lines: &[String], pattern: &[String], start: usize, eof: bool, update_file_mode: crate::ApplyPatchFileUpdateMode, // eof=true 表示该 chunk 声明"必须在文件末尾"(*** End of File)) -> Option<usize> { // None = 四级宽松度全部落空:调用方必须报错,绝不能猜位置 if pattern.is_empty() { return Some(start); } // 空模式 = no-op,直接命中(兼容纯插入 chunk 的边界情况) if pattern.len() > lines.len() { return None; } // 模式比文件还长 → 不可能匹配;提前返回避免搜索循环越界 panic(源码注明这是 2025-04 前的真实 bug) let search_start = if eof && lines.len() >= pattern.len() { /* 从文件尾部开始找 */ } else { start }; // is_end_of_file=true 时优先锚定 EOF:模型说"改文件末尾"就该在末尾找,而不是全文乱撞 for i in search_start..=lines.len().saturating_sub(pattern.len()) { // 第一遍:逐字符精确匹配(最快路径) if lines[i..i + pattern.len()] == *pattern { return Some(i); } // 切片相等即命中,返回下标(最快路径) } // 第二遍:rstrip 后比较——容忍行尾空白差异(模型最常见的"不精确") // 第三遍:两侧 trim 后比较——容忍缩进/前导空格差异 // 第四遍:Unicode 标点归一化(全角破折号→ASCII、弯引号→直引号等),对齐 git apply 的模糊行为为什么这样设计:四级降级是"精确优先、宽松兜底"的梯度:绝大多数情况第一遍就命中(零额外成本),只有模型输出不干净时才逐级放宽。第四遍 Unicode 归一化尤其值得注意——中文代码库里全角标点很常见,模型用 ASCII 打补丁时如果不做这层容错,"看起来一样"的行就会匹配失败。但宽松有边界:它只影响"定位",不影响"替换内容"——命中的旧行按原样删除、新行按模型给的原文插入,不会把用户的缩进风格改掉。另外 ApplyPatchFileUpdateMode(lib.rs 第 64-72 行)提供两种换行策略:NormalizeToLf(默认,统一 LF)和 PreserveLineEndings(feature flag ApplyPatchPreserveLineEndings,handlers/apply_patch.rs 第 63-73 行)——后者用 SourceFile(text_file.rs)逐行保留原始换行符,CRLF 文件不会被"顺手改格式"。
| AddFile | ||
| DeleteFile | ||
| UpdateFile | ||
| 共同约束 |
为什么这样设计:这张表浓缩了第七、八节的细节。注意 rename(lib.rs 第 596-670 行)的实现是"先写目的地 → 再删源文件"两步,而不是 rename(2)——因为跨文件系统 rename 会失败(EXDEV),而"写+删"永远可行;代价是中间态存在两份文件,delta 里用 Update { move_path, old_content, new_content } 完整记录了两端内容兜底。
九、沙箱拒绝嗅探:区分"写失败"和"被拦了"
执行层 core/src/tools/runtimes/apply_patch.rs(242 行)的 ApplyPatchRuntime::run() 是 orchestrator 每次"尝试"的实际执行体。它有两个关键判断:
📄 codex-rs/core/src/tools/runtimes/apply_patch.rs (第 179-232 行,节选)
let result = codex_apply_patch::apply_patch_with_options(&req.action.patch, ApplyPatchOptions { update_file_mode: req.action.update_file_mode(), // 沿用验证阶段选定的换行模式,保证"校验过的内容"和"落盘的内容"一致 follow_symlinks: attempt.sandbox_requested || !attempt.manager.should_sandbox(attempt.permissions, self.sandbox_preference(), attempt.enforce_managed_network), // 只有当"本应沙箱化却被绕过"时才拒绝跟随符号链接——防止模型用 symlink 把写入引到可写根之外;executor 托管沙箱可能合法地 SandboxType::None,不在此列}, &req.action.cwd, &mut stdout, &mut stderr, fs.as_ref(), sandbox.as_ref()).await; // 真正落盘:stdout/stderr 收进内存 buffer(不是真终端),sandbox 上下文由本次 attempt 决定let exit_code = if failed { 1 } else { 0 }; // 统一成"命令退出码"语义——TUI/事件流无需区分工具类型,apply_patch 和 shell 共用一套展示逻辑self.committed_delta.append(delta); // 把本次增量并入累计 delta:同一 turn 多次调用、多文件变更都能完整追踪let sandbox_denied = failed && if attempt.sandbox == SandboxType::None { attempt.sandbox_requested && is_likely_executor_managed_sandbox_denied(&output) } else { is_likely_sandbox_denied(attempt.sandbox, &output) }; // 从输出文本嗅探"沙箱拒绝"特征(如 bwrap/landlock 的典型报错)——区分"文件写失败"和"被沙箱拦了",两者后续处理完全不同if sandbox_denied { if attempt.sandbox != SandboxType::None { record_filesystem_sandbox_violation(attempt.sandbox, &output); } // 记违规遥测:哪个沙箱引擎、什么输出触发的拒绝,供安全分析 return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), network_policy_decision: None }))); // 以 Sandbox(Denied) 上抛 → orchestrator 据此决定是否提权重试(escalate_on_failure=true,见第 120-122 行)}为什么这样设计:"沙箱拒绝"和"普通写失败"必须分开——前者意味着策略层面不允许这次写入,orchestrator 可以走审批提权(用户点头后绕过沙箱重试);后者只是 IO 问题,重试一百次也没用。follow_symlinks 那行是整段代码里最"安全敏感"的:符号链接是绕过可写根的经典手法(/project/link → /etc/passwd),所以只要本次尝试本应沙箱化,就强制不跟随链接;而 executor 托管环境可能合法地没有进程级沙箱(SandboxType::None),此时不能一刀切拒绝,否则远程执行全挂——条件里那个 || !should_sandbox(...) 就是为这个例外留的口子。配合第 116-123 行的 Sandboxable impl(sandbox_preference=Auto、escalate_on_failure=true),apply_patch 获得了和 shell 完全同构的"沙箱内失败 → 审批提权 → 沙箱外重试"能力。
十、流式事件与 Hooks:补丁还没写完,UI 已经在预览
回到第三节的 push_delta——它的消费方是 handlers/apply_patch.rs(第 89-157 行)的 ApplyPatchArgumentDiffConsumer,负责把"解析中的 hunks"变成 TUI 可见的 PatchApplyUpdated 事件。核心是 500ms 节流:
📄 codex-rs/core/src/tools/handlers/apply_patch.rs (第 61、121-156 行)
const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); // 节流窗口:两次进度事件至少间隔 500ms,防止 token 流把事件通道打爆fn push_delta(&mut self, call_id: String, delta: &str) -> Option<PatchApplyUpdatedEvent> { // 模型每吐一段 token 就调用一次;返回 Some(event) 表示该发事件 let hunks = self.parser.push_delta(delta).ok()?; // 流式解析推进;出现非法行则放弃本次事件(最终 finish() 仍会报错,这里不提前炸) if hunks.is_empty() { return None; } // 还没有完整 hunk → 不发事件:空 diff 只会让 UI 闪烁,没有信息量 let event = PatchApplyUpdatedEvent { call_id, changes: convert_apply_patch_hunks_to_protocol(&hunks) }; // 转成协议层 FileChange(Add/Delete/Update),TUI 据此实时渲染"正在改哪些文件" match self.last_sent_at { // 500ms 节流判定 Some(last_sent_at) if now.duration_since(last_sent_at) < APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL => { self.pending = Some(event); None } // 距上次发送不足 500ms → 压进 pending,本次不发(用最新事件覆盖旧的) Some(_) | None => { self.pending = None; self.last_sent_at = Some(now); Some(event) } // 窗口已过 → 正常发出并刷新时间戳 }}fn finish_update_on_complete(&mut self) -> Result<Option<PatchApplyUpdatedEvent>, FunctionCallError> { // 流结束时的收尾:补发被节流压住的事件,保证 UI 最终看到完整状态 self.parser.finish().map_err(|err| FunctionCallError::RespondToModel(format!("failed to parse apply_patch: {err}")))?; // finish() 强制检查 *** End Patch;缺了就把解析错误回传模型 let event = self.pending.take(); if event.is_some() { self.last_sent_at = Some(Instant::now()); } Ok(event) // pending 里压着的事件在这里补发一次,然后清空}为什么这样设计:"pending 覆盖 + finish 补发"是典型的合并式节流:窗口内只保留最新状态(旧事件没有增量价值),结束时保证最终一致。对比 shell 的逐行 stdout 流,补丁进度天然是"全量快照"语义——第 N 个事件的 changes 已经包含前 N-1 个的全部内容,所以丢中间帧毫无损失。另外整条链路受 feature flag ApplyPatchStreamingEvents 控制(第 102-108 行):关掉后 consume_diff() 直接返回 None,解析器照常工作——流式事件是纯增量体验,不是功能依赖。
Hooks 集成同样走 orchestrator 标准接口(第 458-495 行):pre_tool_use_payload() 把原始补丁文本包装成 {"command": ...} 喂给 PreToolUse hook;with_updated_hook_input()(第 465-478 行)允许 hook 改写补丁内容后再执行——比如企业策略 hook 把目标路径重定向到影子目录。PostToolUse hook 则拿到执行结果做审计。为什么这样设计:apply_patch 的输入是纯文本,天然适合"管道式拦截修改";而 shell 命令改写风险高(语义复杂),所以补丁成了 hooks 体系里最安全的干预点。
十一、全链路数据流:从 token 到磁盘
apply_patch 全链路(模型输出 → 磁盘落盘)
① 产出:模型输出补丁文本
直接工具调用(ToolPayload::Custom)或 shell heredoc——后者被 intercept_apply_patch 在 execve 前截胡。
▼
② 流式解析:StreamingPatchParser 状态机
五态逐行消费,边生成边产出 hunk;500ms 节流发 PatchApplyUpdated 事件,TUI 实时预览。
▼
③ 干跑校验:try_verify_apply_patch_args
路径解析 + 重复目标拒绝 + UpdateFile 模糊定位算新内容;失败回传 CorrectnessError(带期望行)。
▼
④ 安全裁决:assess_patch_safety
可写根内 + 沙箱可用 → AutoApprove;越界且允许审批 → AskUser(orchestrator 弹窗);否则 Reject 回传模型。
▼
⑤ 沙箱执行:ApplyPatchRuntime::run
FileSystemSandboxContext 约束每次读写;follow_symlinks 防链接逃逸;失败嗅探沙箱拒绝 → 审批提权重试。
▼
⑥ 结果回传:exit_code + AppliedPatchDelta
git 风格 A/M/D 摘要给模型;delta(含 exact 标志、被覆盖/删除的原文)进 rollout,支撑回滚与审计。
十二、小结:把"改代码"变成一条可审计的流水线
这一讲拆的是 Codex 最核心的写盘工具。它的哲学可以概括为三句话:先验证再审批,先审批再落盘——干跑校验把"模型幻觉的行号"挡在用户弹窗之前;自动放行必须双条件成立——路径合规只是意图层,沙箱兜底才是执行层,硬链接这种"合法路径、非法落点"只有后者能拦;失败也要诚实记账——exact 标志让下游永远知道 delta 可不可信。再加上四级模糊匹配和符号链接防护,apply_patch 把"AI 改代码"从一次赌博变成了一条每一环都可审计的流水线。
下一讲(第 19 讲)我们看 Tool Approvals:当 AskUser 真的触发时,审批弹窗背后的策略引擎、"始终允许"缓存和网络策略是怎么运作的。
📚 系列导航
← 第 17 讲:Shell 命令执行与进程管理——拦截每一次 execve
→ 第 19 讲:Tool Approvals 审批流 + 网络策略
关注公众号「AI技术推荐官」获取更多源码解析内容