1use crate::registry::{Tool, ToolContext};
2use aegis_security::{check_command, check_path, sanitize_credentials, DangerLevel};
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::process::Stdio;
7use std::sync::Arc;
8
9fn kill_process_group(pid: Option<u32>) {
17 #[cfg(unix)]
18 if let Some(p) = pid {
19 unsafe {
21 libc::kill(-(p as i32), libc::SIGKILL);
22 }
23 }
24 #[cfg(not(unix))]
25 let _ = pid;
26}
27
28struct ChildKillGuard {
32 pid: Option<u32>,
33}
34impl ChildKillGuard {
35 fn disarm(&mut self) {
36 self.pid = None;
37 }
38}
39impl Drop for ChildKillGuard {
40 fn drop(&mut self) {
41 kill_process_group(self.pid);
42 }
43}
44
45fn is_risky_for_snapshot(cmd: &str) -> bool {
48 let l = cmd.to_lowercase();
49 const PATTERNS: &[&str] = &[
50 "mv ",
51 "dd ",
52 "truncate",
53 "mkfs",
54 "shred",
55 "git reset --hard",
56 "git clean",
57 "git checkout .",
58 "-delete",
59 ];
60 PATTERNS.iter().any(|p| l.contains(p))
61}
62
63pub struct TerminalTool;
64
65#[async_trait]
66impl Tool for TerminalTool {
67 fn name(&self) -> &str {
68 "terminal"
69 }
70 fn description(&self) -> &str {
71 "Execute a shell command and return stdout+stderr. Commands are security-checked."
72 }
73 fn parameters(&self) -> Value {
74 json!({
75 "type": "object",
76 "properties": {
77 "command": { "type": "string", "description": "Shell command to execute" },
78 "timeout": { "type": "integer", "description": "Timeout in seconds (default 300)" }
79 },
80 "required": ["command"]
81 })
82 }
83 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
84 let cmd = args["command"].as_str().unwrap_or("");
85 let timeout = args["timeout"].as_u64().unwrap_or(300);
86
87 match check_command(cmd) {
89 DangerLevel::Dangerous(reason) => {
90 if !ctx.approve(&format!("⚠️ DANGEROUS: {reason}\nCommand: {cmd}")) {
91 return Ok(format!("Command blocked: {reason}. User denied."));
92 }
93 }
94 DangerLevel::Warn(reason) => {
95 if !ctx.approve(&format!("⚠ Warning: {reason}\nCommand: {cmd}")) {
96 return Ok(format!("Command skipped: {reason}. User denied."));
97 }
98 }
99 DangerLevel::Safe => {}
100 }
101
102 if std::env::var("AEGIS_SNAPSHOT").is_ok() && is_risky_for_snapshot(cmd) {
105 if let Ok(exe) = std::env::var("AEGIS_EXE") {
106 let _ = std::process::Command::new(&exe)
107 .arg("__snapshot-cwd")
108 .arg(&ctx.session_id)
109 .arg(cmd)
110 .current_dir(&ctx.cwd)
111 .status();
112 }
113 }
114
115 let mut command = tokio::process::Command::new("sh");
116 command
117 .arg("-c")
118 .arg(cmd)
119 .current_dir(&ctx.cwd)
120 .env("AEGIS_SESSION", &ctx.session_id)
121 .stdout(Stdio::piped())
122 .stderr(Stdio::piped())
123 .kill_on_drop(true);
124 #[cfg(unix)]
127 command.process_group(0);
128
129 #[cfg(target_os = "linux")]
134 {
135 if ctx.sandbox_enabled {
136 let identity = ctx.effective_identity();
137 let policy = aegis_security::derive_sandbox_policy(&identity, "terminal", &ctx.cwd);
138 if policy.deny_all {
139 return Ok(format!(
140 "Command denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to run shell commands.",
141 identity.display(),
142 identity.trust_level(),
143 ));
144 }
145 let policy_for_hook = policy.clone();
146 unsafe {
151 command.pre_exec(move || {
152 aegis_sandbox::apply_policy_pre_exec(&policy_for_hook)
153 });
154 }
155 tracing::debug!(
156 identity = %identity.display(),
157 trust = %identity.trust_level(),
158 "terminal: applying sandbox policy"
159 );
160 }
161 }
162
163 let child = command
164 .spawn()
165 .map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?;
166 let pid = child.id();
171 let mut guard = ChildKillGuard { pid };
172 let output = match tokio::time::timeout(
173 std::time::Duration::from_secs(timeout),
174 child.wait_with_output(),
175 )
176 .await
177 {
178 Ok(r) => {
179 guard.disarm(); r.map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?
181 }
182 Err(_) => {
183 kill_process_group(pid);
185 guard.disarm();
186 return Err(anyhow::anyhow!("Command timed out after {timeout}s"));
187 }
188 };
189
190 let stdout = String::from_utf8_lossy(&output.stdout);
191 let stderr = String::from_utf8_lossy(&output.stderr);
192 let code = output.status.code().unwrap_or(-1);
193
194 let mut result = String::new();
195 if !stdout.is_empty() {
196 result.push_str(&stdout);
197 }
198 if !stderr.is_empty() {
199 if !result.is_empty() {
200 result.push('\n');
201 }
202 result.push_str("[stderr]\n");
203 result.push_str(&stderr);
204 }
205 if code != 0 {
206 result.push_str(&format!("\n[exit code: {code}]"));
207 }
208
209 if result.len() > 50_000 {
212 let lines: Vec<&str> = result.lines().collect();
213 if lines.len() > 100 {
214 let head: String = lines[..20].join("\n");
215 let tail: String = lines[lines.len() - 80..].join("\n");
216 let omitted = lines.len() - 100;
217 result = format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
218 } else {
219 result.truncate(result.floor_char_boundary(50_000));
220 result.push_str("\n... [output truncated at 50KB]");
221 }
222 }
223
224 Ok(sanitize_credentials(&result))
225 }
226}
227
228pub struct ReadFileTool;
233
234#[async_trait]
235impl Tool for ReadFileTool {
236 fn name(&self) -> &str {
237 "read_file"
238 }
239 fn description(&self) -> &str {
240 "Read file contents with line numbers. Supports offset/limit for large files."
241 }
242 fn parameters(&self) -> Value {
243 json!({
244 "type": "object",
245 "properties": {
246 "path": { "type": "string", "description": "File path (relative to CWD)" },
247 "offset": { "type": "integer", "description": "Start line (0-based, default 0)" },
248 "limit": { "type": "integer", "description": "Max lines to read (default 2000)" }
249 },
250 "required": ["path"]
251 })
252 }
253 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
254 let path_str = args["path"].as_str().unwrap_or("");
255 let offset = args["offset"].as_u64().unwrap_or(0) as usize;
256 let limit = args["limit"].as_u64().unwrap_or(2000) as usize;
257
258 let safe_path = check_path(path_str, &ctx.cwd)?;
259
260 if !safe_path.exists() {
261 if let Some(parent) = safe_path.parent() {
263 if parent.is_dir() {
264 let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
265 let suggestions: Vec<String> = std::fs::read_dir(parent)?
266 .filter_map(|e| e.ok())
267 .map(|e| e.file_name().to_string_lossy().to_string())
268 .filter(|n| strsim::jaro_winkler(n, &fname) > 0.7)
269 .take(3)
270 .collect();
271 if !suggestions.is_empty() {
272 return Ok(format!(
273 "File not found: {path_str}\nDid you mean: {}?",
274 suggestions.join(", ")
275 ));
276 }
277 }
278 }
279 anyhow::bail!("File not found: {path_str}");
280 }
281
282 let preview = std::fs::read(safe_path.as_path())?;
284 if preview.len() >= 512 && preview[..512].iter().filter(|&&b| b == 0).count() > 4 {
285 let ext = safe_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
287 match ext.as_str() {
288 "png" | "jpg" | "jpeg" | "gif" | "webp" => {
289 let data = base64::Engine::encode(
290 &base64::engine::general_purpose::STANDARD,
291 &preview,
292 );
293 let mime = match ext.as_str() {
294 "png" => "image/png",
295 "jpg" | "jpeg" => "image/jpeg",
296 "gif" => "image/gif",
297 "webp" => "image/webp",
298 _ => "image/png",
299 };
300 return Ok(format!("[IMAGE:base64:{mime}:{data}]"));
301 }
302 "pdf" => {
303 let data = base64::Engine::encode(
304 &base64::engine::general_purpose::STANDARD,
305 &preview,
306 );
307 return Ok(format!("[DOCUMENT:base64:application/pdf:{data}]"));
308 }
309 _ => {
310 return Ok(format!(
311 "Binary file ({} bytes): {path_str}. Supported formats for viewing: png, jpg, gif, webp, pdf.",
312 preview.len()
313 ));
314 }
315 }
316 }
317
318 let content = tokio::fs::read_to_string(&safe_path)
319 .await
320 .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
321
322 let lines: Vec<&str> = content.lines().collect();
323 let total = lines.len();
324 let end = (offset + limit).min(total);
325 let slice = &lines[offset.min(total)..end];
326
327 let width = format!("{}", end).len();
328 let numbered: String = slice
329 .iter()
330 .enumerate()
331 .map(|(i, line)| format!("{:>width$} │ {}\n", offset + i + 1, line, width = width))
332 .collect();
333
334 let mut result = numbered;
335 if end < total {
336 result.push_str(&format!(
337 "\n... ({} more lines, use offset={} to continue)",
338 total - end,
339 end
340 ));
341 }
342 Ok(result)
343 }
344}
345
346pub struct WriteFileTool;
351
352#[async_trait]
353impl Tool for WriteFileTool {
354 fn name(&self) -> &str {
355 "write_file"
356 }
357 fn description(&self) -> &str {
358 "Write content to a file. Creates parent directories automatically."
359 }
360 fn parameters(&self) -> Value {
361 json!({
362 "type": "object",
363 "properties": {
364 "path": { "type": "string", "description": "File path" },
365 "content": { "type": "string", "description": "File content to write" }
366 },
367 "required": ["path", "content"]
368 })
369 }
370 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
371 let path_str = args["path"].as_str().unwrap_or("");
372 let content = args["content"].as_str().unwrap_or("");
373
374 let safe_path = check_path(path_str, &ctx.cwd)?;
375
376 let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
378 let sensitive = [".env", "id_rsa", "id_ed25519", ".pem", ".key"];
379 if sensitive.iter().any(|s| fname.contains(s))
380 && !ctx.approve(&format!("Writing to sensitive file: {path_str}"))
381 {
382 return Ok("Write cancelled: sensitive file.".to_string());
383 }
384
385 if let Some(parent) = safe_path.parent() {
386 tokio::fs::create_dir_all(parent).await?;
387 }
388
389 if safe_path.exists() {
391 let _ = crate::tools::CheckpointManager::backup(&safe_path);
392 }
393
394 tokio::fs::write(&safe_path, content).await?;
395 let lines = content.lines().count();
396 Ok(format!("Wrote {} lines to {path_str}", lines))
397 }
398}
399
400pub struct PatchTool;
405
406#[async_trait]
407impl Tool for PatchTool {
408 fn name(&self) -> &str {
409 "patch"
410 }
411 fn description(&self) -> &str {
412 "Replace text in a file. Finds old_string and replaces with new_string."
413 }
414 fn parameters(&self) -> Value {
415 json!({
416 "type": "object",
417 "properties": {
418 "path": { "type": "string", "description": "File path" },
419 "old_string": { "type": "string", "description": "Text to find" },
420 "new_string": { "type": "string", "description": "Replacement text" }
421 },
422 "required": ["path", "old_string", "new_string"]
423 })
424 }
425 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
426 let path_str = args["path"].as_str().unwrap_or("");
427 let old = args["old_string"].as_str().unwrap_or("");
428 let new = args["new_string"].as_str().unwrap_or("");
429
430 let safe_path = check_path(path_str, &ctx.cwd)?;
431 let content = tokio::fs::read_to_string(&safe_path)
432 .await
433 .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
434
435 if content.contains(old) {
437 let _ = CheckpointManager::backup(&safe_path);
438 let updated = content.replacen(old, new, 1);
439 tokio::fs::write(&safe_path, &updated).await?;
440 return Ok(format!("Patched {path_str}: replaced 1 occurrence."));
441 }
442
443 let old_trimmed: Vec<&str> = old.lines().map(|l| l.trim()).collect();
445 let content_lines: Vec<&str> = content.lines().collect();
446
447 if !old_trimmed.is_empty() {
448 for start in 0..content_lines.len() {
449 let end = start + old_trimmed.len();
450 if end > content_lines.len() {
451 break;
452 }
453 let window: Vec<&str> =
454 content_lines[start..end].iter().map(|l| l.trim()).collect();
455 if window == old_trimmed {
456 let mut result_lines: Vec<String> = content_lines[..start]
457 .iter()
458 .map(|s| s.to_string())
459 .collect();
460 for line in new.lines() {
461 result_lines.push(line.to_string());
462 }
463 result_lines.extend(content_lines[end..].iter().map(|s| s.to_string()));
464 let updated = result_lines.join("\n");
465 tokio::fs::write(&safe_path, &updated).await?;
466 return Ok(format!(
467 "Patched {path_str}: fuzzy match at line {} (whitespace-insensitive).",
468 start + 1
469 ));
470 }
471 }
472 }
473
474 Ok(format!(
475 "No match found in {path_str} for the given old_string."
476 ))
477 }
478}
479
480pub struct SearchFilesTool;
485
486#[async_trait]
487impl Tool for SearchFilesTool {
488 fn name(&self) -> &str {
489 "search_files"
490 }
491 fn description(&self) -> &str {
492 "Search for text patterns in files. Uses ripgrep if available, falls back to built-in regex."
493 }
494 fn parameters(&self) -> Value {
495 json!({
496 "type": "object",
497 "properties": {
498 "pattern": { "type": "string", "description": "Regex pattern to search for" },
499 "path": { "type": "string", "description": "Directory to search in (default: CWD)" },
500 "include": { "type": "string", "description": "File glob filter, e.g. '*.rs'" },
501 "limit": { "type": "integer", "description": "Max results (default 50)" }
502 },
503 "required": ["pattern"]
504 })
505 }
506 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
507 let pattern = args["pattern"].as_str().unwrap_or("");
508 let search_path = args["path"].as_str().unwrap_or(".");
509 let include = args["include"].as_str();
510 let limit = args["limit"].as_u64().unwrap_or(50);
511
512 let dir = check_path(search_path, &ctx.cwd)?;
513
514 if which_rg() {
516 return rg_search(pattern, &dir, include, limit).await;
517 }
518
519 builtin_search(pattern, &dir, include, limit).await
521 }
522}
523
524fn which_rg() -> bool {
525 std::process::Command::new("rg")
526 .arg("--version")
527 .output()
528 .is_ok()
529}
530
531async fn rg_search(
532 pattern: &str,
533 dir: &std::path::Path,
534 include: Option<&str>,
535 limit: u64,
536) -> Result<String> {
537 let mut cmd = tokio::process::Command::new("rg");
538 cmd.arg("--line-number")
539 .arg("--no-heading")
540 .arg("--color=never")
541 .arg("--max-count")
542 .arg(limit.to_string());
543 if let Some(glob) = include {
544 cmd.arg("--glob").arg(glob);
545 }
546 cmd.arg(pattern)
547 .arg(dir)
548 .stdout(Stdio::piped())
549 .stderr(Stdio::piped());
550
551 let output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output())
552 .await
553 .map_err(|_| anyhow::anyhow!("search timed out"))??;
554
555 let result = String::from_utf8_lossy(&output.stdout);
556 if result.is_empty() {
557 Ok("No matches found.".to_string())
558 } else {
559 Ok(truncate_output(&result, 50_000))
560 }
561}
562
563async fn builtin_search(
564 pattern: &str,
565 dir: &std::path::Path,
566 include: Option<&str>,
567 limit: u64,
568) -> Result<String> {
569 let re = regex::Regex::new(pattern).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?;
570 let glob_re = include.and_then(|g| {
571 let g = g.replace('.', r"\.").replace('*', ".*").replace('?', ".");
572 regex::Regex::new(&format!("^{g}$")).ok()
573 });
574
575 let mut results = Vec::new();
576 let mut stack = vec![dir.to_path_buf()];
577
578 while let Some(d) = stack.pop() {
579 let entries = match std::fs::read_dir(&d) {
580 Ok(e) => e,
581 Err(_) => continue,
582 };
583 for entry in entries.flatten() {
584 let path = entry.path();
585 if path.is_dir() {
586 let name = path.file_name().unwrap_or_default().to_string_lossy();
587 if !name.starts_with('.') && name != "node_modules" && name != "target" {
588 stack.push(path);
589 }
590 } else if path.is_file() {
591 if let Some(ref gre) = glob_re {
592 let fname = path.file_name().unwrap_or_default().to_string_lossy();
593 if !gre.is_match(&fname) {
594 continue;
595 }
596 }
597 if let Ok(content) = std::fs::read_to_string(&path) {
598 for (i, line) in content.lines().enumerate() {
599 if re.is_match(line) {
600 let rel = path.strip_prefix(dir).unwrap_or(&path);
601 results.push(format!("{}:{}:{}", rel.display(), i + 1, line));
602 if results.len() as u64 >= limit {
603 break;
604 }
605 }
606 }
607 }
608 if results.len() as u64 >= limit {
609 break;
610 }
611 }
612 }
613 if results.len() as u64 >= limit {
614 break;
615 }
616 }
617
618 if results.is_empty() {
619 Ok("No matches found.".to_string())
620 } else {
621 Ok(results.join("\n"))
622 }
623}
624
625fn truncate_output(s: &str, max: usize) -> String {
626 if s.len() <= max {
627 return s.to_string();
628 }
629 let lines: Vec<&str> = s.lines().collect();
632 if lines.len() > 100 {
633 let head_n = 20;
634 let tail_n = 80;
635 let head: String = lines[..head_n].join("\n");
636 let tail: String = lines[lines.len() - tail_n..].join("\n");
637 let omitted = lines.len() - head_n - tail_n;
638 return format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
639 }
640 let mut t = s[..s.floor_char_boundary(max)].to_string();
642 t.push_str("\n... [truncated]");
643 t
644}
645
646pub struct CheckpointManager;
651
652impl CheckpointManager {
653 fn checkpoint_dir() -> std::path::PathBuf {
654 let dir = aegis_types::paths::config_dir().join("checkpoints");
655 let _ = std::fs::create_dir_all(&dir);
656 dir
657 }
658
659 pub fn backup(path: &std::path::Path) -> Result<()> {
661 if !path.exists() {
662 return Ok(());
663 }
664 let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
665 let cp_dir = Self::checkpoint_dir().join(&ts);
666 std::fs::create_dir_all(&cp_dir)?;
667
668 let fname = path.file_name().unwrap_or_default();
670 std::fs::copy(path, cp_dir.join(fname))?;
671
672 std::fs::write(cp_dir.join(".original_path"), path.display().to_string())?;
674
675 Self::prune_old(20)?;
677 Ok(())
678 }
679
680 pub fn list() -> Result<Vec<(String, String)>> {
682 let dir = Self::checkpoint_dir();
683 let mut entries: Vec<(String, String)> = Vec::new();
684 if let Ok(rd) = std::fs::read_dir(&dir) {
685 for entry in rd.flatten() {
686 let name = entry.file_name().to_string_lossy().to_string();
687 let orig = std::fs::read_to_string(entry.path().join(".original_path"))
688 .unwrap_or_default();
689 entries.push((name, orig));
690 }
691 }
692 entries.sort_by(|a, b| b.0.cmp(&a.0));
693 Ok(entries)
694 }
695
696 pub fn restore(checkpoint_name: &str) -> Result<String> {
698 let cp_dir = Self::checkpoint_dir().join(checkpoint_name);
699 if !cp_dir.exists() {
700 anyhow::bail!("Checkpoint not found: {checkpoint_name}");
701 }
702 let orig_path = std::fs::read_to_string(cp_dir.join(".original_path"))?;
703 for entry in std::fs::read_dir(&cp_dir)?.flatten() {
705 let fname = entry.file_name().to_string_lossy().to_string();
706 if fname == ".original_path" {
707 continue;
708 }
709 std::fs::copy(entry.path(), &orig_path)?;
710 return Ok(format!(
711 "Restored {} from checkpoint {checkpoint_name}",
712 orig_path
713 ));
714 }
715 anyhow::bail!("No file found in checkpoint {checkpoint_name}")
716 }
717
718 fn prune_old(max: usize) -> Result<()> {
719 let dir = Self::checkpoint_dir();
720 let mut entries: Vec<_> = std::fs::read_dir(&dir)?
721 .flatten()
722 .filter(|e| e.path().is_dir())
723 .collect();
724 entries.sort_by_key(|e| e.file_name());
725 while entries.len() > max {
726 if let Some(oldest) = entries.first() {
727 let _ = std::fs::remove_dir_all(oldest.path());
728 }
729 entries.remove(0);
730 }
731 Ok(())
732 }
733}
734
735pub fn todo_path(session_id: &str) -> std::path::PathBuf {
741 aegis_types::paths::config_dir()
742 .join("todos")
743 .join(format!("{session_id}.txt"))
744}
745
746pub fn read_todo_progress(session_id: &str) -> Option<(usize, usize, String)> {
750 let content = std::fs::read_to_string(todo_path(session_id)).ok()?;
751 let mut total = 0usize;
752 let mut done = 0usize;
753 let mut current = String::new();
754 for line in content.lines() {
755 if line.trim().is_empty() {
756 continue;
757 }
758 total += 1;
759 if line.starts_with("[x] ") {
760 done += 1;
761 } else if current.is_empty() {
762 current = line.strip_prefix("[ ] ").unwrap_or(line).to_string();
763 }
764 }
765 if total == 0 {
766 None
767 } else {
768 Some((done, total, current))
769 }
770}
771
772pub struct TodoTool;
773
774#[async_trait]
775impl Tool for TodoTool {
776 fn name(&self) -> &str {
777 "todo"
778 }
779 fn description(&self) -> &str {
780 "Track a multi-step task as a checklist for this session, shown to the user \
781 as a live progress bar. Use it to plan and drive long tasks: add each step \
782 up front, then mark steps complete as you finish them. If an earlier step \
783 turns out wrong, use `rollback` to reopen it and everything after it (the \
784 progress bar moves back). Actions: add (task), complete (index, 1-based), \
785 reopen (index), rollback (index = first step to redo), list."
786 }
787 fn parameters(&self) -> Value {
788 json!({
789 "type": "object",
790 "properties": {
791 "action": { "type": "string", "enum": ["add", "complete", "reopen", "rollback", "list"], "description": "Action to perform" },
792 "task": { "type": "string", "description": "Task description (for add)" },
793 "index": { "type": "integer", "description": "1-based task index. complete/reopen: that task; rollback: that task and all after it are reopened" }
794 },
795 "required": ["action"]
796 })
797 }
798 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
799 let todo_path = todo_path(&ctx.session_id);
801 let _ = std::fs::create_dir_all(todo_path.parent().expect("todo path has parent"));
802
803 let mut tasks: Vec<(bool, String)> = if todo_path.exists() {
804 std::fs::read_to_string(&todo_path)?
805 .lines()
806 .map(|l| {
807 if let Some(rest) = l.strip_prefix("[x] ") {
808 (true, rest.to_string())
809 } else if let Some(rest) = l.strip_prefix("[ ] ") {
810 (false, rest.to_string())
811 } else {
812 (false, l.to_string())
813 }
814 })
815 .collect()
816 } else {
817 Vec::new()
818 };
819
820 match args["action"].as_str().unwrap_or("list") {
821 "add" => {
822 let task = args["task"].as_str().unwrap_or("(no description)");
823 tasks.push((false, task.to_string()));
824 save_todos(&todo_path, &tasks)?;
825 Ok(format!("Added task #{}: {task}", tasks.len()))
826 }
827 "complete" => {
828 let idx = args["index"].as_u64().unwrap_or(0) as usize;
829 if idx == 0 || idx > tasks.len() {
830 return Ok("Invalid task index.".to_string());
831 }
832 tasks[idx - 1].0 = true;
833 save_todos(&todo_path, &tasks)?;
834 Ok(format!("Completed task #{}: {}", idx, tasks[idx - 1].1))
835 }
836 "reopen" => {
837 let idx = args["index"].as_u64().unwrap_or(0) as usize;
838 if idx == 0 || idx > tasks.len() {
839 return Ok("Invalid task index.".to_string());
840 }
841 tasks[idx - 1].0 = false;
842 save_todos(&todo_path, &tasks)?;
843 Ok(format!("Reopened task #{}: {}", idx, tasks[idx - 1].1))
844 }
845 "rollback" => {
846 let idx = args["index"].as_u64().unwrap_or(0) as usize;
849 if idx == 0 || idx > tasks.len() {
850 return Ok("Invalid task index.".to_string());
851 }
852 for t in tasks.iter_mut().skip(idx - 1) {
853 t.0 = false;
854 }
855 save_todos(&todo_path, &tasks)?;
856 let done = tasks.iter().filter(|(d, _)| *d).count();
857 Ok(format!(
858 "Rolled back to task #{} ({}). Progress now {}/{}; tasks #{}–#{} reopened.",
859 idx,
860 tasks[idx - 1].1,
861 done,
862 tasks.len(),
863 idx,
864 tasks.len()
865 ))
866 }
867 _ => {
868 if tasks.is_empty() {
869 return Ok("No tasks.".to_string());
870 }
871 let list: String = tasks
872 .iter()
873 .enumerate()
874 .map(|(i, (done, desc))| {
875 let mark = if *done { "x" } else { " " };
876 format!(" {}. [{}] {}", i + 1, mark, desc)
877 })
878 .collect::<Vec<_>>()
879 .join("\n");
880 Ok(list)
881 }
882 }
883 }
884}
885
886fn save_todos(path: &std::path::Path, tasks: &[(bool, String)]) -> Result<()> {
887 let content: String = tasks
888 .iter()
889 .map(|(done, desc)| {
890 if *done {
891 format!("[x] {desc}")
892 } else {
893 format!("[ ] {desc}")
894 }
895 })
896 .collect::<Vec<_>>()
897 .join("\n");
898 std::fs::write(path, content)?;
899 Ok(())
900}
901
902struct BgTask {
907 child: tokio::process::Child,
908 log: std::path::PathBuf,
909 cmd: String,
910 started: std::time::Instant,
911}
912
913pub struct BackgroundTool {
917 dir: std::path::PathBuf,
918 tasks: Arc<std::sync::Mutex<std::collections::HashMap<String, BgTask>>>,
919 backend: BgBackend,
920}
921
922impl BackgroundTool {
923 pub fn new() -> Self {
925 let dir = aegis_types::paths::config_dir().join("bg");
926 Self {
927 dir,
928 tasks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
929 backend: BgBackend::Auto,
930 }
931 }
932
933 pub fn with_backend(mut self, backend: BgBackend) -> Self {
935 self.backend = backend;
936 self
937 }
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum BgBackend {
945 Auto,
946 Tmux,
947 Child,
948}
949
950impl BgBackend {
951 pub fn from_config(s: &str) -> Self {
953 match s.trim().to_ascii_lowercase().as_str() {
954 "tmux" => BgBackend::Tmux,
955 "child" => BgBackend::Child,
956 _ => BgBackend::Auto,
957 }
958 }
959
960 fn effective(self) -> BgBackend {
962 match self {
963 BgBackend::Auto => {
964 if tmux_available() {
965 BgBackend::Tmux
966 } else {
967 BgBackend::Child
968 }
969 }
970 other => other,
971 }
972 }
973}
974
975fn tmux_available() -> bool {
977 std::process::Command::new("tmux")
978 .arg("-V")
979 .stdout(Stdio::null())
980 .stderr(Stdio::null())
981 .status()
982 .map(|s| s.success())
983 .unwrap_or(false)
984}
985
986fn tmux_session_name(id: &str) -> String {
989 let safe: String = id
990 .chars()
991 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
992 .collect();
993 format!("aegis-{safe}")
994}
995
996fn posix_squote(s: &str) -> String {
998 format!("'{}'", s.replace('\'', "'\\''"))
999}
1000
1001impl Default for BackgroundTool {
1002 fn default() -> Self {
1003 Self::new()
1004 }
1005}
1006
1007fn tail_file(path: &std::path::Path, lines: usize) -> String {
1009 use std::io::{Read, Seek, SeekFrom};
1010 let mut f = match std::fs::File::open(path) {
1011 Ok(f) => f,
1012 Err(_) => return String::new(),
1013 };
1014 let len = f.metadata().map(|m| m.len()).unwrap_or(0);
1015 const CAP: u64 = 64 * 1024;
1016 let _ = f.seek(SeekFrom::Start(len.saturating_sub(CAP)));
1017 let mut raw = Vec::new();
1018 let _ = f.read_to_end(&mut raw);
1019 let text = String::from_utf8_lossy(&raw);
1020 let all: Vec<&str> = text.lines().collect();
1021 let start = all.len().saturating_sub(lines);
1022 all[start..].join("\n")
1023}
1024
1025fn write_bg_meta(dir: &std::path::Path, id: &str, pid: u32, cmd: &str) {
1029 write_bg_meta_full(dir, id, pid, cmd, "child", "");
1030}
1031
1032fn write_bg_meta_full(dir: &std::path::Path, id: &str, pid: u32, cmd: &str, backend: &str, session: &str) {
1033 let meta = json!({ "pid": pid, "cmd": cmd, "backend": backend, "session": session, "started": now_epoch() });
1034 let _ = std::fs::write(dir.join(format!("{id}.meta.json")), meta.to_string());
1035}
1036
1037fn now_epoch() -> u64 {
1039 std::time::SystemTime::now()
1040 .duration_since(std::time::UNIX_EPOCH)
1041 .map(|d| d.as_secs())
1042 .unwrap_or(0)
1043}
1044
1045fn read_bg_started(dir: &std::path::Path, id: &str) -> Option<u64> {
1047 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1048 let v: Value = serde_json::from_str(&content).ok()?;
1049 v["started"].as_u64().filter(|s| *s > 0)
1050}
1051
1052fn human_dur(secs: u64) -> String {
1054 if secs < 60 {
1055 format!("{secs}s")
1056 } else if secs < 3600 {
1057 format!("{}m{}s", secs / 60, secs % 60)
1058 } else {
1059 format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
1060 }
1061}
1062
1063fn bg_elapsed_suffix(dir: &std::path::Path, id: &str) -> String {
1065 match read_bg_started(dir, id) {
1066 Some(started) => format!(" (ran {})", human_dur(now_epoch().saturating_sub(started))),
1067 None => String::new(),
1068 }
1069}
1070
1071fn read_bg_meta(dir: &std::path::Path, id: &str) -> Option<(u32, String)> {
1073 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1074 let v: Value = serde_json::from_str(&content).ok()?;
1075 let pid = v["pid"].as_u64()? as u32;
1076 let cmd = v["cmd"].as_str().unwrap_or("").to_string();
1077 Some((pid, cmd))
1078}
1079
1080fn read_bg_session(dir: &std::path::Path, id: &str) -> Option<String> {
1082 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1083 let v: Value = serde_json::from_str(&content).ok()?;
1084 if v["backend"].as_str() == Some("tmux") {
1085 v["session"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string())
1086 } else {
1087 None
1088 }
1089}
1090
1091fn tmux_session_alive(session: &str) -> bool {
1093 std::process::Command::new("tmux")
1094 .args(["has-session", "-t", session])
1095 .stdout(Stdio::null())
1096 .stderr(Stdio::null())
1097 .status()
1098 .map(|s| s.success())
1099 .unwrap_or(false)
1100}
1101
1102fn list_bg_meta(dir: &std::path::Path) -> Vec<(String, u32, String)> {
1104 let mut out = Vec::new();
1105 if let Ok(entries) = std::fs::read_dir(dir) {
1106 for e in entries.flatten() {
1107 if let Some(fname) = e.file_name().to_str() {
1108 if let Some(id) = fname.strip_suffix(".meta.json") {
1109 if let Some((pid, cmd)) = read_bg_meta(dir, id) {
1110 out.push((id.to_string(), pid, cmd));
1111 }
1112 }
1113 }
1114 }
1115 }
1116 out
1117}
1118
1119fn pid_alive(pid: u32) -> bool {
1121 pid != 0 && std::path::Path::new(&format!("/proc/{pid}")).exists()
1122}
1123
1124#[async_trait]
1125impl Tool for BackgroundTool {
1126 fn name(&self) -> &str {
1127 "background"
1128 }
1129 fn description(&self) -> &str {
1130 "Run and supervise long-running processes WITHOUT blocking. Use this for \
1131 anything that takes minutes/hours (builds, training, servers, data \
1132 pipelines): start the command in the background, then keep working and \
1133 periodically check it with status/logs, and kill it when done. Far better \
1134 than a blocking `terminal` call for long jobs. Jobs survive an aegis \
1135 restart — status/logs/kill still work in a later session (great for \
1136 resume/watchdog). Actions: start (command, optional id), status (id), \
1137 logs (id, lines), kill (id), list."
1138 }
1139 fn parameters(&self) -> Value {
1140 json!({
1141 "type": "object",
1142 "properties": {
1143 "action": { "type": "string", "enum": ["start", "status", "logs", "kill", "list"] },
1144 "command": { "type": "string", "description": "Shell command to run (action=start)" },
1145 "id": { "type": "string", "description": "Task id for status/logs/kill (auto-generated on start if omitted)" },
1146 "lines": { "type": "integer", "description": "Tail this many log lines (action=logs, default 40)" }
1147 },
1148 "required": ["action"]
1149 })
1150 }
1151 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
1152 match args["action"].as_str().unwrap_or("list") {
1153 "start" => {
1154 let cmd = args["command"].as_str().unwrap_or("").trim().to_string();
1155 if cmd.is_empty() {
1156 return Ok("Error: 'command' is required for action=start".to_string());
1157 }
1158 match check_command(&cmd) {
1159 DangerLevel::Dangerous(reason) => {
1160 if !ctx.approve(&format!("⚠️ DANGEROUS (background): {reason}\nCommand: {cmd}")) {
1161 return Ok(format!("Command blocked: {reason}. User denied."));
1162 }
1163 }
1164 DangerLevel::Warn(reason) => {
1165 if !ctx.approve(&format!("⚠ Warning (background): {reason}\nCommand: {cmd}")) {
1166 return Ok(format!("Command skipped: {reason}. User denied."));
1167 }
1168 }
1169 DangerLevel::Safe => {}
1170 }
1171 std::fs::create_dir_all(&self.dir).ok();
1172 let id = args["id"].as_str().map(|s| s.to_string()).unwrap_or_else(|| {
1173 let nanos = std::time::SystemTime::now()
1174 .duration_since(std::time::UNIX_EPOCH)
1175 .map(|d| d.as_nanos() as u64)
1176 .unwrap_or(0);
1177 format!("bg-{:06x}", nanos & 0xff_ffff)
1178 });
1179 let log = self.dir.join(format!("{id}.log"));
1180
1181 if self.backend.effective() == BgBackend::Tmux {
1186 if !tmux_available() {
1187 return Ok("Error: background_backend=tmux but tmux is not installed. Install tmux or set [tools] background_backend = \"child\".".to_string());
1188 }
1189 let session = tmux_session_name(&id);
1190 if tmux_session_alive(&session) {
1191 return Ok(format!("Error: tmux session '{session}' already exists (task id '{id}' in use)."));
1192 }
1193 let inner = format!("{cmd} 2>&1 | tee {}", posix_squote(&log.display().to_string()));
1194 let started = std::process::Command::new("tmux")
1195 .args(["new-session", "-d", "-s", &session, "-c"])
1196 .arg(&ctx.cwd)
1197 .arg("sh")
1198 .arg("-c")
1199 .arg(&inner)
1200 .status()
1201 .map(|s| s.success())
1202 .unwrap_or(false);
1203 if !started {
1204 return Ok(format!("Error: failed to start tmux session '{session}' for task '{id}'."));
1205 }
1206 let _ = std::process::Command::new("tmux")
1209 .args(["set-option", "-t", &session, "remain-on-exit", "on"])
1210 .status();
1211 write_bg_meta_full(&self.dir, &id, 0, &cmd, "tmux", &session);
1212 return Ok(format!(
1213 "Started background task '{id}' in tmux session '{session}'.\n\
1214 Attach (watch / type commands live): tmux attach -t {session}\n\
1215 Logs: {}\n\
1216 Check: background action=status id={id} • kill: background action=kill id={id}",
1217 log.display()
1218 ));
1219 }
1220
1221 let file = std::fs::File::create(&log)
1222 .map_err(|e| anyhow::anyhow!("create log file: {e}"))?;
1223 let file2 = file.try_clone().map_err(|e| anyhow::anyhow!("clone log file: {e}"))?;
1224 let child = tokio::process::Command::new("sh")
1225 .arg("-c")
1226 .arg(&cmd)
1227 .current_dir(&ctx.cwd)
1228 .stdout(Stdio::from(file))
1229 .stderr(Stdio::from(file2))
1230 .spawn()
1231 .map_err(|e| anyhow::anyhow!("spawn failed: {e}"))?;
1232 let pid = child.id().unwrap_or(0);
1233 if let Ok(mut tasks) = self.tasks.lock() {
1234 tasks.insert(
1235 id.clone(),
1236 BgTask {
1237 child,
1238 log: log.clone(),
1239 cmd: cmd.clone(),
1240 started: std::time::Instant::now(),
1241 },
1242 );
1243 }
1244 write_bg_meta(&self.dir, &id, pid, &cmd);
1247 Ok(format!(
1248 "Started background task '{id}' (pid {pid}).\nLogs: {}\nCheck with: background action=status id={id} • background action=logs id={id}",
1249 log.display()
1250 ))
1251 }
1252 "status" => {
1253 let id = args["id"].as_str().unwrap_or("");
1254 {
1256 let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1257 if let Some(task) = tasks.get_mut(id) {
1258 let elapsed = task.started.elapsed().as_secs();
1259 return Ok(match task.child.try_wait() {
1260 Ok(Some(st)) => format!(
1261 "Task '{id}' EXITED (code {}) after {elapsed}s.\ncmd: {}",
1262 st.code().unwrap_or(-1),
1263 task.cmd
1264 ),
1265 Ok(None) => format!("Task '{id}' RUNNING ({elapsed}s).\ncmd: {}", task.cmd),
1266 Err(e) => format!("Task '{id}' status error: {e}"),
1267 });
1268 }
1269 }
1270 if let Some(session) = read_bg_session(&self.dir, id) {
1274 let state = if tmux_session_alive(&session) { "RUNNING" } else { "EXITED/gone" };
1275 let cmd = read_bg_meta(&self.dir, id).map(|(_, c)| c).unwrap_or_default();
1276 let elapsed = bg_elapsed_suffix(&self.dir, id);
1277 return Ok(format!(
1278 "Task '{id}' {state}{elapsed} (tmux session '{session}').\nAttach: tmux attach -t {session}\ncmd: {cmd}"
1279 ));
1280 }
1281 match read_bg_meta(&self.dir, id) {
1282 Some((pid, cmd)) => {
1283 let state = if pid_alive(pid) { "RUNNING" } else { "EXITED" };
1284 let elapsed = bg_elapsed_suffix(&self.dir, id);
1285 Ok(format!("Task '{id}' {state}{elapsed} (pid {pid}, from earlier session).\ncmd: {cmd}"))
1286 }
1287 None => Ok(format!("No background task '{id}'. Use action=list.")),
1288 }
1289 }
1290 "logs" => {
1291 let id = args["id"].as_str().unwrap_or("");
1292 let lines = args["lines"].as_u64().unwrap_or(40) as usize;
1293 let log = {
1295 let tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1296 tasks.get(id).map(|t| t.log.clone())
1297 }
1298 .unwrap_or_else(|| self.dir.join(format!("{id}.log")));
1299 if !log.exists() {
1300 return Ok(format!("No background task '{id}'. Use action=list."));
1301 }
1302 let out = tail_file(&log, lines);
1303 if out.is_empty() {
1304 Ok(format!("(no output yet for '{id}')"))
1305 } else {
1306 Ok(format!("--- {id} (last {lines} lines) ---\n{out}"))
1307 }
1308 }
1309 "kill" => {
1310 let id = args["id"].as_str().unwrap_or("");
1311 {
1313 let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1314 if let Some(mut task) = tasks.remove(id) {
1315 let _ = task.child.start_kill();
1316 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1317 return Ok(format!("Killed background task '{id}'."));
1318 }
1319 }
1320 if let Some(session) = read_bg_session(&self.dir, id) {
1322 let _ = std::process::Command::new("tmux")
1323 .args(["kill-session", "-t", &session])
1324 .status();
1325 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1326 return Ok(format!("Killed background task '{id}' (tmux session '{session}')."));
1327 }
1328 match read_bg_meta(&self.dir, id) {
1329 Some((pid, _)) => {
1330 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1331 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1332 Ok(format!("Sent kill to background task '{id}' (pid {pid})."))
1333 }
1334 None => Ok(format!("No background task '{id}'.")),
1335 }
1336 }
1337 _ => {
1338 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1340 let mut out = String::from("Background tasks:\n");
1341 if let Ok(tasks) = self.tasks.lock() {
1342 for (id, task) in tasks.iter() {
1343 seen.insert(id.clone());
1344 out.push_str(&format!(
1345 " {id}: {}s (this session) {}\n",
1346 task.started.elapsed().as_secs(),
1347 task.cmd
1348 ));
1349 }
1350 }
1351 for (id, pid, cmd) in list_bg_meta(&self.dir) {
1352 if seen.contains(&id) {
1353 continue;
1354 }
1355 if let Some(session) = read_bg_session(&self.dir, &id) {
1356 let state = if tmux_session_alive(&session) { "RUNNING" } else { "exited" };
1357 out.push_str(&format!(" {id}: {state} (tmux {session}) {cmd}\n"));
1358 } else {
1359 let state = if pid_alive(pid) { "RUNNING" } else { "exited" };
1360 out.push_str(&format!(" {id}: {state} (pid {pid}) {cmd}\n"));
1361 }
1362 }
1363 if out == "Background tasks:\n" {
1364 return Ok("No background tasks.".to_string());
1365 }
1366 Ok(out)
1367 }
1368 }
1369 }
1370}
1371
1372pub struct ClarifyTool;
1377
1378#[async_trait]
1379impl Tool for ClarifyTool {
1380 fn name(&self) -> &str {
1381 "clarify"
1382 }
1383 fn description(&self) -> &str {
1384 "Ask the user a question and let them pick an answer. ALWAYS use this tool \
1385 (with concrete `options`) for any decision point — including confirming or \
1386 adjusting a plan, choosing a direction, or yes/no approvals — instead of \
1387 writing the choices in your prose reply and waiting for them to type. The \
1388 user picks with arrow keys; a 'manual input' choice is always added. Ask \
1389 several questions at once via `questions` (user switches with ←/→)."
1390 }
1391 fn parameters(&self) -> Value {
1392 json!({
1393 "type": "object",
1394 "properties": {
1395 "question": { "type": "string", "description": "The question to ask the user" },
1396 "options": {
1397 "type": "array",
1398 "items": { "type": "string" },
1399 "description": "Optional preset answers the user can choose from"
1400 },
1401 "questions": {
1402 "type": "array",
1403 "description": "Ask multiple questions at once; user switches with ←/→",
1404 "items": {
1405 "type": "object",
1406 "properties": {
1407 "question": { "type": "string" },
1408 "options": { "type": "array", "items": { "type": "string" } }
1409 },
1410 "required": ["question"]
1411 }
1412 }
1413 }
1414 })
1415 }
1416 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1417 let question = args["question"].as_str().unwrap_or("Could you clarify?");
1418 eprintln!("\n❓ {question}");
1419 eprint!("Your answer: ");
1420 let _ = std::io::Write::flush(&mut std::io::stderr());
1421 let mut answer = String::new();
1422 std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut answer)?;
1423 Ok(format!("User answered: {}", answer.trim()))
1424 }
1425}
1426
1427pub struct SessionSearchTool;
1432
1433#[async_trait]
1434impl Tool for SessionSearchTool {
1435 fn name(&self) -> &str {
1436 "session_search"
1437 }
1438 fn description(&self) -> &str {
1439 "Search past conversation sessions for relevant information using full-text search."
1440 }
1441 fn parameters(&self) -> Value {
1442 json!({
1443 "type": "object",
1444 "properties": {
1445 "query": { "type": "string", "description": "Search query" },
1446 "limit": { "type": "integer", "description": "Max results (default 5)" }
1447 },
1448 "required": ["query"]
1449 })
1450 }
1451 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1452 let query = args["query"].as_str().unwrap_or("");
1453 let limit = args["limit"].as_u64().unwrap_or(5) as u32;
1454
1455 let db_path = aegis_types::paths::config_dir().join("sessions.db");
1456 if !db_path.exists() {
1457 return Ok("No session history available.".to_string());
1458 }
1459
1460 let conn = rusqlite::Connection::open(&db_path)?;
1461 let sanitized = query
1462 .split_whitespace()
1463 .map(|w| {
1464 let c: String = w
1465 .chars()
1466 .filter(|c| c.is_alphanumeric() || *c == '_')
1467 .collect();
1468 format!("\"{c}\"")
1469 })
1470 .filter(|s| s != "\"\"")
1471 .collect::<Vec<_>>()
1472 .join(" ");
1473 if sanitized.is_empty() {
1474 return Ok("Empty query.".to_string());
1475 }
1476
1477 let mut stmt = conn.prepare(
1478 "SELECT m.session_id, m.role, snippet(messages_fts, 0, '>>>', '<<<', '...', 32)
1479 FROM messages_fts f JOIN messages m ON m.id = f.rowid
1480 WHERE messages_fts MATCH ?1 ORDER BY rank LIMIT ?2",
1481 )?;
1482 let results: Vec<String> = stmt
1483 .query_map(rusqlite::params![sanitized, limit], |row| {
1484 let sid: String = row.get(0)?;
1485 let role: String = row.get(1)?;
1486 let snip: String = row.get(2)?;
1487 Ok(format!(
1488 "[{}] ({}) {}",
1489 &sid[..sid.len().min(15)],
1490 role,
1491 snip
1492 ))
1493 })?
1494 .filter_map(|r| r.ok())
1495 .collect();
1496
1497 if results.is_empty() {
1498 Ok("No results found.".to_string())
1499 } else {
1500 Ok(results.join("\n"))
1501 }
1502 }
1503}
1504
1505pub struct SpawnTaskTool {
1510 semaphore: Arc<tokio::sync::Semaphore>,
1511}
1512
1513impl SpawnTaskTool {
1514 pub fn new(max_concurrent: usize) -> Self {
1516 Self {
1517 semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent)),
1518 }
1519 }
1520}
1521
1522#[async_trait]
1523impl Tool for SpawnTaskTool {
1524 fn name(&self) -> &str {
1525 "spawn_task"
1526 }
1527 fn description(&self) -> &str {
1528 "Spawn a sub-agent to handle a task independently. Supports parallel execution. Set isolate=true for file-modifying tasks: each runs in its own git worktree+branch (no clobbering the main tree or sibling tasks; changes land on a branch for review)."
1529 }
1530 fn parameters(&self) -> Value {
1531 json!({
1532 "type": "object",
1533 "properties": {
1534 "prompt": { "type": "string", "description": "Task description for the worker" },
1535 "max_turns": { "type": "integer", "description": "Max iterations (default 20)" },
1536 "isolate": { "type": "boolean", "description": "Run each sub-task in an isolated git worktree+branch (recommended when tasks modify files). Default false." },
1537 "tasks": {
1538 "type": "array",
1539 "items": { "type": "object", "properties": {
1540 "prompt": { "type": "string" }
1541 }},
1542 "description": "Multiple tasks to run in parallel"
1543 },
1544 "depends_on": {
1545 "type": "array",
1546 "items": { "type": "string" },
1547 "description": "List of task IDs that must complete before this task starts."
1548 },
1549 "timeout_secs": {
1550 "type": "integer",
1551 "description": "Timeout in seconds when waiting for depends_on tasks (default 300)"
1552 }
1553 },
1554 "required": []
1555 })
1556 }
1557 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1558 let depends_on: Vec<String> = args["depends_on"]
1560 .as_array()
1561 .map(|arr| {
1562 arr.iter()
1563 .filter_map(|v| v.as_str().map(String::from))
1564 .collect()
1565 })
1566 .unwrap_or_default();
1567
1568 if !depends_on.is_empty() {
1569 let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(300);
1570 wait_for_tasks(depends_on, timeout_secs).await?;
1571 }
1572
1573 let tasks: Vec<String> = if let Some(arr) = args["tasks"].as_array() {
1575 arr.iter()
1576 .filter_map(|t| t["prompt"].as_str().map(String::from))
1577 .collect()
1578 } else if let Some(p) = args["prompt"].as_str() {
1579 vec![p.to_string()]
1580 } else {
1581 return Ok("No prompt provided.".to_string());
1582 };
1583
1584 let max_turns = args["max_turns"].as_u64().unwrap_or(20);
1585 let isolate = args["isolate"].as_bool().unwrap_or(false);
1589 let repo = _ctx.cwd.clone();
1590 let mut results = Vec::new();
1591
1592 let worker_bin = find_worker_binary();
1594
1595 let mut handles = Vec::new();
1596 for (i, prompt) in tasks.iter().enumerate() {
1597 let permit = self
1598 .semaphore
1599 .clone()
1600 .acquire_owned()
1601 .await
1602 .map_err(|e| anyhow::anyhow!("semaphore: {e}"))?;
1603 let bin = worker_bin.clone();
1604 let prompt = prompt.clone();
1605 let worktree = if isolate {
1606 create_worktree(&repo, &format!("{}-{}", std::process::id(), i))
1607 } else {
1608 None
1609 };
1610 let cwd = worktree.as_ref().map(|(p, _)| p.clone());
1611 let branch = worktree.as_ref().map(|(_, b)| b.clone());
1612
1613 let handle = tokio::spawn(async move {
1614 let _permit = permit;
1615 let result = run_worker(&bin, &prompt, max_turns, cwd).await;
1616 (i, result, branch)
1617 });
1618 handles.push(handle);
1619 }
1620
1621 for handle in handles {
1622 let (i, result, branch) = handle.await.map_err(|e| anyhow::anyhow!("join: {e}"))?;
1623 let loc = branch
1624 .map(|b| format!(" [isolated → branch `{b}`; review/merge it]"))
1625 .unwrap_or_default();
1626 match result {
1627 Ok(text) => results.push(format!("[Worker {}] ✅{}\n{}", i + 1, loc, text)),
1628 Err(e) => results.push(format!("[Worker {}] ❌{} {}", i + 1, loc, e)),
1629 }
1630 }
1631
1632 if tasks.len() > 1 {
1634 let successful: Vec<&String> = results.iter()
1635 .filter(|r| r.contains('✅'))
1636 .collect();
1637 if successful.len() >= 2 {
1638 let strategies_dir = dirs_next::home_dir()
1639 .unwrap_or_default()
1640 .join(".aegis/strategies");
1641 let _ = std::fs::create_dir_all(&strategies_dir);
1642
1643 if let Ok(entries) = std::fs::read_dir(&strategies_dir) {
1645 let mut worker_files: Vec<std::path::PathBuf> = entries
1646 .filter_map(|e| e.ok())
1647 .map(|e| e.path())
1648 .filter(|p| {
1649 p.extension().is_some_and(|ext| ext == "md")
1650 && p.file_name().is_some_and(|n| n.to_string_lossy().contains("worker"))
1651 })
1652 .collect();
1653 if worker_files.len() > 50 {
1654 worker_files.sort_by_key(|p| {
1655 std::fs::metadata(p).and_then(|m| m.modified()).ok()
1656 });
1657 for old in worker_files.iter().take(10) {
1658 let _ = std::fs::remove_file(old);
1659 }
1660 }
1661 }
1662
1663 let ts = std::time::SystemTime::now()
1664 .duration_since(std::time::UNIX_EPOCH)
1665 .unwrap_or_default()
1666 .as_secs();
1667 for (i, result) in successful.iter().enumerate() {
1668 let body = if let Some(pos) = result.find('✅') {
1670 let after = &result[pos + '✅'.len_utf8()..];
1671 after.trim_start_matches('\n')
1672 } else {
1673 result.as_str()
1674 };
1675
1676 if body.len() <= 100 {
1678 continue;
1679 }
1680
1681 let hash_input = &body[..body.floor_char_boundary(200)];
1683 let hash: u64 = hash_input.bytes().map(|b| b as u64).sum::<u64>() % 100000;
1684 let hash_filename = format!("strat-hash-{hash}.md");
1685 if strategies_dir.join(&hash_filename).exists() {
1686 continue;
1687 }
1688
1689 let summary = &body[..body.floor_char_boundary(300)];
1690 let id = format!("strat-worker-{ts}-{i}");
1691 let content = format!(
1692 "---\nid: {id}\ntrigger: \"worker task result\"\nversion: 1\nstatus: active\n---\n# Worker Strategy {i}\n\n## Steps\n{summary}\n\n## Source\nPromoted from cluster worker result.\n"
1693 );
1694 let _ = std::fs::write(strategies_dir.join(&hash_filename), content);
1695 }
1696 }
1697 }
1698
1699 Ok(results.join("\n\n"))
1700 }
1701}
1702
1703fn find_worker_binary() -> String {
1704 std::env::current_exe()
1708 .map(|p| p.display().to_string())
1709 .unwrap_or_else(|_| "aegis".to_string())
1710}
1711
1712async fn wait_for_tasks(task_ids: Vec<String>, timeout_secs: u64) -> Result<()> {
1717 let base_url = std::env::var("AEGIS_WORKER_URL")
1718 .unwrap_or_else(|_| "http://localhost:3001".to_string());
1719 let base_url = base_url.trim_end_matches('/').to_string();
1720
1721 let client = reqwest::Client::new();
1722 let deadline = tokio::time::Instant::now()
1723 + std::time::Duration::from_secs(timeout_secs);
1724
1725 let mut remaining: std::collections::HashSet<String> =
1726 task_ids.into_iter().collect();
1727
1728 while !remaining.is_empty() {
1729 if tokio::time::Instant::now() >= deadline {
1730 return Err(anyhow::anyhow!(
1731 "Timed out waiting for tasks: {:?}",
1732 remaining
1733 ));
1734 }
1735
1736 let mut completed = Vec::new();
1737 for task_id in remaining.iter() {
1738 let url = format!("{}/tasks/{}", base_url, task_id);
1739 match client.get(&url).send().await {
1740 Ok(resp) => {
1741 if let Ok(body) = resp.json::<serde_json::Value>().await {
1742 let status = body["status"].as_str().unwrap_or("");
1743 if status == "done" || status == "error" {
1744 completed.push(task_id.clone());
1745 }
1746 }
1747 }
1748 Err(_) => {
1749 }
1751 }
1752 }
1753 for id in completed {
1754 remaining.remove(&id);
1755 }
1756
1757 if !remaining.is_empty() {
1758 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1759 }
1760 }
1761 Ok(())
1762}
1763
1764fn create_worktree(repo: &std::path::Path, task_id: &str) -> Option<(std::path::PathBuf, String)> {
1768 let is_repo = std::process::Command::new("git")
1769 .arg("-C")
1770 .arg(repo)
1771 .args(["rev-parse", "--is-inside-work-tree"])
1772 .output()
1773 .ok()
1774 .map(|o| o.status.success())
1775 .unwrap_or(false);
1776 if !is_repo {
1777 return None;
1778 }
1779 let branch = format!("aegis/task-{task_id}");
1780 let wt = std::env::temp_dir().join(format!("aegis-wt-{task_id}"));
1781 let ok = std::process::Command::new("git")
1782 .arg("-C")
1783 .arg(repo)
1784 .args(["worktree", "add", "-b", &branch])
1785 .arg(&wt)
1786 .arg("HEAD")
1787 .output()
1788 .ok()
1789 .map(|o| o.status.success())
1790 .unwrap_or(false);
1791 if ok {
1792 Some((wt, branch))
1793 } else {
1794 None
1795 }
1796}
1797
1798async fn run_worker(bin: &str, prompt: &str, max_turns: u64, cwd: Option<std::path::PathBuf>) -> Result<String> {
1799 let request = serde_json::json!({
1800 "jsonrpc": "2.0", "id": 1, "method": "task/run",
1801 "params": { "prompt": prompt, "max_turns": max_turns }
1802 });
1803
1804 let mut child = tokio::process::Command::new(bin);
1805 child
1806 .arg("worker")
1807 .stdin(Stdio::piped())
1808 .stdout(Stdio::piped())
1809 .stderr(Stdio::null());
1810 if let Some(dir) = &cwd {
1811 child.current_dir(dir);
1812 }
1813 let mut child = child
1814 .spawn()
1815 .map_err(|e| anyhow::anyhow!("Failed to spawn worker: {e}. Could not run `{bin} worker`."))?;
1816
1817 if let Some(mut stdin) = child.stdin.take() {
1819 use tokio::io::AsyncWriteExt;
1820 stdin.write_all(format!("{}\n", request).as_bytes()).await?;
1821 drop(stdin);
1822 }
1823
1824 let output = tokio::time::timeout(
1826 std::time::Duration::from_secs(900), child.wait_with_output(),
1828 )
1829 .await
1830 .map_err(|_| anyhow::anyhow!("Worker timed out"))??;
1831
1832 let stdout = String::from_utf8_lossy(&output.stdout);
1833
1834 for line in stdout.lines().rev() {
1836 if let Ok(resp) = serde_json::from_str::<serde_json::Value>(line) {
1837 if let Some(result) = resp.get("result") {
1838 return Ok(result["result"]
1839 .as_str()
1840 .unwrap_or("(no result)")
1841 .to_string());
1842 }
1843 if let Some(error) = resp.get("error") {
1844 return Err(anyhow::anyhow!(
1845 "{}",
1846 error["message"].as_str().unwrap_or("unknown error")
1847 ));
1848 }
1849 }
1850 }
1851
1852 Ok(stdout.to_string())
1853}
1854
1855pub struct WebSearchTool;
1860
1861impl WebSearchTool {
1862 pub fn new() -> Self {
1864 WebSearchTool
1865 }
1866
1867 fn load_config_key(key: &str) -> Option<String> {
1868 {
1870 let config_path = aegis_types::paths::config_path();
1871 if let Ok(content) = std::fs::read_to_string(&config_path) {
1872 if let Ok(val) = content.parse::<toml::Value>() {
1873 if let Some(s) = val.get("tools").and_then(|t| t.get(key)).and_then(|v| v.as_str()) {
1874 if !s.is_empty() {
1875 return Some(s.to_string());
1876 }
1877 }
1878 }
1879 }
1880 }
1881 None
1882 }
1883}
1884
1885impl Default for WebSearchTool {
1886 fn default() -> Self {
1887 Self::new()
1888 }
1889}
1890
1891#[async_trait]
1892impl Tool for WebSearchTool {
1893 fn name(&self) -> &str {
1894 "web_search"
1895 }
1896
1897 fn description(&self) -> &str {
1898 "Search the web and return a list of results with title, URL, and snippet."
1899 }
1900
1901 fn parameters(&self) -> Value {
1902 json!({
1903 "type": "object",
1904 "properties": {
1905 "query": { "type": "string", "description": "Search query" },
1906 "num_results": { "type": "integer", "description": "Number of results (default 5)" }
1907 },
1908 "required": ["query"]
1909 })
1910 }
1911
1912 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1913 let query = args["query"].as_str().unwrap_or("").trim().to_string();
1914 if query.is_empty() {
1915 return Ok("Error: query is required".to_string());
1916 }
1917 let num_results = args["num_results"].as_u64().unwrap_or(5) as usize;
1918
1919 let exa_key = Self::load_config_key("exa_api_key")
1921 .or_else(|| std::env::var("EXA_API_KEY").ok());
1922 let tavily_key = Self::load_config_key("tavily_api_key")
1923 .or_else(|| std::env::var("TAVILY_API_KEY").ok());
1924
1925 if let Some(key) = exa_key {
1926 search_exa(&key, &query, num_results).await
1927 } else if let Some(key) = tavily_key {
1928 search_tavily(&key, &query, num_results).await
1929 } else {
1930 search_duckduckgo(&query, num_results).await
1931 }
1932 }
1933}
1934
1935async fn search_exa(api_key: &str, query: &str, num_results: usize) -> Result<String> {
1936 use aegis_security::is_safe_url;
1937 let url = "https://api.exa.ai/search";
1938 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
1939
1940 let client = reqwest::Client::new();
1941 let resp: Value = client
1942 .post(url)
1943 .header("x-api-key", api_key)
1944 .header("Content-Type", "application/json")
1945 .json(&json!({ "query": query, "numResults": num_results }))
1946 .send()
1947 .await?
1948 .json()
1949 .await?;
1950
1951 let mut out = String::new();
1952 if let Some(results) = resp["results"].as_array() {
1953 for (i, r) in results.iter().enumerate() {
1954 let title = r["title"].as_str().unwrap_or("(no title)");
1955 let url = r["url"].as_str().unwrap_or("");
1956 let snippet = r["text"].as_str().or_else(|| r["snippet"].as_str()).unwrap_or("");
1957 let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
1958 out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
1959 }
1960 }
1961 if out.is_empty() {
1962 out = "No results found.".to_string();
1963 }
1964 Ok(out.trim_end().to_string())
1965}
1966
1967async fn search_tavily(api_key: &str, query: &str, num_results: usize) -> Result<String> {
1968 use aegis_security::is_safe_url;
1969 let url = "https://api.tavily.com/search";
1970 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
1971
1972 let client = reqwest::Client::new();
1973 let resp: Value = client
1974 .post(url)
1975 .json(&json!({
1976 "api_key": api_key,
1977 "query": query,
1978 "max_results": num_results
1979 }))
1980 .send()
1981 .await?
1982 .json()
1983 .await?;
1984
1985 let mut out = String::new();
1986 if let Some(results) = resp["results"].as_array() {
1987 for (i, r) in results.iter().enumerate() {
1988 let title = r["title"].as_str().unwrap_or("(no title)");
1989 let url = r["url"].as_str().unwrap_or("");
1990 let snippet = r["content"].as_str().unwrap_or("");
1991 let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
1992 out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
1993 }
1994 }
1995 if out.is_empty() {
1996 out = "No results found.".to_string();
1997 }
1998 Ok(out.trim_end().to_string())
1999}
2000
2001async fn search_duckduckgo(query: &str, num_results: usize) -> Result<String> {
2002 use aegis_security::is_safe_url;
2003 let encoded = urlencoding_encode(query);
2004 let url = format!("https://html.duckduckgo.com/html/?q={}", encoded);
2005 is_safe_url(&url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2006
2007 let client = reqwest::Client::builder()
2008 .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2009 .build()?;
2010 let html = client.get(&url).send().await?.text().await?;
2011
2012 let document = scraper::Html::parse_document(&html);
2013 let result_sel = scraper::Selector::parse(".result").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2014 let title_sel = scraper::Selector::parse(".result__title a, .result__a").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2015 let snippet_sel = scraper::Selector::parse(".result__snippet").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2016 let url_sel = scraper::Selector::parse(".result__url").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2017
2018 let mut out = String::new();
2019 let mut count = 0;
2020
2021 for result in document.select(&result_sel) {
2022 if count >= num_results {
2023 break;
2024 }
2025 let title = result.select(&title_sel).next()
2026 .map(|e| e.text().collect::<Vec<_>>().join(""))
2027 .unwrap_or_default();
2028 let title = title.trim().to_string();
2029 if title.is_empty() {
2030 continue;
2031 }
2032 let url = result.select(&url_sel).next()
2033 .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2034 .unwrap_or_default();
2035 let snippet = result.select(&snippet_sel).next()
2036 .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2037 .unwrap_or_default();
2038 let snippet = if snippet.len() > 300 { snippet[..snippet.floor_char_boundary(300)].to_string() } else { snippet };
2039
2040 count += 1;
2041 out.push_str(&format!("[{count}] {title}\n{url}\n{snippet}\n\n"));
2042 }
2043
2044 if out.is_empty() {
2045 out = "No results found (DuckDuckGo fallback).".to_string();
2046 }
2047 Ok(out.trim_end().to_string())
2048}
2049
2050fn urlencoding_encode(s: &str) -> String {
2051 let mut out = String::new();
2052 for b in s.bytes() {
2053 match b {
2054 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2055 out.push(b as char);
2056 }
2057 b' ' => out.push('+'),
2058 _ => out.push_str(&format!("%{:02X}", b)),
2059 }
2060 }
2061 out
2062}
2063
2064pub struct WebExtractTool;
2069
2070impl WebExtractTool {
2071 pub fn new() -> Self {
2073 WebExtractTool
2074 }
2075}
2076
2077impl Default for WebExtractTool {
2078 fn default() -> Self {
2079 Self::new()
2080 }
2081}
2082
2083#[async_trait]
2084impl Tool for WebExtractTool {
2085 fn name(&self) -> &str {
2086 "web_extract"
2087 }
2088
2089 fn description(&self) -> &str {
2090 "Fetch a URL and extract the main text content as plain text."
2091 }
2092
2093 fn parameters(&self) -> Value {
2094 json!({
2095 "type": "object",
2096 "properties": {
2097 "url": { "type": "string", "description": "URL to fetch and extract" }
2098 },
2099 "required": ["url"]
2100 })
2101 }
2102
2103 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
2104 use aegis_security::is_safe_url;
2105
2106 let url = args["url"].as_str().unwrap_or("").trim();
2107 if url.is_empty() {
2108 return Ok("Error: url is required".to_string());
2109 }
2110
2111 if ctx.sandbox_enabled {
2116 let identity = ctx.effective_identity();
2117 let policy =
2118 aegis_security::derive_sandbox_policy(&identity, "web_extract", &ctx.cwd);
2119 if policy.deny_all {
2120 return Ok(format!(
2121 "web_extract denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to fetch external URLs.",
2122 identity.display(),
2123 identity.trust_level(),
2124 ));
2125 }
2126 tracing::debug!(
2127 identity = %identity.display(),
2128 trust = %identity.trust_level(),
2129 url = %url,
2130 "web_extract: identity check passed"
2131 );
2132 }
2133
2134 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2135
2136 let client = reqwest::Client::builder()
2137 .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2138 .build()?;
2139 let html = client.get(url).send().await?.text().await?;
2140
2141 let document = scraper::Html::parse_document(&html);
2142
2143 let content = extract_text_content(&document);
2145
2146 let content = compress_whitespace(&content);
2148
2149 const MAX_LEN: usize = 4000;
2150 if content.len() > MAX_LEN {
2151 let truncated = &content[..MAX_LEN];
2152 Ok(format!("{}\n\n... [content truncated at 4000 characters. Use a more specific URL or search for subsections.]", truncated))
2153 } else {
2154 Ok(content)
2155 }
2156 }
2157}
2158
2159fn extract_text_content(document: &scraper::Html) -> String {
2160 for selector_str in &["article", "main", "body"] {
2162 if let Ok(sel) = scraper::Selector::parse(selector_str) {
2163 if let Some(elem) = document.select(&sel).next() {
2164 return collect_text_skip_scripts(&elem);
2165 }
2166 }
2167 }
2168 document.root_element().text().collect::<Vec<_>>().join(" ")
2170}
2171
2172fn collect_text_skip_scripts(elem: &scraper::ElementRef) -> String {
2173 let mut text = String::new();
2174 for node in elem.descendants() {
2175 if let Some(el) = scraper::ElementRef::wrap(node) {
2176 let tag = el.value().name();
2177 if tag == "script" || tag == "style" || tag == "noscript" {
2178 continue;
2179 }
2180 } else if let Some(t) = node.value().as_text() {
2181 text.push_str(t);
2182 }
2183 }
2184 text
2185}
2186
2187fn compress_whitespace(s: &str) -> String {
2188 let mut result = String::with_capacity(s.len());
2189 let mut last_was_space = true;
2190 for c in s.chars() {
2191 if c.is_whitespace() {
2192 if !last_was_space {
2193 result.push(' ');
2194 last_was_space = true;
2195 }
2196 } else {
2197 result.push(c);
2198 last_was_space = false;
2199 }
2200 }
2201 result.trim().to_string()
2202}
2203
2204pub struct BrowserTool {
2209 pub binary: String,
2210 pub timeout_secs: u64,
2211}
2212
2213#[async_trait]
2214impl Tool for BrowserTool {
2215 fn name(&self) -> &str {
2216 "browser"
2217 }
2218 fn description(&self) -> &str {
2219 "Control a real browser via browser-harness. Actions: navigate, click, type, screenshot, extract."
2220 }
2221 fn parameters(&self) -> Value {
2222 json!({
2223 "type": "object",
2224 "properties": {
2225 "action": {
2226 "type": "string",
2227 "enum": ["navigate", "click", "type", "screenshot", "extract"],
2228 "description": "Browser action to perform"
2229 },
2230 "url": { "type": "string", "description": "URL to navigate to (navigate)" },
2231 "x": { "type": "number", "description": "X coordinate (click, type)" },
2232 "y": { "type": "number", "description": "Y coordinate (click, type)" },
2233 "text": { "type": "string", "description": "Text to type (type)" },
2234 "selector":{ "type": "string", "description": "CSS selector to click or extract (optional)" }
2235 },
2236 "required": ["action"]
2237 })
2238 }
2239 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2240 let action = args["action"].as_str().unwrap_or("");
2241
2242 let code = match action {
2243 "navigate" => {
2244 let url = args["url"].as_str().unwrap_or("");
2245 if url.is_empty() {
2246 return Ok("Error: url is required for navigate".to_string());
2247 }
2248 format!(
2249 "from browser_harness import Browser\nb = Browser()\nb.go('{url}')\nprint(b.title())\n"
2250 )
2251 }
2252 "click" => {
2253 if let Some(sel) = args["selector"].as_str() {
2254 format!(
2255 "from browser_harness import Browser\nb = Browser()\nb.click('{sel}')\nprint('clicked')\n"
2256 )
2257 } else {
2258 let x = args["x"].as_f64().unwrap_or(0.0);
2259 let y = args["y"].as_f64().unwrap_or(0.0);
2260 format!(
2261 "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nprint('clicked')\n"
2262 )
2263 }
2264 }
2265 "type" => {
2266 let text = args["text"].as_str().unwrap_or("");
2267 if let Some(sel) = args["selector"].as_str() {
2268 format!(
2269 "from browser_harness import Browser\nb = Browser()\nb.type('{sel}', '{text}')\nprint('typed')\n"
2270 )
2271 } else {
2272 let x = args["x"].as_f64().unwrap_or(0.0);
2273 let y = args["y"].as_f64().unwrap_or(0.0);
2274 format!(
2275 "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nb.keyboard_type('{text}')\nprint('typed')\n"
2276 )
2277 }
2278 }
2279 "screenshot" => {
2280 "from browser_harness import Browser\nb = Browser()\npath = b.screenshot()\nprint(path)\n".to_string()
2281 }
2282 "extract" => {
2283 if let Some(sel) = args["selector"].as_str() {
2284 format!(
2285 "from browser_harness import Browser\nb = Browser()\nprint(b.text('{sel}'))\n"
2286 )
2287 } else {
2288 "from browser_harness import Browser\nb = Browser()\nprint(b.text('body'))\n".to_string()
2289 }
2290 }
2291 _ => return Ok(format!("Unknown action: {action}. Use: navigate, click, type, screenshot, extract")),
2292 };
2293
2294 let output = tokio::time::timeout(
2295 std::time::Duration::from_secs(self.timeout_secs),
2296 tokio::process::Command::new(&self.binary)
2297 .arg("-c")
2298 .arg(&code)
2299 .stdout(Stdio::piped())
2300 .stderr(Stdio::piped())
2301 .output(),
2302 )
2303 .await
2304 .map_err(|_| anyhow::anyhow!("browser-harness timed out after {}s", self.timeout_secs))??;
2305
2306 let stdout = String::from_utf8_lossy(&output.stdout);
2307 let stderr = String::from_utf8_lossy(&output.stderr);
2308 let code_exit = output.status.code().unwrap_or(-1);
2309
2310 if code_exit != 0 && stdout.is_empty() {
2311 return Ok(format!("[browser error]\n{stderr}"));
2312 }
2313 let mut result = stdout.to_string();
2314 if !stderr.is_empty() {
2315 result.push_str(&format!("\n[stderr] {stderr}"));
2316 }
2317 Ok(result.trim_end().to_string())
2318 }
2319}
2320
2321pub struct MaigretTool {
2326 pub maigret_path: String,
2327 pub timeout_secs: u64,
2328 pub top_sites: u64,
2329}
2330
2331#[async_trait]
2332impl Tool for MaigretTool {
2333 fn name(&self) -> &str {
2334 "maigret"
2335 }
2336 fn description(&self) -> &str {
2337 "Search for social media accounts and public profiles by username using Maigret OSINT tool. Returns found accounts across hundreds of sites."
2338 }
2339 fn parameters(&self) -> Value {
2340 json!({
2341 "type": "object",
2342 "properties": {
2343 "username": {
2344 "type": "string",
2345 "description": "Username to search for"
2346 },
2347 "top_sites": {
2348 "type": "integer",
2349 "description": "Limit to top N sites (default: configured value, 0 = all)"
2350 }
2351 },
2352 "required": ["username"]
2353 })
2354 }
2355 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2356 let username = args["username"].as_str().unwrap_or("").trim().to_string();
2357 if username.is_empty() {
2358 return Ok("Error: username is required.".to_string());
2359 }
2360 if username.contains([';', '&', '|', '$', '`', '\'', '"', '\n']) {
2362 return Ok("Error: invalid characters in username.".to_string());
2363 }
2364
2365 let top = args["top_sites"].as_u64().unwrap_or(self.top_sites);
2366
2367 let mut cmd = tokio::process::Command::new("python");
2368 cmd.arg("-m")
2369 .arg("maigret")
2370 .arg(&username)
2371 .arg("--no-color")
2372 .arg("--no-progressbar")
2373 .arg("-J")
2374 .arg("ndjson");
2375 if top > 0 {
2376 cmd.arg("--top-sites").arg(top.to_string());
2377 }
2378 cmd.current_dir(&self.maigret_path)
2379 .stdout(Stdio::piped())
2380 .stderr(Stdio::piped());
2381
2382 let output = tokio::time::timeout(
2383 std::time::Duration::from_secs(self.timeout_secs),
2384 cmd.output(),
2385 )
2386 .await
2387 .map_err(|_| anyhow::anyhow!("maigret timed out after {}s", self.timeout_secs))??;
2388
2389 let stdout = String::from_utf8_lossy(&output.stdout);
2390 let stderr = String::from_utf8_lossy(&output.stderr);
2391
2392 let report_path = std::path::Path::new(&self.maigret_path)
2394 .join("reports")
2395 .join(format!("report_{username}_ndjson.json"));
2396
2397 if report_path.exists() {
2398 let json_raw = tokio::fs::read_to_string(&report_path).await?;
2399 if let Ok(data) = serde_json::from_str::<Value>(&json_raw) {
2401 return Ok(format_maigret_report(&username, &data));
2402 }
2403 }
2404
2405 let mut result = stdout.to_string();
2407 if result.is_empty() && !stderr.is_empty() {
2408 result = stderr.to_string();
2409 }
2410 Ok(truncate_output(&result, 20_000))
2411 }
2412}
2413
2414fn format_maigret_report(username: &str, data: &Value) -> String {
2415 let mut found = Vec::new();
2416 let mut not_found = 0usize;
2417
2418 if let Some(obj) = data.as_object() {
2419 for (site, info) in obj {
2420 let status = info["status"]["status"].as_str().unwrap_or("");
2421 if status == "Claimed" {
2422 let url = info["url_user"].as_str()
2423 .or_else(|| info["url"].as_str())
2424 .unwrap_or("");
2425 let mut extra = Vec::new();
2426 for key in &["name", "bio", "location", "followers", "following", "posts"] {
2428 if let Some(v) = info["ids"].get(key).or_else(|| info.get(key)) {
2429 if !v.is_null() {
2430 extra.push(format!("{key}={v}"));
2431 }
2432 }
2433 }
2434 let extra_str = if extra.is_empty() {
2435 String::new()
2436 } else {
2437 format!(" [{}]", extra.join(", "))
2438 };
2439 found.push(format!(" + {site}: {url}{extra_str}"));
2440 } else {
2441 not_found += 1;
2442 }
2443 }
2444 }
2445
2446 found.sort();
2447 let mut out = format!(
2448 "Maigret results for '{}': {} accounts found, {} not found\n\n",
2449 username,
2450 found.len(),
2451 not_found
2452 );
2453 if found.is_empty() {
2454 out.push_str("No accounts found.\n");
2455 } else {
2456 out.push_str(&found.join("\n"));
2457 }
2458 out
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463 use super::*;
2464 use serde_json::json;
2465
2466 #[test]
2467 fn bg_backend_from_config_parses() {
2468 assert_eq!(BgBackend::from_config("tmux"), BgBackend::Tmux);
2469 assert_eq!(BgBackend::from_config(" TMUX "), BgBackend::Tmux);
2470 assert_eq!(BgBackend::from_config("child"), BgBackend::Child);
2471 assert_eq!(BgBackend::from_config("auto"), BgBackend::Auto);
2472 assert_eq!(BgBackend::from_config("nonsense"), BgBackend::Auto);
2473 }
2474
2475 #[test]
2476 fn bg_backend_forced_passthrough() {
2477 assert_eq!(BgBackend::Child.effective(), BgBackend::Child);
2479 assert_eq!(BgBackend::Tmux.effective(), BgBackend::Tmux);
2480 }
2481
2482 #[test]
2483 fn tmux_session_name_sanitizes_and_prefixes() {
2484 assert_eq!(tmux_session_name("job1"), "aegis-job1");
2485 assert_eq!(tmux_session_name("a b;c$(x)"), "aegis-a_b_c__x_");
2486 assert_eq!(tmux_session_name("ok-_1"), "aegis-ok-_1");
2487 }
2488
2489 #[test]
2490 fn posix_squote_escapes_single_quotes() {
2491 assert_eq!(posix_squote("abc"), "'abc'");
2492 assert_eq!(posix_squote("a'b"), "'a'\\''b'");
2493 }
2494
2495 fn make_ctx(cwd: std::path::PathBuf) -> ToolContext<'static> {
2496 ToolContext {
2497 cwd,
2498 session_id: "test".to_string(),
2499 approve_fn: &|_| true,
2500 yolo: true,
2501 identity: None,
2502 sandbox_enabled: false,
2503 }
2504 }
2505
2506 #[tokio::test]
2507 async fn test_spawn_without_depends() {
2508 let tool = SpawnTaskTool::new(1);
2509 let ctx = make_ctx(std::env::temp_dir());
2510 let result = tool.execute(json!({}), &ctx).await;
2512 assert!(result.is_ok());
2513 assert_eq!(result.unwrap(), "No prompt provided.");
2514 }
2515
2516 #[tokio::test]
2517 async fn test_spawn_with_empty_depends() {
2518 let tool = SpawnTaskTool::new(1);
2520 let ctx = make_ctx(std::env::temp_dir());
2521 let result = tool.execute(json!({"depends_on": []}), &ctx).await;
2522 assert!(result.is_ok());
2523 assert_eq!(result.unwrap(), "No prompt provided.");
2524 }
2525
2526 #[tokio::test]
2527 async fn test_wait_for_tasks_empty() {
2528 let result = wait_for_tasks(vec![], 5).await;
2530 assert!(result.is_ok());
2531 }
2532
2533 #[tokio::test]
2534 async fn test_wait_for_tasks_timeout() {
2535 let result = wait_for_tasks(vec!["nonexistent-task-id".to_string()], 1).await;
2538 assert!(result.is_err());
2539 let err = result.unwrap_err().to_string();
2540 assert!(err.contains("Timed out") || err.contains("timed out") || err.contains("nonexistent"));
2541 }
2542}
2543