Skip to content

Commit 517ffd0

Browse files
authored
feat: use the arg0 trick with apply_patch (#2646)
Historically, Codex CLI has treated `apply_patch` (and its sometimes misspelling, `applypatch`) as a "virtual CLI," intercepting it when it appears as the first arg to `command` for the `"container.exec", `"shell"`, or `"local_shell"` tools. This approach has a known limitation where if, say, the model created a Python script that runs `apply_patch` and then tried to run the Python script, we have no insight as to what the model is trying to do and the Python Script would fail because `apply_patch` was never really on the `PATH`. One way to solve this problem is to require users to install an `apply_patch` executable alongside the `codex` executable (or at least put it someplace where Codex can discover it). Though to keep Codex CLI as a standalone executable, we exploit "the arg0 trick" where we create a temporary directory with an entry named `apply_patch` and prepend that directory to the `PATH` for the duration of the invocation of Codex. - On UNIX, `apply_patch` is a symlink to `codex`, which now changes its behavior to behave like `apply_patch` if arg0 is `apply_patch` (or `applypatch`) - On Windows, `apply_patch.bat` is a batch script that runs `codex --codex-run-as-apply-patch %*`, as Codex also changes its behavior if the first argument is `--codex-run-as-apply-patch`.
1 parent 4157788 commit 517ffd0

File tree

10 files changed

+254
-1
lines changed

10 files changed

+254
-1
lines changed

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/apply-patch/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ version = { workspace = true }
77
name = "codex_apply_patch"
88
path = "src/lib.rs"
99

10+
[[bin]]
11+
name = "apply_patch"
12+
path = "src/main.rs"
13+
1014
[lints]
1115
workspace = true
1216

@@ -18,5 +22,6 @@ tree-sitter = "0.25.8"
1822
tree-sitter-bash = "0.25.0"
1923

2024
[dev-dependencies]
25+
assert_cmd = "2"
2126
pretty_assertions = "1.4.1"
2227
tempfile = "3.13.0"

codex-rs/apply-patch/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod parser;
22
mod seek_sequence;
3+
mod standalone_executable;
34

45
use std::collections::HashMap;
56
use std::path::Path;
@@ -19,6 +20,8 @@ use tree_sitter::LanguageError;
1920
use tree_sitter::Parser;
2021
use tree_sitter_bash::LANGUAGE as BASH;
2122

23+
pub use standalone_executable::main;
24+
2225
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
2326
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
2427

codex-rs/apply-patch/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub fn main() -> ! {
2+
codex_apply_patch::main()
3+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use std::io::Read;
2+
use std::io::Write;
3+
4+
pub fn main() -> ! {
5+
let exit_code = run_main();
6+
std::process::exit(exit_code);
7+
}
8+
9+
/// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
10+
/// method is still a nightly API and we want main() to return !.
11+
pub fn run_main() -> i32 {
12+
// Expect either one argument (the full apply_patch payload) or read it from stdin.
13+
let mut args = std::env::args_os();
14+
let _argv0 = args.next();
15+
16+
let patch_arg = match args.next() {
17+
Some(arg) => match arg.into_string() {
18+
Ok(s) => s,
19+
Err(_) => {
20+
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
21+
return 1;
22+
}
23+
},
24+
None => {
25+
// No argument provided; attempt to read the patch from stdin.
26+
let mut buf = String::new();
27+
match std::io::stdin().read_to_string(&mut buf) {
28+
Ok(_) => {
29+
if buf.is_empty() {
30+
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply-patch");
31+
return 2;
32+
}
33+
buf
34+
}
35+
Err(err) => {
36+
eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
37+
return 1;
38+
}
39+
}
40+
}
41+
};
42+
43+
// Refuse extra args to avoid ambiguity.
44+
if args.next().is_some() {
45+
eprintln!("Error: apply_patch accepts exactly one argument.");
46+
return 2;
47+
}
48+
49+
let mut stdout = std::io::stdout();
50+
let mut stderr = std::io::stderr();
51+
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
52+
Ok(()) => {
53+
// Flush to ensure output ordering when used in pipelines.
54+
let _ = stdout.flush();
55+
0
56+
}
57+
Err(_) => 1,
58+
}
59+
}

codex-rs/apply-patch/tests/all.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Single integration test binary that aggregates all test modules.
2+
// The submodules live in `tests/suite/`.
3+
mod suite;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
use assert_cmd::prelude::*;
2+
use std::fs;
3+
use std::process::Command;
4+
use tempfile::tempdir;
5+
6+
#[test]
7+
fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
8+
let tmp = tempdir()?;
9+
let file = "cli_test.txt";
10+
let absolute_path = tmp.path().join(file);
11+
12+
// 1) Add a file
13+
let add_patch = format!(
14+
r#"*** Begin Patch
15+
*** Add File: {file}
16+
+hello
17+
*** End Patch"#
18+
);
19+
Command::cargo_bin("apply_patch")
20+
.expect("should find apply_patch binary")
21+
.arg(add_patch)
22+
.current_dir(tmp.path())
23+
.assert()
24+
.success()
25+
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
26+
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
27+
28+
// 2) Update the file
29+
let update_patch = format!(
30+
r#"*** Begin Patch
31+
*** Update File: {file}
32+
@@
33+
-hello
34+
+world
35+
*** End Patch"#
36+
);
37+
Command::cargo_bin("apply_patch")
38+
.expect("should find apply_patch binary")
39+
.arg(update_patch)
40+
.current_dir(tmp.path())
41+
.assert()
42+
.success()
43+
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
44+
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
45+
46+
Ok(())
47+
}
48+
49+
#[test]
50+
fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
51+
let tmp = tempdir()?;
52+
let file = "cli_test_stdin.txt";
53+
let absolute_path = tmp.path().join(file);
54+
55+
// 1) Add a file via stdin
56+
let add_patch = format!(
57+
r#"*** Begin Patch
58+
*** Add File: {file}
59+
+hello
60+
*** End Patch"#
61+
);
62+
let mut cmd =
63+
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
64+
cmd.current_dir(tmp.path());
65+
cmd.write_stdin(add_patch)
66+
.assert()
67+
.success()
68+
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
69+
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
70+
71+
// 2) Update the file via stdin
72+
let update_patch = format!(
73+
r#"*** Begin Patch
74+
*** Update File: {file}
75+
@@
76+
-hello
77+
+world
78+
*** End Patch"#
79+
);
80+
let mut cmd =
81+
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
82+
cmd.current_dir(tmp.path());
83+
cmd.write_stdin(update_patch)
84+
.assert()
85+
.success()
86+
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
87+
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
88+
89+
Ok(())
90+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mod cli;

codex-rs/arg0/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ codex-apply-patch = { path = "../apply-patch" }
1616
codex-core = { path = "../core" }
1717
codex-linux-sandbox = { path = "../linux-sandbox" }
1818
dotenvy = "0.15.7"
19+
tempfile = "3"
1920
tokio = { version = "1", features = ["rt-multi-thread"] }

codex-rs/arg0/src/lib.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ use std::path::Path;
33
use std::path::PathBuf;
44

55
use codex_core::CODEX_APPLY_PATCH_ARG1;
6+
#[cfg(unix)]
7+
use std::os::unix::fs::symlink;
8+
use tempfile::TempDir;
9+
10+
const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
11+
const APPLY_PATCH_ARG0: &str = "apply_patch";
12+
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
613

714
/// While we want to deploy the Codex CLI as a single executable for simplicity,
815
/// we also want to expose some of its functionality as distinct CLIs, so we use
@@ -39,9 +46,11 @@ where
3946
.and_then(|s| s.to_str())
4047
.unwrap_or("");
4148

42-
if exe_name == "codex-linux-sandbox" {
49+
if exe_name == LINUX_SANDBOX_ARG0 {
4350
// Safety: [`run_main`] never returns.
4451
codex_linux_sandbox::run_main();
52+
} else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 {
53+
codex_apply_patch::main();
4554
}
4655

4756
let argv1 = args.next().unwrap_or_default();
@@ -68,6 +77,19 @@ where
6877
// before creating any threads/the Tokio runtime.
6978
load_dotenv();
7079

80+
// Retain the TempDir so it exists for the lifetime of the invocation of
81+
// this executable. Admittedly, we could invoke `keep()` on it, but it
82+
// would be nice to avoid leaving temporary directories behind, if possible.
83+
let _path_entry = match prepend_path_entry_for_apply_patch() {
84+
Ok(path_entry) => Some(path_entry),
85+
Err(err) => {
86+
// It is possible that Codex will proceed successfully even if
87+
// updating the PATH fails, so warn the user and move on.
88+
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
89+
None
90+
}
91+
};
92+
7193
// Regular invocation – create a Tokio runtime and execute the provided
7294
// async entry-point.
7395
let runtime = tokio::runtime::Runtime::new()?;
@@ -113,3 +135,67 @@ where
113135
}
114136
}
115137
}
138+
139+
/// Creates a temporary directory with either:
140+
///
141+
/// - UNIX: `apply_patch` symlink to the current executable
142+
/// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable
143+
/// with the "secret" --codex-run-as-apply-patch flag.
144+
///
145+
/// This temporary directory is prepended to the PATH environment variable so
146+
/// that `apply_patch` can be on the PATH without requiring the user to
147+
/// install a separate `apply_patch` executable, simplifying the deployment of
148+
/// Codex CLI.
149+
///
150+
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
151+
/// be called before multiple threads are spawned.
152+
fn prepend_path_entry_for_apply_patch() -> std::io::Result<TempDir> {
153+
let temp_dir = TempDir::new()?;
154+
let path = temp_dir.path();
155+
156+
for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] {
157+
let exe = std::env::current_exe()?;
158+
159+
#[cfg(unix)]
160+
{
161+
let link = path.join(filename);
162+
symlink(&exe, &link)?;
163+
}
164+
165+
#[cfg(windows)]
166+
{
167+
let batch_script = path.join(format!("{filename}.bat"));
168+
std::fs::write(
169+
&batch_script,
170+
format!(
171+
r#"@echo off
172+
"{}" {CODEX_APPLY_PATCH_ARG1} %*
173+
"#,
174+
exe.display()
175+
),
176+
)?;
177+
}
178+
}
179+
180+
#[cfg(unix)]
181+
const PATH_SEPARATOR: &str = ":";
182+
183+
#[cfg(windows)]
184+
const PATH_SEPARATOR: &str = ";";
185+
186+
let path_element = path.display();
187+
let updated_path_env_var = match std::env::var("PATH") {
188+
Ok(existing_path) => {
189+
format!("{path_element}{PATH_SEPARATOR}{existing_path}")
190+
}
191+
Err(_) => {
192+
format!("{path_element}")
193+
}
194+
};
195+
196+
unsafe {
197+
std::env::set_var("PATH", updated_path_env_var);
198+
}
199+
200+
Ok(temp_dir)
201+
}

0 commit comments

Comments
 (0)