fix: ensure apply_patch resolves relative paths against workdir or project cwd (#810)
https://github.com/openai/codex/pull/800 kicked off some work to be more disciplined about honoring the `cwd` param passed in rather than assuming `std::env::current_dir()` as the `cwd`. As part of this, we need to ensure `apply_patch` calls honor the appropriate `cwd` as well, which is significant if the paths in the `apply_patch` arg are not absolute paths themselves. Failing that: - The `apply_patch` function call can contain an optional`workdir` param, so: - If specified and is an absolute path, it should be used to resolve relative paths - If specified and is a relative path, should be resolved against `Config.cwd` and then any relative paths will be resolved against the result - If `workdir` is not specified on the function call, relative paths should be resolved against `Config.cwd` Note that we had a similar issue in the TypeScript CLI that was fixed in https://github.com/openai/codex/pull/556. As part of the fix, this PR introduces `ApplyPatchAction` so clients can deal with that instead of the raw `HashMap<PathBuf, ApplyPatchFileChange>`. This enables us to enforce, by construction, that all paths contained in the `ApplyPatchAction` are absolute paths.
This commit is contained in:
@@ -12,6 +12,7 @@ use async_channel::Sender;
|
||||
use codex_apply_patch::maybe_parse_apply_patch_verified;
|
||||
use codex_apply_patch::print_summary;
|
||||
use codex_apply_patch::AffectedPaths;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_apply_patch::MaybeApplyPatchVerified;
|
||||
use fs_err as fs;
|
||||
@@ -271,7 +272,7 @@ impl Session {
|
||||
pub async fn request_patch_approval(
|
||||
&self,
|
||||
sub_id: String,
|
||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
action: &ApplyPatchAction,
|
||||
reason: Option<String>,
|
||||
grant_root: Option<PathBuf>,
|
||||
) -> oneshot::Receiver<ReviewDecision> {
|
||||
@@ -279,7 +280,7 @@ impl Session {
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest {
|
||||
changes: convert_apply_patch_to_protocol(changes),
|
||||
changes: convert_apply_patch_to_protocol(action),
|
||||
reason,
|
||||
grant_root,
|
||||
},
|
||||
@@ -304,19 +305,13 @@ impl Session {
|
||||
state.approved_commands.insert(cmd);
|
||||
}
|
||||
|
||||
async fn notify_exec_command_begin(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
call_id: &str,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
) {
|
||||
async fn notify_exec_command_begin(&self, sub_id: &str, call_id: &str, params: &ExecParams) {
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin {
|
||||
call_id: call_id.to_string(),
|
||||
command,
|
||||
cwd,
|
||||
command: params.command.clone(),
|
||||
cwd: params.cwd.clone(),
|
||||
},
|
||||
};
|
||||
let _ = self.tx_event.send(event).await;
|
||||
@@ -886,8 +881,12 @@ async fn handle_function_call(
|
||||
match name.as_str() {
|
||||
"container.exec" | "shell" => {
|
||||
// parse command
|
||||
let params = match serde_json::from_str::<ShellToolCallParams>(&arguments) {
|
||||
Ok(v) => v,
|
||||
let params: ExecParams = match serde_json::from_str::<ShellToolCallParams>(&arguments) {
|
||||
Ok(shell_tool_call_params) => ExecParams {
|
||||
command: shell_tool_call_params.command,
|
||||
cwd: sess.resolve_path(shell_tool_call_params.workdir.clone()),
|
||||
timeout_ms: shell_tool_call_params.timeout_ms,
|
||||
},
|
||||
Err(e) => {
|
||||
// allow model to re-sample
|
||||
let output = ResponseInputItem::FunctionCallOutput {
|
||||
@@ -902,7 +901,7 @@ async fn handle_function_call(
|
||||
};
|
||||
|
||||
// check if this was a patch, and apply it if so
|
||||
match maybe_parse_apply_patch_verified(¶ms.command) {
|
||||
match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) {
|
||||
MaybeApplyPatchVerified::Body(changes) => {
|
||||
return apply_patch(sess, sub_id, call_id, changes).await;
|
||||
}
|
||||
@@ -924,9 +923,6 @@ async fn handle_function_call(
|
||||
MaybeApplyPatchVerified::NotApplyPatch => (),
|
||||
}
|
||||
|
||||
// this was not a valid patch, execute command
|
||||
let workdir = sess.resolve_path(params.workdir.clone());
|
||||
|
||||
// safety checks
|
||||
let safety = {
|
||||
let state = sess.state.lock().unwrap();
|
||||
@@ -944,7 +940,7 @@ async fn handle_function_call(
|
||||
.request_command_approval(
|
||||
sub_id.clone(),
|
||||
params.command.clone(),
|
||||
workdir.clone(),
|
||||
params.cwd.clone(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
@@ -980,20 +976,11 @@ async fn handle_function_call(
|
||||
}
|
||||
};
|
||||
|
||||
sess.notify_exec_command_begin(
|
||||
&sub_id,
|
||||
&call_id,
|
||||
params.command.clone(),
|
||||
workdir.clone(),
|
||||
)
|
||||
.await;
|
||||
sess.notify_exec_command_begin(&sub_id, &call_id, ¶ms)
|
||||
.await;
|
||||
|
||||
let output_result = process_exec_tool_call(
|
||||
ExecParams {
|
||||
command: params.command.clone(),
|
||||
cwd: workdir.clone(),
|
||||
timeout_ms: params.timeout_ms,
|
||||
},
|
||||
params.clone(),
|
||||
sandbox_type,
|
||||
sess.ctrl_c.clone(),
|
||||
&sess.sandbox_policy,
|
||||
@@ -1050,7 +1037,7 @@ async fn handle_function_call(
|
||||
.request_command_approval(
|
||||
sub_id.clone(),
|
||||
params.command.clone(),
|
||||
workdir,
|
||||
params.cwd.clone(),
|
||||
Some("command failed; retry without sandbox?".to_string()),
|
||||
)
|
||||
.await;
|
||||
@@ -1071,23 +1058,13 @@ async fn handle_function_call(
|
||||
|
||||
// Emit a fresh Begin event so progress bars reset.
|
||||
let retry_call_id = format!("{call_id}-retry");
|
||||
let cwd = sess.resolve_path(params.workdir.clone());
|
||||
sess.notify_exec_command_begin(
|
||||
&sub_id,
|
||||
&retry_call_id,
|
||||
params.command.clone(),
|
||||
cwd.clone(),
|
||||
)
|
||||
.await;
|
||||
sess.notify_exec_command_begin(&sub_id, &retry_call_id, ¶ms)
|
||||
.await;
|
||||
|
||||
// This is an escalated retry; the policy will not be
|
||||
// examined and the sandbox has been set to `None`.
|
||||
let retry_output_result = process_exec_tool_call(
|
||||
ExecParams {
|
||||
command: params.command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
timeout_ms: params.timeout_ms,
|
||||
},
|
||||
params,
|
||||
SandboxType::None,
|
||||
sess.ctrl_c.clone(),
|
||||
&sess.sandbox_policy,
|
||||
@@ -1180,7 +1157,7 @@ async fn apply_patch(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
call_id: String,
|
||||
changes: HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
action: ApplyPatchAction,
|
||||
) -> ResponseInputItem {
|
||||
let writable_roots_snapshot = {
|
||||
let guard = sess.writable_roots.lock().unwrap();
|
||||
@@ -1188,7 +1165,7 @@ async fn apply_patch(
|
||||
};
|
||||
|
||||
let auto_approved = match assess_patch_safety(
|
||||
&changes,
|
||||
&action,
|
||||
sess.approval_policy,
|
||||
&writable_roots_snapshot,
|
||||
&sess.cwd,
|
||||
@@ -1198,7 +1175,7 @@ async fn apply_patch(
|
||||
// Compute a readable summary of path changes to include in the
|
||||
// approval request so the user can make an informed decision.
|
||||
let rx_approve = sess
|
||||
.request_patch_approval(sub_id.clone(), &changes, None, None)
|
||||
.request_patch_approval(sub_id.clone(), &action, None, None)
|
||||
.await;
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
|
||||
@@ -1227,7 +1204,7 @@ async fn apply_patch(
|
||||
// Verify write permissions before touching the filesystem.
|
||||
let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() };
|
||||
|
||||
if let Some(offending) = first_offending_path(&changes, &writable_snapshot, &sess.cwd) {
|
||||
if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) {
|
||||
let root = offending.parent().unwrap_or(&offending).to_path_buf();
|
||||
|
||||
let reason = Some(format!(
|
||||
@@ -1236,7 +1213,7 @@ async fn apply_patch(
|
||||
));
|
||||
|
||||
let rx = sess
|
||||
.request_patch_approval(sub_id.clone(), &changes, reason.clone(), Some(root.clone()))
|
||||
.request_patch_approval(sub_id.clone(), &action, reason.clone(), Some(root.clone()))
|
||||
.await;
|
||||
|
||||
if !matches!(
|
||||
@@ -1263,7 +1240,7 @@ async fn apply_patch(
|
||||
msg: EventMsg::PatchApplyBegin {
|
||||
call_id: call_id.clone(),
|
||||
auto_approved,
|
||||
changes: convert_apply_patch_to_protocol(&changes),
|
||||
changes: convert_apply_patch_to_protocol(&action),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
@@ -1272,37 +1249,43 @@ async fn apply_patch(
|
||||
let mut stderr = Vec::new();
|
||||
// Enforce writable roots. If a write is blocked, collect offending root
|
||||
// and prompt the user to extend permissions.
|
||||
let mut result = apply_changes_from_apply_patch_and_report(&changes, &mut stdout, &mut stderr);
|
||||
let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr);
|
||||
|
||||
if let Err(err) = &result {
|
||||
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
// Determine first offending path.
|
||||
let offending_opt = changes.iter().find_map(|(path, change)| {
|
||||
let path_ref = match change {
|
||||
ApplyPatchFileChange::Add { .. } => path,
|
||||
ApplyPatchFileChange::Delete => path,
|
||||
ApplyPatchFileChange::Update { .. } => path,
|
||||
};
|
||||
let offending_opt = action
|
||||
.changes()
|
||||
.iter()
|
||||
.flat_map(|(path, change)| match change {
|
||||
ApplyPatchFileChange::Add { .. } => vec![path.as_ref()],
|
||||
ApplyPatchFileChange::Delete => vec![path.as_ref()],
|
||||
ApplyPatchFileChange::Update {
|
||||
move_path: Some(move_path),
|
||||
..
|
||||
} => {
|
||||
vec![path.as_ref(), move_path.as_ref()]
|
||||
}
|
||||
ApplyPatchFileChange::Update {
|
||||
move_path: None, ..
|
||||
} => vec![path.as_ref()],
|
||||
})
|
||||
.find_map(|path: &Path| {
|
||||
// ApplyPatchAction promises to guarantee absolute paths.
|
||||
if !path.is_absolute() {
|
||||
panic!("apply_patch invariant failed: path is not absolute: {path:?}");
|
||||
}
|
||||
|
||||
// Reuse safety normalization logic: treat absolute path.
|
||||
let abs = if path_ref.is_absolute() {
|
||||
path_ref.clone()
|
||||
} else {
|
||||
// TODO(mbolin): If workdir was supplied with apply_patch call,
|
||||
// relative paths should be resolved against it.
|
||||
sess.cwd.join(path_ref)
|
||||
};
|
||||
|
||||
let writable = {
|
||||
let roots = sess.writable_roots.lock().unwrap();
|
||||
roots.iter().any(|root| abs.starts_with(root))
|
||||
};
|
||||
if writable {
|
||||
None
|
||||
} else {
|
||||
Some(path_ref.clone())
|
||||
}
|
||||
});
|
||||
let writable = {
|
||||
let roots = sess.writable_roots.lock().unwrap();
|
||||
roots.iter().any(|root| path.starts_with(root))
|
||||
};
|
||||
if writable {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(offending) = offending_opt {
|
||||
let root = offending.parent().unwrap_or(&offending).to_path_buf();
|
||||
@@ -1314,7 +1297,7 @@ async fn apply_patch(
|
||||
let rx = sess
|
||||
.request_patch_approval(
|
||||
sub_id.clone(),
|
||||
&changes,
|
||||
&action,
|
||||
reason.clone(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
@@ -1328,7 +1311,7 @@ async fn apply_patch(
|
||||
stdout.clear();
|
||||
stderr.clear();
|
||||
result = apply_changes_from_apply_patch_and_report(
|
||||
&changes,
|
||||
&action,
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
);
|
||||
@@ -1374,10 +1357,11 @@ async fn apply_patch(
|
||||
/// `writable_roots` (after normalising). If all paths are acceptable,
|
||||
/// returns None.
|
||||
fn first_offending_path(
|
||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
action: &ApplyPatchAction,
|
||||
writable_roots: &[PathBuf],
|
||||
cwd: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let changes = action.changes();
|
||||
for (path, change) in changes {
|
||||
let candidate = match change {
|
||||
ApplyPatchFileChange::Add { .. } => path,
|
||||
@@ -1411,9 +1395,8 @@ fn first_offending_path(
|
||||
None
|
||||
}
|
||||
|
||||
fn convert_apply_patch_to_protocol(
|
||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
) -> HashMap<PathBuf, FileChange> {
|
||||
fn convert_apply_patch_to_protocol(action: &ApplyPatchAction) -> HashMap<PathBuf, FileChange> {
|
||||
let changes = action.changes();
|
||||
let mut result = HashMap::with_capacity(changes.len());
|
||||
for (path, change) in changes {
|
||||
let protocol_change = match change {
|
||||
@@ -1436,11 +1419,11 @@ fn convert_apply_patch_to_protocol(
|
||||
}
|
||||
|
||||
fn apply_changes_from_apply_patch_and_report(
|
||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
action: &ApplyPatchAction,
|
||||
stdout: &mut impl std::io::Write,
|
||||
stderr: &mut impl std::io::Write,
|
||||
) -> std::io::Result<()> {
|
||||
match apply_changes_from_apply_patch(changes) {
|
||||
match apply_changes_from_apply_patch(action) {
|
||||
Ok(affected_paths) => {
|
||||
print_summary(&affected_paths, stdout)?;
|
||||
}
|
||||
@@ -1452,13 +1435,12 @@ fn apply_changes_from_apply_patch_and_report(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_changes_from_apply_patch(
|
||||
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
) -> anyhow::Result<AffectedPaths> {
|
||||
fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result<AffectedPaths> {
|
||||
let mut added: Vec<PathBuf> = Vec::new();
|
||||
let mut modified: Vec<PathBuf> = Vec::new();
|
||||
let mut deleted: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let changes = action.changes();
|
||||
for (path, change) in changes {
|
||||
match change {
|
||||
ApplyPatchFileChange::Add { content } => {
|
||||
|
||||
Reference in New Issue
Block a user