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}
351
352impl FastshellBackend {
353    pub fn new(fs: SharedFastshell) -> Self {
354        FastshellBackend { fs }
355    }
356}
357
358impl ShellBackend for FastshellBackend {
359    fn run(
360        &self,
361        command: &str,
362        stdin_input: Option<&str>,
363        timeout_secs: u64,
364        _idle_timeout_secs: u64,
365        _cwd: &std::path::Path,
366    ) -> CmdOutput {
367        // fastshell::execute takes only the command; stdin is emulated by
368        // piping via printf (fastshell has no stdin param). cwd is governed
369        // by the sandbox.
370        let effective = match stdin_input {
371            Some(s) if !s.is_empty() => {
372                let escaped = s.replace('\\', "\\\\").replace('\'', "'\\''");
373                format!("printf '%s' '{escaped}' | {command}")
374            }
375            _ => command.to_string(),
376        };
377
378        // Try-lock the fastshell handle. Use the command timeout as the
379        // lock-acquisition ceiling (with a 5 s floor) so we don't burn
380        // more time waiting for the lock than we'd allow the command to run.
381        let lock_timeout_secs = timeout_secs.max(5);
382        let timeout_ms = timeout_secs.saturating_mul(1000);
383        let fs_guard = {
384            let deadline = std::time::Instant::now()
385                + std::time::Duration::from_secs(lock_timeout_secs);
386            loop {
387                match self.fs.try_lock() {
388                    Ok(g) => break g,
389                    Err(std::sync::TryLockError::WouldBlock) => {
390                        if std::time::Instant::now() > deadline {
391                            return CmdOutput {
392                                stdout: String::new(),
393                                stderr: format!(
394                                    "fastshell: could not acquire lock after {lock_timeout_secs}s (busy)\n"
395                                ),
396                                exit_code: 124,
397                            };
398                        }
399                        std::thread::sleep(std::time::Duration::from_millis(50));
400                    }
401                    Err(std::sync::TryLockError::Poisoned(e)) => break e.into_inner(),
402                }
403            }
404        };
405
406        let r = if timeout_ms > 0 {
407            fs_guard.execute_with_timeout(&effective, timeout_ms)
408        } else {
409            fs_guard.execute(&effective)
410        };
411
412        CmdOutput {
413            stdout: r.stdout,
414            stderr: r.stderr,
415            exit_code: r.exit_code,
416        }
417    }
418
419    fn kind(&self) -> &'static str {
420        "fastshell"
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn platform_default_is_native_on_desktop() {
430        // On the CI/dev desktop target this must be Native.
431        #[cfg(not(any(target_os = "android", target_os = "ios")))]
432        assert_eq!(BackendKind::platform_default(), BackendKind::Native);
433    }
434
435    #[test]
436    fn native_echo() {
437        let sh = NativeShell::new();
438        let out = sh.run("echo hello", None, 10, 0, std::path::Path::new("."));
439        assert_eq!(out.exit_code, 0);
440        assert!(out.stdout.contains("hello"));
441    }
442
443    #[test]
444    fn native_heredoc_works() {
445        // The whole point: real shell features like heredoc must work natively.
446        let dir = std::env::temp_dir().join(format!("native_hd_{}", uuid::Uuid::new_v4().simple()));
447        std::fs::create_dir_all(&dir).unwrap();
448        let sh = NativeShell::new();
449        let out = sh.run(
450            "cat > f.txt << 'EOF'\nline1\nline2\nEOF\ncat f.txt",
451            None,
452            10,
453            0,
454            &dir,
455        );
456        assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
457        assert!(out.stdout.contains("line1"));
458        assert!(out.stdout.contains("line2"));
459        // File actually created at the real cwd (not a VFS path).
460        assert!(dir.join("f.txt").exists());
461    }
462
463    #[test]
464    fn native_real_cwd_and_pipes() {
465        let dir = std::env::temp_dir().join(format!("native_cwd_{}", uuid::Uuid::new_v4().simple()));
466        std::fs::create_dir_all(&dir).unwrap();
467        std::fs::write(dir.join("data.txt"), "apple\nbanana\napple\n").unwrap();
468        let sh = NativeShell::new();
469        let out = sh.run("cat data.txt | grep apple | wc -l", None, 10, 0, &dir);
470        assert_eq!(out.exit_code, 0);
471        assert_eq!(out.stdout.trim(), "2");
472    }
473
474    #[test]
475    fn native_stdin_input() {
476        let sh = NativeShell::new();
477        let out = sh.run("cat", Some("piped content"), 10, 0, std::path::Path::new("."));
478        assert_eq!(out.exit_code, 0);
479        assert!(out.stdout.contains("piped content"));
480    }
481
482    #[test]
483    fn native_nonzero_exit() {
484        let sh = NativeShell::new();
485        let out = sh.run("exit 3", None, 10, 0, std::path::Path::new("."));
486        assert_eq!(out.exit_code, 3);
487    }
488
489    #[test]
490    fn native_timeout_kills() {
491        let sh = NativeShell::new();
492        let start = std::time::Instant::now();
493        // On some platforms, the OS shell may absorb SIGKILL to its children
494        // differently; we verify that a command that sleeps for 10s with a 1s
495        // timeout DOES terminate within a reasonable window (guarded at 8s).
496        let out = sh.run("sleep 10", None, 1, 0, std::path::Path::new("."));
497        let elapsed = start.elapsed();
498        assert!(elapsed.as_secs() < 8, "timeout should have fired; elapsed={elapsed:?}");
499        // Exit 124 is the POSIX convention for command-killed-by-timeout.
500        assert_eq!(out.exit_code, 124, "unexpected exit code; stdout={}, stderr={}", out.stdout, out.stderr);
501    }
502
503    #[test]
504    fn native_python_real() {
505        // If python3 exists (desktop), it runs via the real interpreter.
506        let sh = NativeShell::new();
507        let out = sh.run("python3 -c \"print(6*7)\"", None, 10, 0, std::path::Path::new("."));
508        if out.exit_code == 0 {
509            assert!(out.stdout.contains("42"));
510        }
511    }
512}