Skip to main content

atd_tools_shell/
exec.rs

1//! `ref:shell.exec` — POSIX shell command execution (bash -c).
2
3use std::sync::OnceLock;
4use std::time::{Duration, Instant};
5
6use atd_protocol::{
7    BindingProtocol, SafetyLevel, ToolBinding, ToolCapability, ToolDefinition, ToolResources,
8    ToolSafety, ToolTrust, ToolVisibility, TrustLevel,
9};
10
11use crate::shared::{RunError, RunRequest, run};
12use atd_runtime::context::CallContext;
13use atd_runtime::error::ToolCallError;
14use atd_runtime::registry::{CallFuture, Tool};
15
16static DEFINITION: OnceLock<ToolDefinition> = OnceLock::new();
17
18fn definition() -> &'static ToolDefinition {
19    DEFINITION.get_or_init(|| ToolDefinition {
20        id: "ref:shell.exec".into(),
21        name: "Shell Execute".into(),
22        description: "Run a command via `bash -c`. Captures stdout/stderr separately (each capped at ctx.max_output_bytes/2), returns the exit code. Nonzero exit is not a tool error — the agent interprets exit codes itself.".into(),
23        version: "0.1.0".into(),
24        capability: ToolCapability {
25            domain: "shell".into(),
26            actions: vec!["exec".into()],
27            tags: vec!["shell".into(), "bash".into(), "subprocess".into()],
28            intent_examples: vec![
29                "run `ls -la`".into(),
30                "list files matching '*.rs' via shell".into(),
31            ],
32        },
33        input_schema: serde_json::json!({
34            "type": "object",
35            "properties": {
36                "command":  { "type": "string", "minLength": 1 },
37                "grace_ms": { "type": "integer", "minimum": 0 }
38            },
39            "required": ["command"]
40        }),
41        output_schema: serde_json::json!({
42            "type": "object",
43            "properties": {
44                "exit_code":        { "type": ["integer", "null"] },
45                "stdout":           { "type": "string" },
46                "stdout_truncated": { "type": "boolean" },
47                "stderr":           { "type": "string" },
48                "stderr_truncated": { "type": "boolean" },
49                "duration_ms":      { "type": "integer" }
50            }
51        }),
52        bindings: vec![ToolBinding {
53            protocol: BindingProtocol::Cli,
54            config: serde_json::json!({}),
55        }],
56        safety: ToolSafety {
57            level: SafetyLevel::Destructive,
58            dry_run: true,
59            side_effects: vec!["subprocess".into(), "filesystem".into(), "network".into()],
60            data_sensitivity: Some("depends on command".into()),
61        },
62        resources: ToolResources {
63            timeout_ms: 60_000,
64            max_concurrent: 10,
65            rate_limit_per_min: None,
66            estimated_tokens: Some(500),
67        },
68        trust: ToolTrust {
69            publisher: "atd-ref-server".into(),
70            trust_level: TrustLevel::L2Tested,
71            signature: None,
72        },
73        visibility: ToolVisibility::Dangerous,
74        required_capabilities: vec![],
75        tier: None,
76        errors: vec![],
77    })
78}
79
80pub struct ShellExecTool;
81
82impl ShellExecTool {
83    pub fn new() -> Self {
84        Self
85    }
86}
87
88impl Default for ShellExecTool {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94#[derive(serde::Deserialize)]
95struct ExecArgs {
96    command: String,
97    #[serde(default)]
98    grace_ms: Option<u64>,
99}
100
101impl Tool for ShellExecTool {
102    fn definition(&self) -> &ToolDefinition {
103        definition()
104    }
105
106    fn call<'a>(&'a self, args: serde_json::Value, ctx: &'a CallContext) -> CallFuture<'a> {
107        Box::pin(async move {
108            let args: ExecArgs = serde_json::from_value(args)
109                .map_err(|e| ToolCallError::InvalidArgs(e.to_string()))?;
110            if args.command.trim().is_empty() {
111                return Err(ToolCallError::InvalidArgs(
112                    "command is empty or whitespace-only".into(),
113                ));
114            }
115
116            let deadline = ctx.deadline.or_else(|| {
117                // Fallback: small default if no server-side deadline was set
118                Some(Instant::now() + Duration::from_secs(60))
119            });
120
121            let half = ctx.max_output_bytes / 2;
122            let req = RunRequest {
123                program: "bash",
124                args: &["-c", &args.command],
125                cwd: &ctx.cwd,
126                deadline,
127                grace_ms: args.grace_ms.unwrap_or(1000),
128                max_stdout_bytes: half,
129                max_stderr_bytes: half,
130            };
131
132            match run(req).await {
133                Ok(out) => Ok(serde_json::json!({
134                    "exit_code": out.exit_code,
135                    "stdout": out.stdout,
136                    "stdout_truncated": out.stdout_truncated,
137                    "stderr": out.stderr,
138                    "stderr_truncated": out.stderr_truncated,
139                    "duration_ms": out.duration_ms,
140                })),
141                Err(RunError::NotFound { program }) => Err(ToolCallError::ExecutionFailed {
142                    code: "NOT_AVAILABLE".into(),
143                    message: format!("{program} not on PATH"),
144                    retryable: false,
145                }),
146                Err(RunError::TimedOut { after_ms }) => Err(ToolCallError::ExecutionFailed {
147                    code: "TIMEOUT".into(),
148                    message: format!("command timed out after {after_ms}ms"),
149                    retryable: true,
150                }),
151                Err(RunError::SpawnFailed(e)) | Err(RunError::Io(e)) => {
152                    Err(ToolCallError::ExecutionFailed {
153                        code: "IO".into(),
154                        message: format!("io: {e}"),
155                        retryable: true,
156                    })
157                }
158            }
159        })
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[tokio::test]
168    async fn happy_path_echo() {
169        let t = ShellExecTool::new();
170        let ctx = CallContext::for_test();
171        let r = t
172            .call(serde_json::json!({"command": "echo hi"}), &ctx)
173            .await
174            .unwrap();
175        assert_eq!(r["exit_code"], 0);
176        assert_eq!(r["stdout"], "hi\n");
177        assert_eq!(r["stderr"], "");
178    }
179
180    #[tokio::test]
181    async fn stderr_propagates() {
182        let t = ShellExecTool::new();
183        let ctx = CallContext::for_test();
184        let r = t
185            .call(
186                serde_json::json!({"command": ">&2 echo boom; exit 2"}),
187                &ctx,
188            )
189            .await
190            .unwrap();
191        assert_eq!(r["exit_code"], 2);
192        assert_eq!(r["stderr"], "boom\n");
193    }
194
195    #[tokio::test]
196    async fn nonzero_exit_code_is_not_a_tool_error() {
197        let t = ShellExecTool::new();
198        let ctx = CallContext::for_test();
199        let r = t
200            .call(serde_json::json!({"command": "false"}), &ctx)
201            .await
202            .unwrap();
203        assert_eq!(r["exit_code"], 1);
204    }
205
206    #[tokio::test]
207    async fn timeout_returns_execution_failed() {
208        let t = ShellExecTool::new();
209        let mut ctx = CallContext::for_test();
210        ctx.deadline = Some(Instant::now() + Duration::from_millis(200));
211        let err = t
212            .call(
213                serde_json::json!({"command": "sleep 10", "grace_ms": 50}),
214                &ctx,
215            )
216            .await
217            .unwrap_err();
218        match err {
219            ToolCallError::ExecutionFailed {
220                code, retryable, ..
221            } => {
222                assert_eq!(code, "TIMEOUT");
223                assert!(retryable);
224            }
225            _ => panic!("expected TIMEOUT"),
226        }
227    }
228
229    #[tokio::test]
230    async fn empty_command_is_invalid_args() {
231        let t = ShellExecTool::new();
232        let ctx = CallContext::for_test();
233        let err = t
234            .call(serde_json::json!({"command": "   "}), &ctx)
235            .await
236            .unwrap_err();
237        assert!(matches!(err, ToolCallError::InvalidArgs(_)));
238    }
239
240    #[tokio::test]
241    async fn grace_ms_override_respected() {
242        // Can't directly observe SIGTERM vs SIGKILL, but we can verify the
243        // call doesn't take longer than deadline + grace for a sleep that
244        // ignores SIGTERM (sleep handles SIGTERM and exits cleanly, so this
245        // tests the happy-exit path after SIGTERM). Acceptable proxy.
246        let t = ShellExecTool::new();
247        let mut ctx = CallContext::for_test();
248        ctx.deadline = Some(Instant::now() + Duration::from_millis(150));
249        let start = Instant::now();
250        let _ = t
251            .call(
252                serde_json::json!({"command": "sleep 10", "grace_ms": 200}),
253                &ctx,
254            )
255            .await;
256        let elapsed = start.elapsed();
257        // Deadline + grace + small overhead — well under the 10s sleep.
258        assert!(elapsed < Duration::from_secs(2), "too slow: {elapsed:?}");
259    }
260}