oxedyne/daimond/src/executor.rs
5.1 KiB, 1 run
created by r2519314175:945, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Command execution backend for the agent's shell tool. |
| 2 | //! |
| 3 | //! `Executor` is a pluggable boundary (plan D4): the `Local` variant |
| 4 | //! runs commands directly under the Daimond process's user, in a given |
| 5 | //! working directory, with a timeout. It is a *run location*, not a |
| 6 | //! security sandbox — appropriate for the trusted, self-hosted |
| 7 | //! environment (plan D0). A future `Remote` variant can offload |
| 8 | //! execution to another host without changing callers. |
| 9 | |
| 10 | use oxedyne_fe2o3_core::prelude::*; |
| 11 | |
| 12 | use std::path::Path; |
| 13 | #[cfg(not(target_arch = "wasm32"))] |
| 14 | use std::time::Duration; |
| 15 | |
| 16 | |
| 17 | /// Where and how the agent runs shell commands. |
| 18 | #[derive(Clone, Debug)] |
| 19 | pub enum Executor { |
| 20 | /// Run locally under the Daimond process's user, capped by `timeout`. |
| 21 | #[cfg(not(target_arch = "wasm32"))] |
| 22 | Local { timeout: Duration }, |
| 23 | /// Run inside the browser (wasm32). In-browser shell execution is |
| 24 | /// not yet wired up, so a command escalates rather than running. |
| 25 | // TODO(wasm-exec): route shell commands to an in-browser sandbox or |
| 26 | // a server round-trip once the browser tool surface is built. |
| 27 | #[cfg(target_arch = "wasm32")] |
| 28 | Wasm, |
| 29 | } |
| 30 | |
| 31 | /// The captured result of a command. |
| 32 | #[derive(Clone, Debug)] |
| 33 | pub struct CommandOutput { |
| 34 | pub stdout: String, |
| 35 | pub stderr: String, |
| 36 | pub exit_code: i32, |
| 37 | pub timed_out: bool, |
| 38 | } |
| 39 | |
| 40 | impl Executor { |
| 41 | |
| 42 | /// A local executor with a sensible default timeout. |
| 43 | #[cfg(not(target_arch = "wasm32"))] |
| 44 | pub fn local_default() -> Self { |
| 45 | Self::Local { timeout: Duration::from_secs(120) } |
| 46 | } |
| 47 | |
| 48 | /// Run `command` via `sh -c` in `cwd`, capturing stdout/stderr. |
| 49 | /// |
| 50 | /// On timeout the child is killed (via `kill_on_drop`) and a |
| 51 | /// `timed_out` result is returned rather than an error. On wasm32 |
| 52 | /// there is no local process backend, so this escalates. |
| 53 | pub async fn run(&self, command: &str, cwd: &Path) -> Outcome<CommandOutput> { |
| 54 | match self { |
| 55 | #[cfg(not(target_arch = "wasm32"))] |
| 56 | Self::Local { timeout } => { |
| 57 | use tokio::process::Command; |
| 58 | let mut cmd = Command::new("sh"); |
| 59 | cmd.arg("-c").arg(command) |
| 60 | .current_dir(cwd) |
| 61 | .stdin(std::process::Stdio::null()) |
| 62 | .stdout(std::process::Stdio::piped()) |
| 63 | .stderr(std::process::Stdio::piped()) |
| 64 | .kill_on_drop(true); |
| 65 | let child = res!(cmd.spawn() |
| 66 | .map_err(|e| err!(e, "Executor: failed to spawn command."; IO, Init))); |
| 67 | match tokio::time::timeout(*timeout, child.wait_with_output()).await { |
| 68 | Ok(Ok(out)) => Ok(CommandOutput { |
| 69 | stdout: String::from_utf8_lossy(&out.stdout).to_string(), |
| 70 | stderr: String::from_utf8_lossy(&out.stderr).to_string(), |
| 71 | exit_code: out.status.code().unwrap_or(-1), |
| 72 | timed_out: false, |
| 73 | }), |
| 74 | Ok(Err(e)) => Err(err!(e, "Executor: waiting on command failed."; IO)), |
| 75 | Err(_) => Ok(CommandOutput { |
| 76 | stdout: String::new(), |
| 77 | stderr: fmt!("Command timed out after {} seconds.", timeout.as_secs()), |
| 78 | exit_code: -1, |
| 79 | timed_out: true, |
| 80 | }), |
| 81 | } |
| 82 | } |
| 83 | #[cfg(target_arch = "wasm32")] |
| 84 | Self::Wasm => { |
| 85 | // In-browser shell execution is not yet available; the |
| 86 | // caller must escalate this to a server round-trip. |
| 87 | let _ = (command, cwd); |
| 88 | Err(err!( |
| 89 | "Executor: in-browser shell execution is not yet \ |
| 90 | supported; escalation required."; |
| 91 | Unimplemented)) |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | |
| 98 | // ┌───────────────────────────────────────────────────────────────┐ |
| 99 | // │ Tests │ |
| 100 | // └───────────────────────────────────────────────────────────────┘ |
| 101 | |
| 102 | #[cfg(test)] |
| 103 | mod tests { |
| 104 | use super::*; |
| 105 | |
| 106 | #[tokio::test] |
| 107 | async fn test_run_echo() { |
| 108 | let ex = Executor::local_default(); |
| 109 | let out = ex.run("echo hello", Path::new("/tmp")).await.expect("run"); |
| 110 | assert_eq!(out.stdout.trim(), "hello"); |
| 111 | assert_eq!(out.exit_code, 0); |
| 112 | assert!(!out.timed_out); |
| 113 | } |
| 114 | |
| 115 | #[tokio::test] |
| 116 | async fn test_run_exit_code() { |
| 117 | let ex = Executor::local_default(); |
| 118 | let out = ex.run("exit 3", Path::new("/tmp")).await.expect("run"); |
| 119 | assert_eq!(out.exit_code, 3); |
| 120 | } |
| 121 | |
| 122 | #[tokio::test] |
| 123 | async fn test_run_timeout() { |
| 124 | let ex = Executor::Local { timeout: Duration::from_millis(200) }; |
| 125 | let out = ex.run("sleep 5", Path::new("/tmp")).await.expect("run"); |
| 126 | assert!(out.timed_out); |
| 127 | } |
| 128 | } |