Skip to main content

aacode_rs/tools/
backend.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Shell execution backend abstraction.
5//!
6//! The agent's `run_shell` tool can run commands two ways:
7//!
8//!   * **NativeShell** (default on desktop: Linux/macOS/Windows) — spawns the
9//!     real OS shell (`sh -c` / `cmd /C`). Full heredoc, pipes, real paths,
10//!     real `grep`/`rg`/`git`, and the system `python3` all work exactly as the
11//!     user expects. This is the natural behavior on a developer machine.
12//!
13//!   * **FastshellBackend** (default on mobile: Android/iOS) — routes through
14//!     the `fastshell` sandbox engine (180+ built-in commands + embedded
15//!     CPython) inside a VFS jail. Used where there is no usable OS shell.
16//!
17//! The choice is auto-detected by target OS but can be overridden by config.
18
19use fastshell::Fastshell;
20use std::sync::{Arc, Mutex};
21
22/// Uniform result of a shell command.
23#[derive(Debug, Clone)]
24pub struct CmdOutput {
25    pub stdout: String,
26    pub stderr: String,
27    pub exit_code: i32,
28}
29
30/// A command execution backend. `cwd` is the working directory; `timeout_secs`
31/// of 0 means "no explicit total timeout". `idle_timeout_secs` of 0 disables
32/// idle timeout (currently only enforced by `NativeShell`, ignored by
33/// `FastshellBackend`).
34pub trait ShellBackend: Send + Sync {
35    fn run(
36        &self,
37        command: &str,
38        stdin_input: Option<&str>,
39        timeout_secs: u64,
40        idle_timeout_secs: u64,
41        cwd: &std::path::Path,
42    ) -> CmdOutput;
43
44    /// Human-readable backend name (for diagnostics).
45    fn kind(&self) -> &'static str;
46}
47
48/// Which backend to use.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BackendKind {
51    /// Real OS shell via std::process::Command.
52    Native,
53    /// fastshell sandbox engine.
54    Fastshell,
55}
56
57impl BackendKind {
58    /// Default backend for the current platform: native on desktop, fastshell
59    /// on mobile (where there is no usable system shell).
60    pub fn platform_default() -> BackendKind {
61        if cfg!(any(target_os = "android", target_os = "ios")) {
62            BackendKind::Fastshell
63        } else {
64            BackendKind::Native
65        }
66    }
67}
68
69// ───────────────────────────── Native backend ─────────────────────────────
70
71/// Runs commands through the real operating-system shell.
72pub struct NativeShell;
73
74impl NativeShell {
75    pub fn new() -> Self {
76        NativeShell
77    }
78}
79
80impl Default for NativeShell {
81    fn default() -> Self {
82        NativeShell::new()
83    }
84}
85
86impl ShellBackend for NativeShell {
87    fn run(
88        &self,
89        command: &str,
90        stdin_input: Option<&str>,
91        timeout_secs: u64,
92        idle_timeout_secs: u64,
93        cwd: &std::path::Path,
94    ) -> CmdOutput {
95        use std::io::Write;
96        use std::process::{Command, Stdio};
97
98        // Pick the platform shell.
99        let mut cmd = if cfg!(target_os = "windows") {
100            let mut c = Command::new("cmd");
101            c.arg("/C").arg(command);
102            c
103        } else {
104            let mut c = Command::new("sh");
105            c.arg("-c").arg(command);
106            c
107        };
108        cmd.current_dir(cwd)
109            .stdin(if stdin_input.is_some() {
110                Stdio::piped()
111            } else {
112                Stdio::null()
113            })
114            .stdout(Stdio::piped())
115            .stderr(Stdio::piped());
116
117        let mut child = match cmd.spawn() {
118            Ok(c) => c,
119            Err(e) => {
120                return CmdOutput {
121                    stdout: String::new(),
122                    stderr: format!("failed to spawn shell: {e}"),
123                    exit_code: 127,
124                }
125            }
126        };
127
128        // Feed stdin if provided.
129        if let Some(input) = stdin_input {
130            if let Some(mut stdin) = child.stdin.take() {
131                let _ = stdin.write_all(input.as_bytes());
132                // dropping stdin closes it (EOF)
133            }
134        }
135
136        // Timeout handling: wait in a thread and enforce a deadline.
137        let output = if timeout_secs > 0 || idle_timeout_secs > 0 {
138            wait_with_timeout(child, timeout_secs, idle_timeout_secs)
139        } else {
140            child.wait_with_output().map(|o| CmdOutput {
141                stdout: String::from_utf8_lossy(&o.stdout).to_string(),
142                stderr: String::from_utf8_lossy(&o.stderr).to_string(),
143                exit_code: o.status.code().unwrap_or(-1),
144            })
145        };
146
147        output.unwrap_or_else(|e| CmdOutput {
148            stdout: String::new(),
149            stderr: format!("shell execution error: {e}"),
150            exit_code: -1,
151        })
152    }
153
154    fn kind(&self) -> &'static str {
155        "native"
156    }
157}
158
159/// Wait for a child process. Enforces both a total timeout and an idle
160/// timeout (time with no stdout/stderr output). On timeout, kills the
161/// process group and reports exit 124.
162fn wait_with_timeout(
163    mut child: std::process::Child,
164    timeout_secs: u64,
165    idle_timeout_secs: u64,
166) -> std::io::Result<CmdOutput> {
167    use std::io::Read;
168    use std::sync::mpsc;
169
170    let stdout_pipe = child.stdout.take();
171    let stderr_pipe = child.stderr.take();
172
173    enum Chunk {
174        Stdout(String),
175        Stderr(String),
176    }
177
178    let (chunk_tx, chunk_rx) = mpsc::channel::<Chunk>();
179
180    // Reader threads send chunks through the channel so the main thread can
181    // track idle time.
182    if let Some(mut p) = stdout_pipe {
183        let tx = chunk_tx.clone();
184        std::thread::spawn(move || {
185            let mut buf = [0u8; 4096];
186            loop {
187                match p.read(&mut buf) {
188                    Ok(0) => break,
189                    Ok(n) => {
190                        let s = String::from_utf8_lossy(&buf[..n]).to_string();
191                        let _ = tx.send(Chunk::Stdout(s));
192                    }
193                    Err(_) => break,
194                }
195            }
196        });
197    }
198    if let Some(mut p) = stderr_pipe {
199        let tx = chunk_tx.clone();
200        std::thread::spawn(move || {
201            let mut buf = [0u8; 4096];
202            loop {
203                match p.read(&mut buf) {
204                    Ok(0) => break,
205                    Ok(n) => {
206                        let s = String::from_utf8_lossy(&buf[..n]).to_string();
207                        let _ = tx.send(Chunk::Stderr(s));
208                    }
209                    Err(_) => break,
210                }
211            }
212        });
213    }
214    drop(chunk_tx);
215
216    let pid = child.id();
217    let (exit_tx, exit_rx) = mpsc::channel();
218    std::thread::spawn(move || {
219        let status = child.wait();
220        let _ = exit_tx.send(status);
221    });
222
223    let mut stdout = String::new();
224    let mut stderr = String::new();
225    let deadline = if timeout_secs > 0 {
226        Some(std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs))
227    } else {
228        None
229    };
230    let idle_dur = std::time::Duration::from_secs(idle_timeout_secs);
231    let mut last_data = std::time::Instant::now();
232
233    loop {
234        // Compute how long we can wait for the next event.
235        let wait_dur = match deadline {
236            Some(dl) => {
237                let remaining = dl.saturating_duration_since(std::time::Instant::now());
238                // Also limit by idle timeout if enabled.
239                if idle_timeout_secs > 0 {
240                    let idle_remaining = idle_dur.saturating_sub(last_data.elapsed());
241                    remaining.min(idle_remaining)
242                } else {
243                    remaining
244                }
245            }
246            None => {
247                if idle_timeout_secs > 0 {
248                    idle_dur.saturating_sub(last_data.elapsed())
249                } else {
250                    // Neither total nor idle timeout — wait forever.
251                    std::time::Duration::from_secs(u64::MAX)
252                }
253            }
254        };
255
256        if wait_dur.is_zero() {
257            // Determine the reason and kill.
258            let reason = if deadline.map_or(false, |dl| dl <= std::time::Instant::now()) {
259                format!("command timed out after {timeout_secs}s")
260            } else {
261                format!("command idle timeout after {idle_timeout_secs}s")
262            };
263            #[cfg(unix)]
264            unsafe {
265                libc::kill(pid as i32, libc::SIGKILL);
266            }
267            #[cfg(windows)]
268            {
269                let _ = std::process::Command::new("taskkill")
270                    .args(["/F", "/T", "/PID", &pid.to_string()])
271                    .output();
272            }
273            return Ok(CmdOutput {
274                stdout,
275                stderr: format!("{stderr}\n{reason}"),
276                exit_code: 124,
277            });
278        }
279
280        // Wait for either a chunk or the process to exit.
281        match chunk_rx.recv_timeout(wait_dur) {
282            Ok(Chunk::Stdout(s)) => {
283                last_data = std::time::Instant::now();
284                stdout.push_str(&s);
285            }
286            Ok(Chunk::Stderr(s)) => {
287                last_data = std::time::Instant::now();
288                stderr.push_str(&s);
289            }
290            Err(mpsc::RecvTimeoutError::Disconnected) => {
291                // Reader channels closed — drain any remaining exit status.
292                break;
293            }
294            Err(mpsc::RecvTimeoutError::Timeout) => {
295                // Check if the process already exited in the meantime.
296                if let Ok(Ok(status)) = exit_rx.try_recv() {
297                    return Ok(CmdOutput {
298                        stdout,
299                        stderr,
300                        exit_code: status.code().unwrap_or(-1),
301                    });
302                }
303                // Otherwise the timeout computation will catch it on the
304                // next loop iteration.
305                continue;
306            }
307        }
308
309        // Also check if process already exited (non-blocking).
310        if let Ok(Ok(status)) = exit_rx.try_recv() {
311            // Drain remaining chunks quickly.
312            while let Ok(chunk) = chunk_rx.try_recv() {
313                match chunk {
314                    Chunk::Stdout(s) => stdout.push_str(&s),
315                    Chunk::Stderr(s) => stderr.push_str(&s),
316                }
317            }
318            return Ok(CmdOutput {
319                stdout,
320                stderr,
321                exit_code: status.code().unwrap_or(-1),
322            });
323        }
324    }
325
326    // Process has exited or all reader threads finished.
327    match exit_rx.recv() {
328        Ok(Ok(status)) => Ok(CmdOutput {
329            stdout,
330            stderr,
331            exit_code: status.code().unwrap_or(-1),
332        }),
333        Ok(Err(e)) => Err(e),
334        Err(_) => Ok(CmdOutput {
335            stdout,
336            stderr: format!("{stderr}\nprocess disappeared"),
337            exit_code: 127,
338        }),
339    }
340}
341
342// ─────────────────────────── Fastshell backend ────────────────────────────
343
344/// Shared, thread-safe handle to the fastshell SDK.
345pub type SharedFastshell = Arc<Mutex<Fastshell>>;
346
347/// Routes commands through the fastshell sandbox engine.
348pub struct FastshellBackend {
349    fs: SharedFastshell,
350    /// Baseline working directory (VFS path), captured on first use. fastshell's
351    /// cwd is persistent across commands, so without this a `cd` in one
352    /// `run_shell` leaks into the next (the model assumes each call starts at
353    /// the project root). Each command runs from this baseline.
354    base_cwd: std::sync::Mutex<Option<String>>,
355}
356
357impl FastshellBackend {
358    pub fn new(fs: SharedFastshell) -> Self {
359        FastshellBackend {
360            fs,
361            base_cwd: std::sync::Mutex::new(None),
362        }
363    }
364}
365
366impl ShellBackend for FastshellBackend {
367    fn run(
368        &self,
369        command: &str,
370        stdin_input: Option<&str>,
371        timeout_secs: u64,
372        _idle_timeout_secs: u64,
373        _cwd: &std::path::Path,
374    ) -> CmdOutput {
375        // fastshell::execute takes only the command; stdin is emulated by
376        // piping via printf (fastshell has no stdin param). cwd is governed
377        // by the sandbox.
378        let effective = match stdin_input {
379            Some(s) if !s.is_empty() => {
380                let escaped = s.replace('\\', "\\\\").replace('\'', "'\\''");
381                format!("printf '%s' '{escaped}' | {command}")
382            }
383            _ => command.to_string(),
384        };
385
386        // Try-lock the fastshell handle. Use the command timeout as the
387        // lock-acquisition ceiling (with a 5 s floor) so we don't burn
388        // more time waiting for the lock than we'd allow the command to run.
389        let lock_timeout_secs = timeout_secs.max(5);
390        let timeout_ms = timeout_secs.saturating_mul(1000);
391        let fs_guard = {
392            let deadline =
393                std::time::Instant::now() + std::time::Duration::from_secs(lock_timeout_secs);
394            loop {
395                match self.fs.try_lock() {
396                    Ok(g) => break g,
397                    Err(std::sync::TryLockError::WouldBlock) => {
398                        if std::time::Instant::now() > deadline {
399                            return CmdOutput {
400                                stdout: String::new(),
401                                stderr: format!(
402                                    "fastshell: could not acquire lock after {lock_timeout_secs}s (busy)\n"
403                                ),
404                                exit_code: 124,
405                            };
406                        }
407                        std::thread::sleep(std::time::Duration::from_millis(50));
408                    }
409                    Err(std::sync::TryLockError::Poisoned(e)) => break e.into_inner(),
410                }
411            }
412        };
413
414        let r = {
415            // Capture the baseline cwd once (the project root the host set up).
416            let base = {
417                let mut b = self.base_cwd.lock().unwrap_or_else(|e| e.into_inner());
418                if b.is_none() {
419                    let pwd = fs_guard.execute("pwd").stdout.trim().to_string();
420                    *b = Some(if pwd.is_empty() { "/".to_string() } else { pwd });
421                }
422                b.clone().unwrap_or_else(|| "/".to_string())
423            };
424            fs_guard.execute_in_with_timeout(&base, &effective, timeout_ms)
425        };
426
427        CmdOutput {
428            stdout: r.stdout,
429            stderr: r.stderr,
430            exit_code: r.exit_code,
431        }
432    }
433
434    fn kind(&self) -> &'static str {
435        "fastshell"
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn platform_default_is_native_on_desktop() {
445        // On the CI/dev desktop target this must be Native.
446        #[cfg(not(any(target_os = "android", target_os = "ios")))]
447        assert_eq!(BackendKind::platform_default(), BackendKind::Native);
448    }
449
450    #[test]
451    fn native_echo() {
452        let sh = NativeShell::new();
453        let out = sh.run("echo hello", None, 10, 0, std::path::Path::new("."));
454        assert_eq!(out.exit_code, 0);
455        assert!(out.stdout.contains("hello"));
456    }
457
458    #[test]
459    fn native_heredoc_works() {
460        // The whole point: real shell features like heredoc must work natively.
461        let dir = std::env::temp_dir().join(format!("native_hd_{}", uuid::Uuid::new_v4().simple()));
462        std::fs::create_dir_all(&dir).unwrap();
463        let sh = NativeShell::new();
464        let out = sh.run(
465            "cat > f.txt << 'EOF'\nline1\nline2\nEOF\ncat f.txt",
466            None,
467            10,
468            0,
469            &dir,
470        );
471        assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
472        assert!(out.stdout.contains("line1"));
473        assert!(out.stdout.contains("line2"));
474        // File actually created at the real cwd (not a VFS path).
475        assert!(dir.join("f.txt").exists());
476    }
477
478    #[test]
479    fn native_real_cwd_and_pipes() {
480        let dir =
481            std::env::temp_dir().join(format!("native_cwd_{}", uuid::Uuid::new_v4().simple()));
482        std::fs::create_dir_all(&dir).unwrap();
483        std::fs::write(dir.join("data.txt"), "apple\nbanana\napple\n").unwrap();
484        let sh = NativeShell::new();
485        let out = sh.run("cat data.txt | grep apple | wc -l", None, 10, 0, &dir);
486        assert_eq!(out.exit_code, 0);
487        assert_eq!(out.stdout.trim(), "2");
488    }
489
490    #[test]
491    fn native_stdin_input() {
492        let sh = NativeShell::new();
493        let out = sh.run(
494            "cat",
495            Some("piped content"),
496            10,
497            0,
498            std::path::Path::new("."),
499        );
500        assert_eq!(out.exit_code, 0);
501        assert!(out.stdout.contains("piped content"));
502    }
503
504    #[test]
505    fn native_nonzero_exit() {
506        let sh = NativeShell::new();
507        let out = sh.run("exit 3", None, 10, 0, std::path::Path::new("."));
508        assert_eq!(out.exit_code, 3);
509    }
510
511    #[test]
512    fn native_timeout_kills() {
513        let sh = NativeShell::new();
514        let start = std::time::Instant::now();
515        // On some platforms, the OS shell may absorb SIGKILL to its children
516        // differently; we verify that a command that sleeps for 10s with a 1s
517        // timeout DOES terminate within a reasonable window (guarded at 8s).
518        let out = sh.run("sleep 10", None, 1, 0, std::path::Path::new("."));
519        let elapsed = start.elapsed();
520        assert!(
521            elapsed.as_secs() < 8,
522            "timeout should have fired; elapsed={elapsed:?}"
523        );
524        // Exit 124 is the POSIX convention for command-killed-by-timeout.
525        assert_eq!(
526            out.exit_code, 124,
527            "unexpected exit code; stdout={}, stderr={}",
528            out.stdout, out.stderr
529        );
530    }
531
532    #[test]
533    fn native_python_real() {
534        // If python3 exists (desktop), it runs via the real interpreter.
535        let sh = NativeShell::new();
536        let out = sh.run(
537            "python3 -c \"print(6*7)\"",
538            None,
539            10,
540            0,
541            std::path::Path::new("."),
542        );
543        if out.exit_code == 0 {
544            assert!(out.stdout.contains("42"));
545        }
546    }
547
548    /// fastshell's cwd is persistent, but each `run_shell` must start at the
549    /// project root (the model assumes statelessness; a `cd` must not leak).
550    #[test]
551    fn fastshell_backend_resets_cwd_between_calls() {
552        let dir =
553            std::env::temp_dir().join(format!("fs_backend_{}", uuid::Uuid::new_v4().simple()));
554        std::fs::create_dir_all(&dir).unwrap();
555        let mut fs = Fastshell::new();
556        let mut cfg = fastshell::Config::default();
557        cfg.sandbox_path = dir.to_string_lossy().to_string();
558        cfg.command_timeout_ms = 5_000;
559        fs.init(cfg).unwrap();
560        let backend = FastshellBackend::new(Arc::new(Mutex::new(fs)));
561
562        let base = backend
563            .run("pwd", None, 5, 0, &dir)
564            .stdout
565            .trim()
566            .to_string();
567        assert!(!base.is_empty(), "pwd should return the baseline cwd");
568
569        // `cd ..` in one call must not leak into the next.
570        let _ = backend.run("cd ..", None, 5, 0, &dir);
571        let after = backend
572            .run("pwd", None, 5, 0, &dir)
573            .stdout
574            .trim()
575            .to_string();
576        assert_eq!(after, base, "cwd must reset to the baseline between calls");
577    }
578}