Skip to main content

vtcode_core/tools/registry/
patch_recovery.rs

1//! One bounded fresh read after a typed patch context mismatch.
2use 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    /// Start a user/task turn without changing permissions or loop history.
18    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        // Repeat containment validation before creating an allowance. Invalid
42        // paths and symlink escapes never acquire a recovery read.
43        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    /// Check admission without consuming the read before other guards run.
88    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    /// Canonical identity used to reserve a capped read before batch execution.
93    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        // The modern handler honors limit and ignores legacy max_lines.
160        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}