1use std::collections::{HashMap, HashSet};
3use std::path::{Component, Path, PathBuf};
4
5use serde_json::Value;
6
7use super::{ToolExecutionError, ToolRegistry};
8use crate::config::constants::tools;
9
10#[derive(Default)]
11pub(super) struct PatchRecoveryReads {
12 canonical_paths_by_alias: HashMap<PathBuf, PathBuf>,
13 consumed_paths: HashSet<PathBuf>,
14}
15
16impl ToolRegistry {
17 pub fn begin_patch_recovery_turn(&self) {
19 *self.patch_recovery_reads.lock() = PatchRecoveryReads::default();
20 }
21
22 fn patch_recovery_key(&self, path: impl AsRef<Path>) -> Option<PathBuf> {
23 let path = self.workspace_root().join(path);
24 let mut normalized = PathBuf::new();
25 for component in path.components() {
26 match component {
27 Component::CurDir => {}
28 Component::ParentDir => {
29 normalized.pop();
30 }
31 component => normalized.push(component.as_os_str()),
32 }
33 }
34 normalized.starts_with(self.workspace_root()).then_some(normalized)
35 }
36
37 pub(super) async fn grant_patch_recovery_read(&self, error: &ToolExecutionError) {
38 let Some(path) = error.patch_context_mismatch_path() else {
39 return;
40 };
41 let Ok(canonical_path) = self.file_ops_tool().normalize_user_path(path).await else {
44 return;
45 };
46 if let Some(key) = self.patch_recovery_key(path) {
47 let mut reads = self.patch_recovery_reads.lock();
48 if !reads.consumed_paths.contains(&canonical_path) {
49 reads.canonical_paths_by_alias.insert(key, canonical_path.clone());
50 reads.canonical_paths_by_alias.insert(canonical_path.clone(), canonical_path);
51 }
52 }
53 }
54
55 fn patch_recovery_read_key(&self, tool_name: &str, args: &Value) -> Option<PathBuf> {
56 if crate::tools::tool_intent::is_command_run_tool_call(tool_name, args) {
57 let parts = crate::tools::command_args::command_words(args).ok()??;
58 let [command, option, script, path] = parts.as_slice() else {
59 return None;
60 };
61 if command != "sed" || option != "-n" {
62 return None;
63 }
64 let range = script.strip_suffix('p')?;
65 let (start, end) = range.split_once(',').unwrap_or((range, range));
66 let start = start.parse::<u64>().ok()?;
67 let end = end.parse::<u64>().ok()?;
68 if start == 0 || end < start || end.saturating_sub(start) >= 200 {
69 return None;
70 }
71 let directory = self
72 .pty_manager()
73 .working_dir_candidate(crate::tools::command_args::working_dir_text(args))
74 .ok()?;
75 return self.patch_recovery_key(directory.join(path));
76 }
77 if tool_name != tools::READ_FILE
78 && !(tool_name == tools::UNIFIED_FILE
79 && crate::tools::tool_intent::file_operation_action(args) == Some("read"))
80 {
81 return None;
82 }
83 let path = crate::tools::file_ops::bounded_line_read_path(args, 200)?;
84 self.patch_recovery_key(path)
85 }
86
87 pub fn has_patch_recovery_read(&self, tool_name: &str, args: &Value) -> bool {
89 self.pending_patch_recovery_read_path(tool_name, args).is_some()
90 }
91
92 pub fn pending_patch_recovery_read_path(&self, tool_name: &str, args: &Value) -> Option<PathBuf> {
94 let key = self.patch_recovery_read_key(tool_name, args)?;
95 let reads = self.patch_recovery_reads.lock();
96 reads
97 .canonical_paths_by_alias
98 .get(&key)
99 .filter(|path| !reads.consumed_paths.contains(*path))
100 .cloned()
101 }
102
103 pub(super) fn consume_patch_recovery_read(&self, tool_name: &str, args: &Value) -> bool {
104 let Some(key) = self.patch_recovery_read_key(tool_name, args) else {
105 return false;
106 };
107 let mut reads = self.patch_recovery_reads.lock();
108 let Some(path) = reads.canonical_paths_by_alias.get(&key).cloned() else {
109 return false;
110 };
111 reads.consumed_paths.insert(path)
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::tools::editing::PatchError;
119 use serde_json::json;
120
121 #[tokio::test]
122 async fn patch_recovery_requires_typed_context_and_cannot_replenish_within_turn() {
123 let temp = tempfile::tempdir().unwrap();
124 let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
125 tokio::fs::write(temp.path().join("sample.txt"), "content\n").await.unwrap();
126 let read = json!({"path":"sample.txt", "limit":10});
127 for source in [
128 anyhow::anyhow!("permission denied"),
129 anyhow::Error::new(PatchError::InvalidFormat("bad".into())),
130 anyhow::Error::new(PatchError::InvalidPath {
131 operation: "update",
132 path: "../outside".into(),
133 reason: "outside workspace".into(),
134 }),
135 ] {
136 let error = ToolExecutionError::from_anyhow(tools::APPLY_PATCH, &source, 0, false, false, None);
137 registry.grant_patch_recovery_read(&error).await;
138 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &read));
139 }
140 let source = anyhow::Error::new(PatchError::ContextNotFound {
141 path: "sample.txt".into(),
142 context: "missing".into(),
143 })
144 .context("contextual wrapping");
145 let error = ToolExecutionError::from_anyhow(tools::APPLY_PATCH, &source, 0, false, false, None);
146 registry.grant_patch_recovery_read(&error).await;
147 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &json!({"path":"sample.txt"})));
148 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &json!({"path":"sample.txt", "limit":201})));
149 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &json!({"path":"other.txt", "limit":10})));
150 assert!(registry.has_patch_recovery_read(tools::EXEC_COMMAND, &json!({"cmd":"sed -n '2,4p' sample.txt"})));
151 for command in [
152 "sed -n '0,2p' sample.txt",
153 "sed -n '1,201p' sample.txt",
154 "sed -n '4,2p' sample.txt",
155 "sed -n '1,2p' sample.txt && touch ignored",
156 ] {
157 assert!(!registry.has_patch_recovery_read(tools::EXEC_COMMAND, &json!({"cmd":command})));
158 }
159 assert!(
161 registry
162 .has_patch_recovery_read(tools::READ_FILE, &json!({"path":"sample.txt", "limit":10, "max_lines":1000}))
163 );
164 assert!(registry.has_patch_recovery_read(tools::READ_FILE, &read));
165 assert!(registry.consume_patch_recovery_read(tools::READ_FILE, &read));
166 registry.grant_patch_recovery_read(&error).await;
167 assert!(!registry.consume_patch_recovery_read(tools::READ_FILE, &read));
168 registry.begin_patch_recovery_turn();
169 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &read));
170 registry.grant_patch_recovery_read(&error).await;
171 assert!(registry.consume_patch_recovery_read(tools::READ_FILE, &read));
172 }
173 #[cfg(unix)]
174 #[tokio::test]
175 async fn patch_recovery_deduplicates_symlink_aliases_and_denies_escapes() {
176 let temp = tempfile::tempdir().unwrap();
177 let outside = tempfile::tempdir().unwrap();
178 tokio::fs::write(temp.path().join("sample.txt"), "content\n").await.unwrap();
179 tokio::fs::write(outside.path().join("outside.txt"), "outside\n").await.unwrap();
180 std::os::unix::fs::symlink(temp.path().join("sample.txt"), temp.path().join("alias.txt")).unwrap();
181 std::os::unix::fs::symlink(outside.path().join("outside.txt"), temp.path().join("escape.txt")).unwrap();
182 let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
183 for path in ["sample.txt", "alias.txt", "escape.txt"] {
184 let source =
185 anyhow::Error::new(PatchError::ContextNotFound { path: path.into(), context: "missing".into() });
186 let error = ToolExecutionError::from_anyhow(tools::APPLY_PATCH, &source, 0, false, false, None);
187 registry.grant_patch_recovery_read(&error).await;
188 let read = json!({"path":path, "limit":10});
189 assert_eq!(registry.consume_patch_recovery_read(tools::READ_FILE, &read), path == "sample.txt");
190 }
191 }
192 #[tokio::test]
193 async fn patch_recovery_checks_the_effective_reader_range() {
194 let temp = tempfile::tempdir().unwrap();
195 let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
196 let content = (1..=400).map(|line| format!("fixture-{line}\n")).collect::<String>();
197 tokio::fs::write(temp.path().join("sample.txt"), content).await.unwrap();
198 let source = anyhow::Error::new(PatchError::ContextNotFound {
199 path: "sample.txt".into(),
200 context: "missing".into(),
201 });
202 let error = ToolExecutionError::from_anyhow(tools::APPLY_PATCH, &source, 0, false, false, None);
203 registry.grant_patch_recovery_read(&error).await;
204 for args in [
205 json!({"path":"sample.txt", "limit_lines":1, "condense":false}),
206 json!({"path":"sample.txt", "max_lines":1}),
207 json!({"path":"sample.txt", "chunk_lines":1}),
208 json!({"path":"sample.txt", "limit":0}),
209 json!({"path":"sample.txt", "limit":201, "l":1}),
210 json!({"path":"sample.txt", "limit":1, "offset_bytes":0, "page_size_bytes":8192}),
211 ] {
212 assert!(!registry.has_patch_recovery_read(tools::READ_FILE, &args), "{args}");
213 assert!(!registry.consume_patch_recovery_read(tools::READ_FILE, &args), "{args}");
214 }
215 let narrow = json!({"path":"sample.txt", "limit":1, "condense":false, "max_tokens":4096});
216 assert!(registry.has_patch_recovery_read(tools::READ_FILE, &narrow));
217 let output = registry.file_ops_tool().read_file(narrow).await.unwrap();
218 let text = output["content"].as_str().unwrap();
219 assert!(text.contains("fixture-1"));
220 assert!(!text.contains("fixture-2"));
221 assert!(
222 crate::tools::file_ops::bounded_line_read_path(&json!({"path":"sample.txt", "limit":1}), 100).is_none()
223 );
224 for args in [
225 json!({"path":"sample.txt", "limit":200, "condense":false}),
226 json!({"path":"sample.txt", "limit":"200", "condense":false}),
227 json!({"path":"sample.txt", "l":200, "condense":false}),
228 json!({"path":"sample.txt", "page_size_lines":200, "condense":false}),
229 json!({"path":"sample.txt", "start_line":1, "end_line":200, "condense":false}),
230 ] {
231 assert!(registry.has_patch_recovery_read(tools::READ_FILE, &args), "{args}");
232 let output = registry.file_ops_tool().read_file(args.clone()).await.unwrap();
233 let text = output["content"].as_str().unwrap();
234 assert!(text.contains("fixture-200"), "{args}: {output}");
235 assert!(!text.contains("fixture-201"), "{args}: {output}");
236 assert!(!text.contains("fixture-400"), "{args}: {output}");
237 }
238 }
239
240 #[tokio::test]
241 async fn patch_recovery_resolves_shell_targets_in_the_execution_directory() {
242 let temp = tempfile::tempdir().unwrap();
243 tokio::fs::create_dir(temp.path().join("sub")).await.unwrap();
244 tokio::fs::write(temp.path().join("sample.txt"), "root-only\n").await.unwrap();
245 tokio::fs::write(temp.path().join("sub/sample.txt"), "nested-only\n")
246 .await
247 .unwrap();
248 let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
249 registry.allow_all_tools().await.unwrap();
250 for path in ["sample.txt", "sub/sample.txt"] {
251 registry.begin_patch_recovery_turn();
252 let source =
253 anyhow::Error::new(PatchError::ContextNotFound { path: path.into(), context: "missing".into() });
254 let error = ToolExecutionError::from_anyhow(tools::APPLY_PATCH, &source, 0, false, false, None);
255 registry.grant_patch_recovery_read(&error).await;
256 for field in ["workdir", "cwd", "working_dir"] {
257 let args = json!({"cmd":"sed -n '1,2p' sample.txt", field:"sub"});
258 assert_eq!(
259 registry.has_patch_recovery_read(tools::EXEC_COMMAND, &args),
260 path == "sub/sample.txt",
261 "{path}: {args}"
262 );
263 }
264 let args = json!({"cmd":"sed -n '1,2p' sample.txt", "workdir":"sub"});
265 if path == "sample.txt" {
266 assert!(!registry.consume_patch_recovery_read(tools::EXEC_COMMAND, &args));
267 assert!(
268 registry.has_patch_recovery_read(tools::EXEC_COMMAND, &json!({"cmd":"sed -n '1,2p' sample.txt"}))
269 );
270 } else {
271 let output = registry.execute_tool(tools::EXEC_COMMAND, args.clone()).await.unwrap();
272 let text = output.to_string();
273 assert!(text.contains("nested-only"), "{output}");
274 assert!(!text.contains("root-only"), "{output}");
275 assert!(!registry.has_patch_recovery_read(tools::EXEC_COMMAND, &args));
276 }
277 }
278 }
279}