perspt_agent/tools/
sandbox.rs1use super::*;
2
3pub fn create_sandbox(
13 working_dir: &Path,
14 session_id: &str,
15 branch_id: &str,
16) -> std::io::Result<PathBuf> {
17 let sandbox_root = working_dir
18 .join(".perspt")
19 .join("sandboxes")
20 .join(session_id)
21 .join(branch_id);
22
23 fs::create_dir_all(&sandbox_root)?;
24
25 log::debug!("Created sandbox workspace at {}", sandbox_root.display());
26
27 Ok(sandbox_root)
28}
29
30pub fn seed_sandbox_manifests(
36 working_dir: &Path,
37 sandbox_dir: &Path,
38 plugins: &[&str],
39) -> std::io::Result<()> {
40 let registry = perspt_core::plugin::PluginRegistry::new();
41 let mut seeded = Vec::new();
42
43 for plugin_name in plugins {
44 if let Some(plugin) = registry.get(plugin_name) {
45 for key_file in plugin.key_files() {
46 if working_dir.join(key_file).exists() {
48 copy_to_sandbox(working_dir, sandbox_dir, key_file)?;
49 seeded.push(key_file.to_string());
50 }
51 if let Ok(entries) = fs::read_dir(working_dir) {
54 for entry in entries.flatten() {
55 let path = entry.path();
56 if path.is_dir() && path.file_name().is_none_or(|n| n != ".perspt") {
57 let sub_key = path.join(key_file);
59 if sub_key.exists() {
60 let rel = sub_key
61 .strip_prefix(working_dir)
62 .unwrap_or(&sub_key)
63 .to_string_lossy()
64 .to_string();
65 let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
66 seeded.push(rel);
67 }
68 if let Ok(sub_entries) = fs::read_dir(&path) {
70 for sub_entry in sub_entries.flatten() {
71 let sub_path = sub_entry.path();
72 if sub_path.is_dir() {
73 let deep_key = sub_path.join(key_file);
74 if deep_key.exists() {
75 let rel = deep_key
76 .strip_prefix(working_dir)
77 .unwrap_or(&deep_key)
78 .to_string_lossy()
79 .to_string();
80 let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
81 seeded.push(rel);
82 }
83 }
84 }
85 }
86 }
87 }
88 }
89 }
90 }
91 }
92
93 if !seeded.is_empty() {
94 log::debug!("Seeded sandbox with manifests: {}", seeded.join(", "));
95 }
96
97 if plugins.contains(&"rust") {
101 ensure_rust_workspace_members_in_sandbox(working_dir, sandbox_dir);
102 }
103
104 if plugins.contains(&"python") {
107 seed_python_sandbox(working_dir, sandbox_dir);
108 }
109
110 Ok(())
111}
112
113fn ensure_rust_workspace_members_in_sandbox(working_dir: &Path, sandbox_dir: &Path) {
119 let cargo_toml = sandbox_dir.join("Cargo.toml");
120 let content = match fs::read_to_string(&cargo_toml) {
121 Ok(c) => c,
122 Err(_) => return,
123 };
124 let members = parse_workspace_members(&content);
125
126 for member in &members {
127 let member_dir = sandbox_dir.join(member);
128 let member_cargo = member_dir.join("Cargo.toml");
129
130 let src_cargo = working_dir.join(member).join("Cargo.toml");
132 if src_cargo.exists() && !member_cargo.exists() {
133 let _ = fs::create_dir_all(&member_dir);
134 let _ = fs::copy(&src_cargo, &member_cargo);
135 }
136
137 if !member_cargo.exists() {
139 let _ = fs::create_dir_all(&member_dir);
140 let name = member.rsplit('/').next().unwrap_or(member);
141 let stub = format!(
142 "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
143 name
144 );
145 let _ = fs::write(&member_cargo, &stub);
146 }
147
148 let src_dir = member_dir.join("src");
150 let has_lib = src_dir.join("lib.rs").exists();
151 let has_main = src_dir.join("main.rs").exists();
152 if !has_lib && !has_main {
153 let _ = fs::create_dir_all(&src_dir);
154 let ws_lib = working_dir.join(member).join("src").join("lib.rs");
156 let ws_main = working_dir.join(member).join("src").join("main.rs");
157 if ws_lib.exists() {
158 let _ = fs::copy(&ws_lib, src_dir.join("lib.rs"));
159 } else if ws_main.exists() {
160 let _ = fs::copy(&ws_main, src_dir.join("main.rs"));
161 } else {
162 let _ = fs::write(
164 src_dir.join("lib.rs"),
165 "// stub — will be replaced by agent\n",
166 );
167 }
168 }
169 }
170
171 if !members.is_empty() {
172 log::debug!(
173 "Ensured {} workspace member(s) have valid stubs in sandbox",
174 members.len()
175 );
176 }
177}
178
179fn parse_workspace_members(content: &str) -> Vec<String> {
181 let mut in_workspace = false;
182 let mut members: Vec<String> = Vec::new();
183 for raw_line in content.lines() {
184 let line = raw_line.trim();
185 if line.starts_with('[') {
186 in_workspace = line == "[workspace]";
187 continue;
188 }
189 if in_workspace && line.starts_with("members") {
190 if let Some((_, value)) = line.split_once('=') {
191 let raw = value.trim();
192 if raw.starts_with('[') {
193 let inner = raw.trim_start_matches('[').trim_end_matches(']');
194 for item in inner.split(',') {
195 let member = item.trim().trim_matches('"').trim_matches('\'');
196 if !member.is_empty() {
197 members.push(member.to_string());
198 }
199 }
200 }
201 }
202 }
203 }
204 members
205}
206
207fn seed_python_sandbox(working_dir: &Path, sandbox_dir: &Path) {
211 let workspace_venv = working_dir.join(".venv");
214 let sandbox_venv = sandbox_dir.join(".venv");
215 if workspace_venv.is_dir() && !sandbox_venv.exists() {
216 #[cfg(unix)]
217 {
218 if let Err(e) = std::os::unix::fs::symlink(&workspace_venv, &sandbox_venv) {
219 log::debug!("Could not symlink .venv into sandbox: {}", e);
220 } else {
221 log::debug!("Symlinked .venv into sandbox");
222 }
223 }
224 #[cfg(not(unix))]
225 {
226 log::debug!("Skipping .venv symlink on non-Unix platform");
229 }
230 }
231
232 for ancillary in &["README.md", "README.rst", "README", ".python-version"] {
237 let src = working_dir.join(ancillary);
238 if src.is_file() {
239 let dst = sandbox_dir.join(ancillary);
240 if !dst.exists() {
241 let _ = fs::copy(&src, &dst);
242 }
243 }
244 }
245
246 let workspace_src = working_dir.join("src");
249 if workspace_src.is_dir() {
250 if let Ok(entries) = fs::read_dir(&workspace_src) {
251 for entry in entries.flatten() {
252 let pkg_dir = entry.path();
253 if pkg_dir.is_dir() && pkg_dir.join("__init__.py").exists() {
254 if let Err(e) = copy_dir_to_sandbox(working_dir, sandbox_dir, &pkg_dir) {
256 log::debug!(
257 "Could not seed src/{} into sandbox: {}",
258 entry.file_name().to_string_lossy(),
259 e
260 );
261 }
262 }
263 }
264 }
265 }
266
267 for extra in &["conftest.py", "tests"] {
269 let src = working_dir.join(extra);
270 if src.is_file() {
271 let rel = extra.to_string();
272 let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
273 } else if src.is_dir() {
274 let _ = copy_dir_to_sandbox(working_dir, sandbox_dir, &src);
275 }
276 }
277}
278
279fn copy_dir_to_sandbox(
282 working_dir: &Path,
283 sandbox_dir: &Path,
284 src_dir: &Path,
285) -> std::io::Result<()> {
286 const SKIP: &[&str] = &[".venv", "__pycache__", ".mypy_cache", ".pytest_cache"];
287 for entry in fs::read_dir(src_dir)? {
288 let entry = entry?;
289 let path = entry.path();
290 let name = entry.file_name();
291 let name_str = name.to_string_lossy();
292
293 if path.is_dir() {
294 if SKIP.iter().any(|s| *s == &*name_str) {
295 continue;
296 }
297 copy_dir_to_sandbox(working_dir, sandbox_dir, &path)?;
298 } else if !name_str.ends_with(".pyc") {
299 if let Ok(rel) = path.strip_prefix(working_dir) {
300 let rel_str = rel.to_string_lossy().to_string();
301 copy_to_sandbox(working_dir, sandbox_dir, &rel_str)?;
302 }
303 }
304 }
305 Ok(())
306}
307
308pub fn cleanup_sandbox(sandbox_dir: &Path) -> std::io::Result<()> {
310 if sandbox_dir.exists() {
311 fs::remove_dir_all(sandbox_dir)?;
312 log::debug!("Cleaned up sandbox at {}", sandbox_dir.display());
313 }
314 Ok(())
315}
316
317pub fn cleanup_session_sandboxes(working_dir: &Path, session_id: &str) -> std::io::Result<()> {
319 let session_sandbox = working_dir
320 .join(".perspt")
321 .join("sandboxes")
322 .join(session_id);
323
324 if session_sandbox.exists() {
325 fs::remove_dir_all(&session_sandbox)?;
326 log::debug!("Cleaned up all sandboxes for session {}", session_id);
327 }
328 Ok(())
329}
330
331pub fn copy_to_sandbox(
333 working_dir: &Path,
334 sandbox_dir: &Path,
335 relative_path: &str,
336) -> std::io::Result<()> {
337 let src = working_dir.join(relative_path);
338 let dst = sandbox_dir.join(relative_path);
339
340 if let Some(parent) = dst.parent() {
341 fs::create_dir_all(parent)?;
342 }
343
344 if src.exists() {
345 fs::copy(&src, &dst)?;
346 }
347 Ok(())
348}
349
350pub fn copy_from_sandbox(
352 sandbox_dir: &Path,
353 working_dir: &Path,
354 relative_path: &str,
355) -> std::io::Result<()> {
356 let src = sandbox_dir.join(relative_path);
357 let dst = working_dir.join(relative_path);
358
359 if let Some(parent) = dst.parent() {
360 fs::create_dir_all(parent)?;
361 }
362
363 if src.exists() {
364 fs::copy(&src, &dst)?;
365 }
366 Ok(())
367}
368
369pub fn list_sandbox_files(sandbox_dir: &Path) -> std::io::Result<Vec<String>> {
371 let mut files = Vec::new();
372 if !sandbox_dir.exists() {
373 return Ok(files);
374 }
375 const SKIP_DIRS: &[&str] = &[
378 ".venv",
379 "__pycache__",
380 "node_modules",
381 ".mypy_cache",
382 ".pytest_cache",
383 ".ruff_cache",
384 ];
385 fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> std::io::Result<()> {
386 for entry in fs::read_dir(dir)? {
387 let entry = entry?;
388 let path = entry.path();
389 if path.is_dir() {
390 let name = entry.file_name();
391 let name_str = name.to_string_lossy();
392 if SKIP_DIRS.iter().any(|s| *s == &*name_str) {
393 continue;
394 }
395 walk(&path, base, out)?;
396 } else if let Ok(rel) = path.strip_prefix(base) {
397 let normalized = rel
398 .components()
399 .map(|component| component.as_os_str().to_string_lossy().into_owned())
400 .collect::<Vec<_>>()
401 .join("/");
402 if !normalized.ends_with(".pyc") {
404 out.push(normalized);
405 }
406 }
407 }
408 Ok(())
409 }
410 walk(sandbox_dir, sandbox_dir, &mut files)?;
411 Ok(files)
412}