vtcode_core/tools/handlers/
intercept_apply_patch.rs1use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10
11use super::apply_patch_handler::parse_apply_patch_command;
12use super::tool_handler::{ToolOutput, ToolSession, TurnContext};
13use super::turn_diff_tracker::SharedTurnDiffTracker;
14use crate::tools::apply_patch::decode_apply_patch_input;
15use crate::tools::editing::Patch;
16use serde_json::json;
17use vtcode_commons::paths::ensure_path_within_workspace_resolved;
18
19pub const CODEX_APPLY_PATCH_ARG: &str = "--codex-run-as-apply-patch";
21
22#[derive(Clone, Debug)]
24pub struct ApplyPatchRequest {
25 pub patch: String,
27 pub cwd: PathBuf,
29 pub timeout_ms: Option<u64>,
31 pub user_explicitly_approved: bool,
33 pub codex_exe: Option<PathBuf>,
35}
36
37impl ApplyPatchRequest {
38 pub fn new(patch: String, cwd: PathBuf) -> Self {
39 Self {
40 patch,
41 cwd,
42 timeout_ms: Some(30000),
43 user_explicitly_approved: false,
44 codex_exe: None,
45 }
46 }
47
48 pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
49 self.timeout_ms = Some(timeout_ms);
50 self
51 }
52
53 pub fn with_approval(mut self, approved: bool) -> Self {
54 self.user_explicitly_approved = approved;
55 self
56 }
57
58 pub fn with_codex_exe(mut self, exe: PathBuf) -> Self {
59 self.codex_exe = Some(exe);
60 self
61 }
62}
63
64pub fn maybe_parse_apply_patch_from_command(command: &[String]) -> Option<String> {
68 let (is_apply_patch, patch_content) = parse_apply_patch_command(command);
69 is_apply_patch.then_some(patch_content).flatten()
70}
71
72pub async fn intercept_apply_patch(
77 command: &[String],
78 cwd: &Path,
79 timeout_ms: Option<u64>,
80 session: &dyn ToolSession,
81 _turn: &TurnContext,
82 tracker: Option<&SharedTurnDiffTracker>,
83 call_id: &str,
84 tool_name: &str,
85) -> Result<Option<ToolOutput>, ApplyPatchError> {
86 let Some(patch) = maybe_parse_apply_patch_from_command(command) else {
87 return Ok(None);
88 };
89
90 let args = json!({ "input": &patch });
95 let decoded = match decode_apply_patch_input(&args).map_err(|e| ApplyPatchError::ParseError(e.to_string()))? {
96 Some(decoded) => decoded,
97 None => return Ok(None),
98 };
99 if decoded.text.is_empty() {
100 return Ok(None);
101 }
102
103 let workspace_root = session.workspace_root();
108 if let Err(reason) = ensure_path_within_workspace_resolved(cwd, workspace_root).await {
109 return Err(ApplyPatchError::ParseError(format!(
110 "intercept_apply_patch rejected cwd '{}' (workspace='{}'): {}",
111 cwd.display(),
112 workspace_root.display(),
113 reason
114 )));
115 }
116
117 let req = ApplyPatchRequest::new(decoded.text.clone(), cwd.to_path_buf()).with_timeout(timeout_ms.unwrap_or(30000));
119
120 if let Some(tracker) = tracker {
122 let mut t = tracker.write().await;
123 t.on_patch_begin(Default::default());
124 }
125
126 let fallback = crate::tools::source_inspection::SourceInspection::default();
131 let inspection = session.source_inspection().unwrap_or(&fallback);
132 let parsed = Patch::parse(&req.patch).map_err(|error| ApplyPatchError::ParseError(error.to_string()))?;
133 let _leases = if let Some(monitor) = session.edited_file_monitor() {
134 crate::tools::source_inspection::SourceInspection::acquire_patch_leases(monitor, &parsed, &req.cwd)
135 .await
136 .map_err(|error| ApplyPatchError::PatchFailed(error.to_string()))?
137 } else {
138 Vec::new()
139 };
140 let inspection_plan = inspection
141 .prepare_patch(&parsed, &req.cwd)
142 .await
143 .map_err(|error| ApplyPatchError::PatchFailed(error.to_string()))?;
144 let result = execute_patch(&req).await;
145
146 if let Some(tracker) = tracker {
148 let mut t = tracker.write().await;
149 t.on_patch_end(result.is_ok());
150 }
151
152 match result {
153 Ok(output) => {
154 inspection.commit_patch(
155 inspection_plan,
156 crate::tools::source_inspection::call_key(crate::config::constants::tools::APPLY_PATCH, &args),
157 );
158 Ok(Some(ToolOutput::simple(output)))
159 }
160 Err(e) => Ok(Some(ToolOutput::error(format!("{e} (call_id={call_id}, tool_name={tool_name})")))),
161 }
162}
163
164async fn execute_patch(req: &ApplyPatchRequest) -> Result<String, ApplyPatchError> {
165 let patch = Patch::parse(&req.patch).map_err(|e| ApplyPatchError::ParseError(e.to_string()))?;
166 if patch.is_empty() {
167 return Ok("Patch is empty, no changes applied".to_string());
168 }
169
170 let results = patch
171 .apply(&req.cwd)
172 .await
173 .map_err(|e| ApplyPatchError::PatchFailed(e.to_string()))?;
174 Ok(results.join("\n"))
175}
176
177#[derive(Debug, thiserror::Error)]
179pub enum ApplyPatchError {
180 #[error("Failed to parse patch: {0}")]
181 ParseError(String),
182
183 #[error("Patch application failed: {0}")]
184 PatchFailed(String),
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::tools::handlers::Constrained;
191 use crate::tools::handlers::adapter::DefaultToolSession;
192 use crate::tools::handlers::tool_handler::ShellEnvironmentPolicy;
193
194 fn test_turn_context(cwd: PathBuf) -> TurnContext {
195 TurnContext {
196 cwd,
197 turn_id: "test-turn".to_string(),
198 sub_id: None,
199 shell_environment_policy: ShellEnvironmentPolicy::default(),
200 approval_policy: Constrained::default(),
201 linux_sandbox_launcher: None,
202 sandbox_policy: Constrained::default(),
203 }
204 }
205
206 #[test]
207 fn maybe_parse_apply_patch_detects_direct_invocation() {
208 assert_eq!(
209 maybe_parse_apply_patch_from_command(&[
210 "apply_patch".to_string(),
211 "*** Begin Patch\n*** End Patch".to_string(),
212 ]),
213 Some("*** Begin Patch\n*** End Patch".to_string())
214 );
215 assert_eq!(
216 maybe_parse_apply_patch_from_command(&[
217 "applypatch".to_string(),
218 "*** Begin Patch\n*** End Patch".to_string(),
219 ]),
220 Some("*** Begin Patch\n*** End Patch".to_string())
221 );
222 assert_eq!(
223 maybe_parse_apply_patch_from_command(&["codex".to_string(), CODEX_APPLY_PATCH_ARG.to_string(),]),
224 None
225 );
226 assert_eq!(
227 maybe_parse_apply_patch_from_command(&["git".to_string(), "apply".to_string(), "test.patch".to_string(),]),
228 None
229 );
230 assert_eq!(maybe_parse_apply_patch_from_command(&["patch".to_string(), "-p1".to_string(),]), None);
231 assert_eq!(maybe_parse_apply_patch_from_command(&["echo".to_string(), "hello".to_string(),]), None);
232 }
233
234 #[test]
235 fn test_apply_patch_request_builder() {
236 let req = ApplyPatchRequest::new("patch content".to_string(), PathBuf::from("/tmp"))
237 .with_timeout(5000)
238 .with_approval(true)
239 .with_codex_exe(PathBuf::from("/usr/bin/codex"));
240
241 assert_eq!(req.patch, "patch content");
242 assert_eq!(req.timeout_ms, Some(5000));
243 assert!(req.user_explicitly_approved);
244 assert_eq!(req.codex_exe, Some(PathBuf::from("/usr/bin/codex")));
245 }
246
247 #[cfg(unix)]
248 #[tokio::test]
249 async fn intercept_rejects_symlink_escaped_cwd_before_outside_mutation() {
250 use std::os::unix::fs::symlink;
251
252 let workspace = tempfile::TempDir::new().expect("workspace should be created");
253 let outside = tempfile::TempDir::new().expect("outside directory should be created");
254 let escaped_cwd = workspace.path().join("escape");
255 symlink(outside.path(), &escaped_cwd).expect("workspace symlink should be created");
256
257 let session = DefaultToolSession::with_workspace(escaped_cwd.clone(), workspace.path().to_path_buf());
258 let turn = test_turn_context(escaped_cwd.clone());
259 let command = vec![
260 "apply_patch".to_string(),
261 "*** Begin Patch\n*** Add File: outside.txt\n+must not exist\n*** End Patch\n".to_string(),
262 ];
263
264 let error = intercept_apply_patch(&command, &escaped_cwd, None, &session, &turn, None, "call-1", "apply_patch")
265 .await
266 .expect_err("symlink-escaped cwd must be rejected");
267
268 assert!(matches!(error, ApplyPatchError::ParseError(_)));
269 assert!(!outside.path().join("outside.txt").exists(), "rejection must precede mutation");
270 }
271}