Skip to main content

minion_engine/steps/
cmd.rs

1use std::time::{Duration, Instant};
2
3use async_trait::async_trait;
4use tokio::process::Command;
5
6use crate::config::StepConfig;
7use crate::engine::context::Context;
8use crate::error::StepError;
9use crate::workflow::schema::StepDef;
10
11use super::{CmdOutput, SandboxAwareExecutor, SharedSandbox, StepExecutor, StepOutput};
12
13pub struct CmdExecutor;
14
15#[async_trait]
16impl StepExecutor for CmdExecutor {
17    async fn execute(
18        &self,
19        step: &StepDef,
20        config: &StepConfig,
21        ctx: &Context,
22    ) -> Result<StepOutput, StepError> {
23        self.execute_sandboxed(step, config, ctx, &None).await
24    }
25}
26
27#[async_trait]
28impl SandboxAwareExecutor for CmdExecutor {
29    async fn execute_sandboxed(
30        &self,
31        step: &StepDef,
32        config: &StepConfig,
33        ctx: &Context,
34        sandbox: &SharedSandbox,
35    ) -> Result<StepOutput, StepError> {
36        let run_template = step
37            .run
38            .as_ref()
39            .ok_or_else(|| StepError::Fail("cmd step missing 'run' field".into()))?;
40
41        let command = ctx.render_template(run_template)?;
42        let timeout = config
43            .get_duration("timeout")
44            .unwrap_or(Duration::from_secs(60));
45        let fail_on_error = config.get_bool("fail_on_error");
46
47        let start = Instant::now();
48
49        // ── Sandbox path: run inside Docker container ─────────────────────
50        let result = if let Some(sb) = sandbox {
51            let sb_guard = sb.lock().await;
52            let sb_output = tokio::time::timeout(timeout, sb_guard.run_command(&command))
53                .await
54                .map_err(|_| StepError::Timeout(timeout))?
55                .map_err(|e| StepError::Fail(format!("Sandbox command failed: {e}")))?;
56
57            CmdOutput {
58                stdout: sb_output.stdout,
59                stderr: sb_output.stderr,
60                exit_code: sb_output.exit_code,
61                duration: start.elapsed(),
62            }
63        } else {
64            // ── Host path: run directly on host ───────────────────────────
65            let shell = config.get_str("shell").unwrap_or("/bin/bash");
66            let working_dir = config.get_str("working_directory").map(String::from);
67
68            let mut cmd = Command::new(shell);
69            cmd.arg("-c").arg(&command);
70            cmd.stdout(std::process::Stdio::piped());
71            cmd.stderr(std::process::Stdio::piped());
72
73            // Ensure common tool directories are in PATH (gh, cargo, brew, etc.)
74            let current_path = std::env::var("PATH").unwrap_or_default();
75            let extra_dirs = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/sbin"];
76            let mut full_path = current_path.clone();
77            for dir in &extra_dirs {
78                if !current_path.contains(dir) {
79                    full_path = format!("{}:{}", dir, full_path);
80                }
81            }
82            // Also include ~/.cargo/bin for Rust tools
83            if let Ok(home) = std::env::var("HOME") {
84                let cargo_bin = format!("{}/.cargo/bin", home);
85                if !full_path.contains(&cargo_bin) {
86                    full_path = format!("{}:{}", cargo_bin, full_path);
87                }
88            }
89            cmd.env("PATH", &full_path);
90
91            if let Some(dir) = &working_dir {
92                cmd.current_dir(dir);
93            }
94
95            let output = tokio::time::timeout(timeout, cmd.output())
96                .await
97                .map_err(|_| StepError::Timeout(timeout))?
98                .map_err(|e| StepError::Fail(format!("Failed to spawn command: {e}")))?;
99
100            CmdOutput {
101                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
102                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
103                exit_code: output.status.code().unwrap_or(-1),
104                duration: start.elapsed(),
105            }
106        };
107
108        if fail_on_error && result.exit_code != 0 {
109            return Err(StepError::Fail(format!(
110                "Command failed (exit {}): {}",
111                result.exit_code,
112                result.stderr.trim()
113            )));
114        }
115
116        Ok(StepOutput::Cmd(result))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::collections::HashMap;
124
125    fn empty_step(run: &str) -> StepDef {
126        StepDef {
127            name: "test".to_string(),
128            step_type: crate::workflow::schema::StepType::Cmd,
129            run: Some(run.to_string()),
130            prompt: None,
131            condition: None,
132            on_pass: None,
133            on_fail: None,
134            message: None,
135            scope: None,
136            max_iterations: None,
137            initial_value: None,
138            items: None,
139            parallel: None,
140            steps: None,
141            config: HashMap::new(),
142            outputs: None,
143            output_type: None,
144            async_exec: None,
145        }
146    }
147
148    #[tokio::test]
149    async fn cmd_echo() {
150        let step = empty_step("echo hello");
151        let config = StepConfig::default();
152        let ctx = Context::new(String::new(), HashMap::new());
153
154        let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
155        assert_eq!(result.text().trim(), "hello");
156        assert_eq!(result.exit_code(), 0);
157    }
158
159    #[tokio::test]
160    async fn cmd_echo_via_sandbox_aware_no_sandbox() {
161        // When sandbox is None, SandboxAwareExecutor falls back to host execution
162        let step = empty_step("echo sandbox_test");
163        let config = StepConfig::default();
164        let ctx = Context::new(String::new(), HashMap::new());
165
166        let result = CmdExecutor
167            .execute_sandboxed(&step, &config, &ctx, &None)
168            .await
169            .unwrap();
170        assert_eq!(result.text().trim(), "sandbox_test");
171    }
172
173    #[tokio::test]
174    async fn cmd_exit_nonzero_without_fail_on_error() {
175        let step = empty_step("exit 42");
176        let config = StepConfig::default();
177        let ctx = Context::new(String::new(), HashMap::new());
178
179        let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
180        assert_eq!(result.exit_code(), 42);
181    }
182
183    #[tokio::test]
184    async fn cmd_exit_nonzero_with_fail_on_error() {
185        let step = empty_step("exit 1");
186        let mut values = HashMap::new();
187        values.insert(
188            "fail_on_error".to_string(),
189            serde_json::Value::Bool(true),
190        );
191        let config = StepConfig { values };
192        let ctx = Context::new(String::new(), HashMap::new());
193
194        let result = CmdExecutor.execute(&step, &config, &ctx).await;
195        assert!(result.is_err());
196    }
197
198    #[tokio::test]
199    async fn cmd_timeout() {
200        let step = empty_step("sleep 10");
201        let mut values = HashMap::new();
202        values.insert(
203            "timeout".to_string(),
204            serde_json::Value::String("100ms".to_string()),
205        );
206        let config = StepConfig { values };
207        let ctx = Context::new(String::new(), HashMap::new());
208
209        let result = CmdExecutor.execute(&step, &config, &ctx).await;
210        assert!(matches!(result, Err(crate::error::StepError::Timeout(_))));
211    }
212
213    #[tokio::test]
214    async fn cmd_working_directory() {
215        let step = empty_step("pwd");
216        let mut values = HashMap::new();
217        values.insert(
218            "working_directory".to_string(),
219            serde_json::Value::String("/tmp".to_string()),
220        );
221        let config = StepConfig { values };
222        let ctx = Context::new(String::new(), HashMap::new());
223
224        let result = CmdExecutor.execute(&step, &config, &ctx).await.unwrap();
225        // /tmp resolves to /private/tmp on macOS, so check contains "tmp"
226        assert!(result.text().contains("tmp"));
227    }
228}