mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
apply-patch: carry paths as PathUri (#28854)
## Why Allows the model to edit files that are hosted on a different OS than where app-server is running. ## What * Use `PathUri` for apply_patch-internal data structures * Limit `PathUri` -> `AbsolutePathBuf` conversion to cases where the inferred path convention matches the host OS, allows requiring valid paths to pass to perms check * Adds `PathConvention::path_segments()` for iterating over path segments regardless of OS * Handle cross-platform relative paths in path filename parsing for sniffing a shell * Ensure we can apply patches in the wine e2e test
This commit is contained in:
committed by
GitHub
Unverified
parent
a52a3b5197
commit
0f89dd768c
@@ -1,9 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tree_sitter::Parser;
|
||||
use tree_sitter::Query;
|
||||
use tree_sitter::QueryCursor;
|
||||
@@ -21,6 +19,7 @@ use crate::parser::Hunk;
|
||||
use crate::parser::ParseError;
|
||||
use crate::parser::parse_patch;
|
||||
use crate::unified_diff_from_chunks;
|
||||
use codex_utils_path_uri::PathConvention;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::str::Utf8Error;
|
||||
use tree_sitter::LanguageError;
|
||||
@@ -51,15 +50,17 @@ pub enum ExtractHeredocError {
|
||||
FailedToFindHeredocBody,
|
||||
}
|
||||
|
||||
fn classify_shell_name(shell: &str) -> Option<String> {
|
||||
std::path::Path::new(shell)
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
fn classify_shell_name(shell: &str, convention: PathConvention) -> Option<String> {
|
||||
let basename = convention.path_segments(shell).next_back()?;
|
||||
let stem = basename
|
||||
.rsplit_once('.')
|
||||
.and_then(|(stem, _extension)| (!stem.is_empty()).then_some(stem))
|
||||
.unwrap_or(basename);
|
||||
Some(stem.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
|
||||
classify_shell_name(shell).and_then(|name| match name.as_str() {
|
||||
fn classify_shell(shell: &str, flag: &str, convention: PathConvention) -> Option<ApplyPatchShell> {
|
||||
classify_shell_name(shell, convention).and_then(|name| match name.as_str() {
|
||||
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
|
||||
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
|
||||
Some(ApplyPatchShell::PowerShell)
|
||||
@@ -69,20 +70,24 @@ fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
|
||||
})
|
||||
}
|
||||
|
||||
fn can_skip_flag(shell: &str, flag: &str) -> bool {
|
||||
classify_shell_name(shell).is_some_and(|name| {
|
||||
fn can_skip_flag(shell: &str, flag: &str, convention: PathConvention) -> bool {
|
||||
classify_shell_name(shell, convention).is_some_and(|name| {
|
||||
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
|
||||
fn parse_shell_script<'a>(argv: &'a [String], cwd: &PathUri) -> Option<(ApplyPatchShell, &'a str)> {
|
||||
let convention = cwd.infer_path_convention()?;
|
||||
match argv {
|
||||
[shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
|
||||
[shell, flag, script] => classify_shell(shell, flag, convention).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
}),
|
||||
[shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
|
||||
classify_shell(shell, flag).map(|shell_type| {
|
||||
[shell, skip_flag, flag, script] => {
|
||||
if !can_skip_flag(shell, skip_flag, convention) {
|
||||
return None;
|
||||
}
|
||||
classify_shell(shell, flag, convention).map(|shell_type| {
|
||||
let script = script.as_str();
|
||||
(shell_type, script)
|
||||
})
|
||||
@@ -103,7 +108,8 @@ fn extract_apply_patch_from_shell(
|
||||
}
|
||||
|
||||
// TODO: make private once we remove tests in lib.rs
|
||||
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
/// `cwd` supplies the path convention used to interpret the shell executable in `argv`.
|
||||
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) {
|
||||
@@ -111,7 +117,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
Err(e) => MaybeApplyPatch::PatchParseError(e),
|
||||
},
|
||||
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
|
||||
_ => match parse_shell_script(argv) {
|
||||
_ => match parse_shell_script(argv, cwd) {
|
||||
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
|
||||
Ok((body, workdir)) => match parse_patch(&body) {
|
||||
Ok(mut source) => {
|
||||
@@ -130,11 +136,11 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// cwd must be an absolute path so that we can resolve relative paths in the
|
||||
/// patch.
|
||||
/// `cwd` must identify an absolute environment-native path so relative patch paths can be
|
||||
/// resolved without projecting them onto the app-server or exec-server host.
|
||||
pub async fn maybe_parse_apply_patch_verified(
|
||||
argv: &[String],
|
||||
cwd: &AbsolutePathBuf,
|
||||
cwd: &PathUri,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
||||
) -> MaybeApplyPatchVerified {
|
||||
@@ -145,13 +151,13 @@ pub async fn maybe_parse_apply_patch_verified(
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
if let Some((_, script)) = parse_shell_script(argv)
|
||||
if let Some((_, script)) = parse_shell_script(argv, cwd)
|
||||
&& parse_patch(script).is_ok()
|
||||
{
|
||||
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
|
||||
}
|
||||
|
||||
match maybe_parse_apply_patch(argv) {
|
||||
match maybe_parse_apply_patch(argv, cwd) {
|
||||
MaybeApplyPatch::Body(args) => verify_apply_patch_args(args, cwd, fs, sandbox).await,
|
||||
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
|
||||
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
|
||||
@@ -161,10 +167,22 @@ pub async fn maybe_parse_apply_patch_verified(
|
||||
|
||||
pub async fn verify_apply_patch_args(
|
||||
args: ApplyPatchArgs,
|
||||
cwd: &AbsolutePathBuf,
|
||||
cwd: &PathUri,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
||||
) -> MaybeApplyPatchVerified {
|
||||
match try_verify_apply_patch_args(args, cwd, fs, sandbox).await {
|
||||
Ok(action) => MaybeApplyPatchVerified::Body(action),
|
||||
Err(err) => MaybeApplyPatchVerified::CorrectnessError(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_verify_apply_patch_args(
|
||||
args: ApplyPatchArgs,
|
||||
cwd: &PathUri,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
|
||||
) -> Result<ApplyPatchAction, ApplyPatchError> {
|
||||
let ApplyPatchArgs {
|
||||
patch,
|
||||
hunks,
|
||||
@@ -173,35 +191,24 @@ pub async fn verify_apply_patch_args(
|
||||
} = args;
|
||||
let effective_cwd = workdir
|
||||
.as_ref()
|
||||
.map(|dir| cwd.join(Path::new(dir)))
|
||||
.map(|dir| cwd.join(dir))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| cwd.clone());
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(&effective_cwd);
|
||||
let path = hunk.resolve_path(&effective_cwd)?;
|
||||
match hunk {
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(
|
||||
path.into_path_buf(),
|
||||
ApplyPatchFileChange::Add { content: contents },
|
||||
);
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
}
|
||||
Hunk::DeleteFile { .. } => {
|
||||
let path_uri = PathUri::from_abs_path(&path);
|
||||
let content = match fs.read_file_text(&path_uri, sandbox).await {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read {}", path.display()),
|
||||
source: e,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
changes.insert(
|
||||
path.into_path_buf(),
|
||||
ApplyPatchFileChange::Delete { content },
|
||||
);
|
||||
let content = fs.read_file_text(&path, sandbox).await.map_err(|source| {
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read {}", path.inferred_native_path_string()),
|
||||
source,
|
||||
})
|
||||
})?;
|
||||
changes.insert(path, ApplyPatchFileChange::Delete { content });
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
move_path, chunks, ..
|
||||
@@ -210,27 +217,24 @@ pub async fn verify_apply_patch_args(
|
||||
unified_diff,
|
||||
content: contents,
|
||||
..
|
||||
} = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
}
|
||||
};
|
||||
} = unified_diff_from_chunks(&path, &chunks, fs, sandbox).await?;
|
||||
changes.insert(
|
||||
path.into_path_buf(),
|
||||
path,
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()),
|
||||
move_path: move_path
|
||||
.map(|path| effective_cwd.join(&path.to_string_lossy()))
|
||||
.transpose()?,
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
Ok(ApplyPatchAction {
|
||||
changes,
|
||||
patch,
|
||||
cwd: effective_cwd.into(),
|
||||
cwd: effective_cwd,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -392,7 +396,6 @@ mod tests {
|
||||
use crate::unified_diff_from_chunks;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -448,8 +451,22 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
assert_match_args_with_cwd(
|
||||
args,
|
||||
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
||||
expected_workdir,
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_match_args_with_cwd(
|
||||
args: Vec<String>,
|
||||
cwd: &PathUri,
|
||||
expected_workdir: Option<&str>,
|
||||
) {
|
||||
match maybe_parse_apply_patch(&args, cwd) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
assert_eq!(hunks, expected_single_add());
|
||||
@@ -458,6 +475,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
assert_match_args(args, expected_workdir);
|
||||
@@ -466,7 +484,10 @@ mod tests {
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
maybe_parse_apply_patch(
|
||||
&args,
|
||||
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
||||
),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
);
|
||||
}
|
||||
@@ -479,7 +500,7 @@ mod tests {
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -496,7 +517,7 @@ mod tests {
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -516,7 +537,10 @@ mod tests {
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
match maybe_parse_apply_patch(
|
||||
&args,
|
||||
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
||||
) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
@@ -541,7 +565,10 @@ mod tests {
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
match maybe_parse_apply_patch(
|
||||
&args,
|
||||
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
||||
) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
@@ -580,7 +607,10 @@ mod tests {
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
match maybe_parse_apply_patch(
|
||||
&args,
|
||||
&PathUri::parse("file:///workspace").expect("valid POSIX test cwd"),
|
||||
) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
@@ -614,6 +644,21 @@ PATCH"#,
|
||||
assert_match_args(args_pwsh(&script), /*expected_workdir*/ None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_interception_uses_cwd_convention_for_windows_pwsh_path() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args_with_cwd(
|
||||
strs_to_strings(&[
|
||||
r"C:\Program Files\PowerShell\7\pwsh.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&script,
|
||||
]),
|
||||
&PathUri::parse("file:///C:/windows").expect("valid Windows test cwd"),
|
||||
/*expected_workdir*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
@@ -707,9 +752,9 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
@@ -747,9 +792,9 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_abs = path.as_path().abs();
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
@@ -787,7 +832,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -799,7 +844,8 @@ PATCH"#,
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
PathUri::from_path(session_dir.path().join(relative_path))
|
||||
.expect("absolute test path"),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
@@ -811,9 +857,7 @@ PATCH"#,
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: AbsolutePathBuf::from_absolute_path(session_dir.path())
|
||||
.unwrap()
|
||||
.into(),
|
||||
cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -843,7 +887,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -858,18 +902,18 @@ PATCH"#,
|
||||
worktree_dir.as_path()
|
||||
);
|
||||
|
||||
let source_path = worktree_dir.join(source_name);
|
||||
let source_path =
|
||||
PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path");
|
||||
let change = action
|
||||
.changes()
|
||||
.get(source_path.as_path())
|
||||
.get(&source_path)
|
||||
.expect("source file change present");
|
||||
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
assert_eq!(
|
||||
move_path.as_deref(),
|
||||
Some(worktree_dir.join(dest_name).as_path())
|
||||
);
|
||||
let expected_move_path =
|
||||
PathUri::from_path(worktree_dir.join(dest_name)).expect("absolute test path");
|
||||
assert_eq!(move_path.as_ref(), Some(&expected_move_path));
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
}
|
||||
@@ -879,7 +923,7 @@ PATCH"#,
|
||||
async fn test_unreadable_destinations_still_verify() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap();
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap();
|
||||
let cwd = PathUri::from_path(session_dir.path()).expect("absolute test path");
|
||||
let add_argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
"*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(),
|
||||
@@ -922,7 +966,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user