# apply_patch · Codex 的文件编辑格式

> Codex 没有直接写工作区文件、替换字符串的工具，改文件统一走 apply_patch：一种以 *** Begin Patch 开头、按文件分段、不带行号的精简 diff。解析器永远以宽松模式运行，seek_sequence 分四级放宽匹配，写进 shell 命令的补丁也会被截下来按补丁处理；补丁先校验、再审批，最后在执行环境的文件系统里逐个 hunk 落盘，不保证原子性。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/apply-patch
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# apply_patch · Codex 的文件编辑格式

Codex 的内置工具里找不到 `write_file`、`edit_file` 这类直接改工作区文件的工具。模型想改文件，要么在 shell 里自己动手，要么调用 `apply_patch`，后者是正路。它的实现分两处：格式的解析与应用在独立的 `codex-apply-patch` crate，审批、沙箱与事件在 `codex-core` 的 `tools/handlers/apply_patch.rs`。

## 用户看到的样子

模型改文件时，界面上出现一个文件变更条目，带着彩色 diff；补丁写到可写目录之外、或者审批策略是 `untrusted` 时，会先弹出审批，同样附带 diff（见[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)）。hook 里按 `apply_patch` 匹配它，也可以写成 `Edit` 或 `Write`（见 [Hooks](https://daiw.net/manual/codex/hooks)）。这个工具出不出现由模型目录决定：`ModelInfo.apply_patch_tool_type` 有值且当前有执行环境时才注册，而这个类型在 0.158.0 里只有 `Freeform` 一种取值。

## 补丁长什么样

下面这段补丁取自 `parser.rs` 的单元测试，一次做了三件事：新建文件、删除文件、修改并改名另一个文件。

```text
*** Begin Patch
*** Add File: path/add.py
+abc
+def
*** Delete File: path/delete.py
*** Update File: path/update.py
*** Move to: path/update2.py
@@ def f():
-    pass
+    return 123
*** End Patch
```

规则很少：整段夹在 `*** Begin Patch` 与 `*** End Patch` 之间；新建文件的每一行都以 `+` 开头；修改文件由若干块组成，每块以 `@@` 或 `@@ <一行上下文>` 开头（第一块可以省略），那一行上下文（通常是类名或函数签名）用来缩小查找范围，块内每行以空格（上下文）、`-`（删除）或 `+`（新增）开头；块末可以加 `*** End of File`，表示这些旧行必须落在文件结尾。和 unified diff 最大的不同是没有行号，定位全靠上下文。它是 Responses API 的 `custom` 类型工具，格式用一份 Lark 语法描述、随请求发给模型，工具描述还特意叮嘱不要再包一层 JSON：

```text
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?

hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?

filename: /(.+)/
add_line: "+" /(.*)/ LF -> line

change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
```

（`codex-rs/core/assets/tools/apply_patch.lark:1`）

会话里有多个执行环境时，`create_apply_patch_freeform_tool` 会往语法里插一条可选的 `*** Environment ID: <id>` 行，放在 `*** Begin Patch` 之后，用来指明补丁作用于哪个环境。文件路径按所选环境的工作目录解析。

<Callout type="info">
  `parser.rs` 文件头也抄了一份语法，但与模型实际拿到的 `apply_patch.lark` 有出入：前者把 `add_line` 与 `change_line` 写成 `/(.+)/`，要求前缀后至少一个字符；后者是 `/(.*)/`，允许只有前缀的空行。以 `.lark` 文件为准，解析器也接受这种空行。
</Callout>

## 解析：永远宽松，逐行推进

`parse_patch` 有严格和宽松两种模式，但开关是写死的：

```rust
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
/// gpt-4.1. While we could try to require everyone to pass in a strictness
/// param when invoking apply_patch, it is a pain to thread it through all of
/// the call sites, so we resign ourselves allowing lenient parsing for all
/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
/// gpt-4.1.
const PARSE_IN_STRICT_MODE: bool = false;
```

（`codex-rs/apply-patch/src/parser.rs:47`）

解析器本来就容忍标记行前后的空白；宽松模式再多做一件事：整段如果被 `<<EOF`、`<<'EOF'` 或 `<<"EOF"` 与结尾的 `EOF` 包着，就剥掉这层 heredoc 外壳。`ParseMode::Lenient` 的注释解释了来由：GPT-4.1 会把以 `<<'EOF'` 开头的整段 heredoc 当作 `apply_patch` 的参数，直接放进不经 shell 的 argv，heredoc 标记于是成了补丁正文的一部分。

真正干活的是 `StreamingPatchParser`，一个按行推进的状态机：`NotStarted` 只接受 `*** Begin Patch`，之后在 `StartedPatch`、`AddFile`、`DeleteFile`、`UpdateFile` 之间切换，遇到 `*** End Patch` 进入 `EndedPatch`，每条错误都带行号，例如 `Update file hunk for path 'test.py' is empty`。它的 `push_delta`（`codex-rs/apply-patch/src/streaming_parser.rs:139`）逐字符攒行，遇到换行（顺手去掉行尾的 `\r`）才处理一行，每次都返回目前为止解析出的全部 hunk；非流式的 `parse_patch` 只是把整段文本一次喂进去，再调用 `finish` 检查结尾。流式接口是为模型边生成边展示准备的：`ApplyPatchHandler` 实现了[注册表](https://daiw.net/manual/codex-source/tool-architecture)里的 `create_diff_consumer`，模型每吐出一段补丁，解析器就多推进几行，产出目前已知的文件变更，按至少 500 毫秒的间隔发 `PatchApplyUpdated` 事件。这条路径受仍在开发中的 `apply_patch_streaming_events` 特性控制，默认关闭。

## 定位：四级放宽的 seek_sequence

应用一个修改块时，`compute_replacements` 先用 `@@` 后面那行上下文找到大致位置，再从那里往下找块里的旧行（上下文行加 `-` 行）；带 `*** End of File` 的块从文件末尾找起；旧行以空行结尾却找不到时，去掉这个代表末尾换行的空行再试一次。所有替换算好之后按位置倒序应用，前面的替换不会挪动后面的下标。查找本身在 `seek_sequence` 里，严格程度逐级下降：

```rust
    // Exact match first.
    for i in search_start..=lines.len().saturating_sub(pattern.len()) {
        if lines[i..i + pattern.len()] == *pattern {
            return Some(i);
        }
    }
    // Then rstrip match.
    for i in search_start..=lines.len().saturating_sub(pattern.len()) {
        let mut ok = true;
        for (p_idx, pat) in pattern.iter().enumerate() {
            if lines[i + p_idx].trim_end() != pat.trim_end() {
                ok = false;
                break;
            }
        }
        if ok {
            return Some(i);
        }
    }
```

（`codex-rs/apply-patch/src/seek_sequence.rs:39`）

第三级忽略首尾空白，第四级再把各种破折号、弯引号、不间断空格等 Unicode 标点归一成 ASCII，注释说这是在模仿 `git apply` 定位上下文时的模糊行为。四级都失败，模型会收到 `Failed to find expected lines in <路径>` 加上它写的旧行，或者 `Failed to find context '<上下文>' in <路径>`。换行符默认统一成 LF（`ApplyPatchFileUpdateMode::NormalizeToLf`），保留原文件换行风格的 `apply_patch_preserve_line_endings` 还在开发中。

## 模型把补丁写进 shell 命令时

内置的缺省基础指令（`BaseInstructions` 的默认值，`codex-rs/protocol/src/prompts/base_instructions/default.md`）里，示范写法仍是 `{"command":["apply_patch","*** Begin Patch\n..."]}` 这种命令调用形式，模型也常写成 `apply_patch <<'EOF' ... EOF`。所以补丁有三个入口：

```mermaid
flowchart TB
  A1[apply_patch 工具<br/>ToolPayload::Custom] --> P[parse_patch]
  A2[exec_command 的 cmd] --> IC[intercept_apply_patch<br/>maybe_parse_apply_patch]
  IC -->|是补丁| P
  IC -->|不是| SH[照常启动 shell]
  SH --> AR[PATH 上的 apply_patch<br/>其实是 codex 自己]
  P --> V[verify_apply_patch_args<br/>读文件 · 算 diff]
  V --> S[assess_patch_safety<br/>自动批准 · 询问 · 拒绝]
  S --> O[ToolOrchestrator::run<br/>ApplyPatchRuntime]
  O --> FS[执行环境文件系统<br/>逐个 hunk 落盘]
  AR --> FS2[本地文件系统]
```

`exec_command` 在起进程之前调用 `intercept_apply_patch`，由 `maybe_parse_apply_patch` 判断这条命令是不是补丁：

```rust
pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch {
    match argv {
        // Direct invocation: apply_patch <patch>
        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
            Ok(source) => MaybeApplyPatch::Body(source),
            Err(e) => MaybeApplyPatch::PatchParseError(e),
        },
        // Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
        _ => match parse_shell_script(argv, cwd) {
            // ...
            None => MaybeApplyPatch::NotApplyPatch,
        },
    }
}
```

（`codex-rs/apply-patch/src/invocation.rs:113`）

shell 形式认 `bash`、`zsh`、`sh` 的 `-c` / `-lc`，PowerShell 的 `-Command`（前面可以有 `-NoProfile`）与 cmd 的 `/c`；脚本用 tree-sitter-bash 查询匹配，必须整段只有一条语句：`apply_patch <<'EOF' ... EOF`，或者 `cd <目录> && apply_patch <<'EOF' ... EOF`，命令名写成 `applypatch` 也认。前后多一句 `echo` 就不算，注释的理由是别的写法多半是模型出错。认出来的补丁走和 `apply_patch` 工具完全相同的校验、审批与应用流程，不会真的起进程。

认不出来的命令照常交给 shell。这时如果脚本里调用了 `apply_patch`，找到的其实是 Codex 自己：`codex-arg0` 启动时建一个临时目录，放一个指向当前可执行文件的 `apply_patch` 符号链接（Windows 上是带 `--codex-run-as-apply-patch` 参数的 `apply_patch.bat`），并把它加到 `PATH` 最前面。Unix 上以这个名字启动的 Codex 直接进入 `codex-rs/apply-patch/src/standalone_executable.rs`，补丁可以来自参数或 stdin；Windows 的 `.bat` 走隐藏参数，只接受参数形式。两者都在当前目录应用补丁，它们只是沙箱里 shell 的普通子进程，受同一个沙箱约束。

## 从校验到落盘

`ApplyPatchHandler` 拿到补丁后分三步走。先校验：`verify_apply_patch_args_with_mode` 在执行环境的文件系统上读出每个待改文件，算出 unified diff 与新内容，得到一个 `ApplyPatchAction`，这一步只读不写。再判定：`assess_patch_safety` 规定空补丁直接拒绝、`untrusted` 一律询问；否则只要所有目标路径（含改名的目的地）都在可写范围内、且有沙箱兜底（或者权限配置本来就不设沙箱），就自动批准；够不上自动批准时，审批策略为 `never`（或细粒度配置关掉了沙箱审批）就拒绝，其余情况询问用户。细节留给[审批流程](https://daiw.net/manual/codex-source/approvals-flow)。最后执行：`ToolOrchestrator` 驱动 `ApplyPatchRuntime`，它不起子进程，而是在进程内调用 `apply_patch_with_options`，通过执行环境的文件系统接口写文件，沙箱由传入的 `FileSystemSandboxContext` 约束；写入被沙箱拒绝时，编排器照常按策略请求批准后重试。

应用是逐个 hunk 进行的，不是事务。中途失败时，前面已经写下的改动不会回滚，`AppliedPatchDelta` 把真正提交了的变更记下来（写失败时还会标记“不再精确”），`ToolEmitter` 据此更新整轮的 diff，让它与磁盘上的实际状态一致。成功时模型收到的是 `Success. Updated the following files:` 加上以 `A`、`M`、`D` 开头的文件清单，外面再包一层 `Exit code`、`Wall time`、`Output` 头部；界面那一路拿到的则是每个文件完整的 diff。

## 和《从 LLM 到 Coding Agent》对照

[工具系统的抽象设计](https://daiw.net/manual/llm-to-agent/tool-abstraction)讲“一次执行，两个受众”，举的例子正是编辑文件：给模型一句“已编辑 foo.ts”，给人一块 diff。`apply_patch` 就是这么做的，模型只看到 `A`、`M`、`D` 清单，界面拿到完整的变更与 diff。[OpenCode 源码解读](https://daiw.net/manual/opencode/file-tools)一篇提到，OpenCode 给 GPT 系列模型提供的正是同一种补丁格式，匹配也分精确、忽略行尾空白、忽略首尾空白、标点归一四级放宽；两边的格式与匹配思路一致，可以对照着读。

---

上一篇：[读懂一条命令 · 解析、安全判断与 shell 快照](https://daiw.net/manual/codex-source/shell-command-analysis) · 下一篇：[MCP 工具调用 · 连接管理与工具目录](https://daiw.net/manual/codex-source/mcp-tool-calls)
