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